ArunKr commited on
Commit
921792c
·
verified ·
1 Parent(s): 8f88962

Upload folder using huggingface_hub

Browse files
.env.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Supabase
2
+ SUPABASE_URL=
3
+ SUPABASE_KEY=
4
+
5
+ # Chat defaults (UI convenience)
6
+ DEFAULT_BASE_URL=https://router.huggingface.co/v1
7
+ DEFAULT_API_KEY=
8
+ DEFAULT_MODEL=gpt-3.5-turbo
9
+
10
+ # Feature flags (safe defaults depend on Supabase being configured)
11
+ ENABLE_TERMINAL=1
12
+ ENABLE_CODEX=1
13
+ ENABLE_MCP=1
14
+ ENABLE_INDEXING=0
15
+
16
+ # Optional: SSH via secrets
17
+ SSH_PRIVATE_KEY=
18
+ SSH_PUBLIC_KEY=
19
+ SSH_KNOWN_HOSTS=
20
+
.github/workflows/ci.yml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: ["main"]
7
+
8
+ jobs:
9
+ python:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Setup Python
16
+ uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.11"
19
+
20
+ - name: Install dependencies
21
+ run: |
22
+ python -m pip install --upgrade pip
23
+ pip install -r requirements.txt -r requirements-dev.txt
24
+
25
+ - name: Lint (ruff)
26
+ run: ruff check .
27
+
28
+ - name: Tests (pytest)
29
+ run: pytest -q
30
+
PLANS.md CHANGED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Roadmap (P0–P3)
2
+
3
+ This file is the repo-level roadmap for `autonomy-labs`. It’s intentionally opinionated and ordered by risk reduction first, then maintainability, then feature expansion.
4
+
5
+ ## P0 — Security + correctness (blockers)
6
+ - Gate **all dangerous endpoints** server-side (not just UI):
7
+ - `/ws/terminal`
8
+ - `/api/codex*`
9
+ - `/api/mcp*`
10
+ - any indexing endpoints (docs/web/GitHub)
11
+ - Define a clear auth transport for WebSockets (cookie or token) and verify on the server.
12
+ - Add capability flags with safe defaults:
13
+ - `ENABLE_TERMINAL`, `ENABLE_CODEX`, `ENABLE_MCP`, `ENABLE_INDEXING`
14
+ - Add `SECURITY.md` with threat model + safe deployment guidance.
15
+
16
+ ## P1 — Backend refactor + lifecycle
17
+ - Split `main.py` into routers/services:
18
+ - `app/auth.py`, `app/chat.py`, `app/terminal.py`, `app/codex.py`, `app/mcp.py`, `app/settings.py`, `app/admin.py`, `app/indexing.py`
19
+ - Add FastAPI lifespan management:
20
+ - subprocess lifecycle (Codex MCP server)
21
+ - cleanup policies (device-login attempts, job registries)
22
+ - Unify Codex integration (prefer CLI-first for device-auth consistency; keep SDK only if needed).
23
+ - Standardize API error schema (UI should not parse strings to detect failure modes).
24
+
25
+ ## P2 — UI/UX, settings, admin, landing
26
+ - Split `static/dashboard.html` into modules:
27
+ - `static/dashboard.js`, `static/terminal.js`, `static/agent.js`, `static/settings.js`, `static/admin.js`, `static/mcp.js`, `static/rag.js`
28
+ - `static/theme.css`
29
+ - Fix UI inconsistencies:
30
+ - theme tokens shared across login + dashboard
31
+ - consistent spacing, typography, button states, error banners
32
+ - terminal sizing/fit reliability (debounce + visible-only fitting)
33
+ - Separate Settings vs Admin dashboard:
34
+ - Settings: provider configs, tokens status, terminal layout, workspace directory, MCP registry
35
+ - Admin: user/role management, global toggles, indexing jobs, audit logs
36
+ - Create a “blazing” landing page:
37
+ - `/` marketing/intro + CTA
38
+ - keep `/login` and `/app` as dedicated routes (or similar)
39
+
40
+ ## P2 — Provider auth parity (Codex/Gemini/Claude)
41
+ - Keep provider auth out of git; source from env/HF Secrets.
42
+ - Support “Codex-like” auth file generation when a CLI requires it:
43
+ - Codex: `~/.codex/.auth.json` and `~/.codex/auth.json` from `CODEX_*` (or fallback envs).
44
+ - Gemini/Claude: prefer env (`GEMINI_API_KEY`, `ANTHROPIC_API_KEY`); add file-based auth only if required and documented.
45
+ - Optional: SSH key support via Secrets:
46
+ - `SSH_PRIVATE_KEY` (+ optional `SSH_PUBLIC_KEY`, `SSH_KNOWN_HOSTS`)
47
+
48
+ ## P2 — Codex workspace directory (UI)
49
+ - Add a per-user “workspace directory” setting.
50
+ - Enforce an allowlisted root (e.g. `/data/codex/workspace/<user>`), prevent traversal, ensure it exists.
51
+
52
+ ## P2 — Stream Codex events in Agent mode
53
+ - Use `/api/codex/cli/stream` for agent execution.
54
+ - UI: render streaming events progressively (agent text, tool events, final summary + usage).
55
+ - Add stop/reconnect handling.
56
+
57
+ ## P2/P3 — MCP registry
58
+ - Add a first-class MCP registry:
59
+ - per-user servers + optional global templates
60
+ - “test connection”, “list tools”, allow/deny tool lists
61
+ - import/export `mcp.json`
62
+
63
+ ## P3 — RAG + indexing (docs/web/GitHub) + “password manager”
64
+ - Clarify “password manager” scope:
65
+ - secure vault for secrets (high-risk; encryption + audit required), or
66
+ - indexed notes (lower-risk but still private)
67
+ - Implement indexing connectors:
68
+ - document uploads
69
+ - website crawl (depth, allowlist, robots, rate limits)
70
+ - GitHub repo indexing (branch/path filters, token support via Secrets)
71
+ - Build a jobs UI: progress, retries, errors, and access controls.
72
+
73
+ ## P3 — P2P pubsub chat + account manager
74
+ - Implement account manager concepts:
75
+ - identities/devices, room/topic membership, permissions, moderation tools
76
+ - Transport:
77
+ - WebRTC DataChannel (P2P) + server signaling
78
+ - fallback to server pubsub when P2P fails
79
+ - UX:
80
+ - rooms, presence, delivery status, network mode indicators
81
+
82
+ ## Engineering hygiene (ongoing)
83
+ - Add `.env.example`, `docs/TROUBLESHOOTING.md`, `docs/ARCHITECTURE.md`, `docs/SECURITY_DEPLOYMENT.md`
84
+ - Add lint/tests + CI:
85
+ - Python: `ruff`, `pytest`
86
+ - basic security smoke tests for endpoint gating
87
+
app/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Application package for autonomy-labs."""
2
+
app/auth.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from typing import Any
6
+
7
+ from fastapi import HTTPException, Request
8
+
9
+ _SUPABASE_TOKEN_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
10
+
11
+
12
+ async def verify_supabase_access_token(access_token: str) -> dict[str, Any]:
13
+ """
14
+ Verifies a Supabase access token by calling Supabase Auth `GET /auth/v1/user`.
15
+ Uses a small in-memory TTL cache to avoid calling Supabase on every request.
16
+ """
17
+ access_token = (access_token or "").strip()
18
+ if not access_token:
19
+ raise HTTPException(status_code=401, detail="Missing access token")
20
+
21
+ now = time.time()
22
+ cached = _SUPABASE_TOKEN_CACHE.get(access_token)
23
+ if cached and (now - cached[0]) < 30:
24
+ return cached[1]
25
+
26
+ supabase_url = os.environ.get("SUPABASE_URL")
27
+ supabase_key = os.environ.get("SUPABASE_KEY")
28
+ if not supabase_url or not supabase_key:
29
+ raise HTTPException(status_code=503, detail="Supabase is not configured")
30
+
31
+ import httpx
32
+
33
+ headers = {"Authorization": f"Bearer {access_token}", "apikey": supabase_key}
34
+ url = f"{supabase_url.rstrip('/')}/auth/v1/user"
35
+ async with httpx.AsyncClient(timeout=10.0) as client:
36
+ resp = await client.get(url, headers=headers)
37
+ if resp.status_code != 200:
38
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
39
+
40
+ user = resp.json()
41
+ _SUPABASE_TOKEN_CACHE[access_token] = (now, user)
42
+
43
+ # Best-effort cache bound.
44
+ if len(_SUPABASE_TOKEN_CACHE) > 500:
45
+ for k in list(_SUPABASE_TOKEN_CACHE.keys())[:200]:
46
+ _SUPABASE_TOKEN_CACHE.pop(k, None)
47
+ return user
48
+
49
+
50
+ async def require_user_from_request(request: Request) -> dict[str, Any]:
51
+ auth = (request.headers.get("authorization") or "").strip()
52
+ if not auth.lower().startswith("bearer "):
53
+ raise HTTPException(status_code=401, detail="Missing Authorization bearer token")
54
+ token = auth.split(None, 1)[1].strip()
55
+ return await verify_supabase_access_token(token)
56
+
app/mcp_client.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Optional
6
+
7
+ from fastapi import HTTPException
8
+
9
+
10
+ class McpStdioClient:
11
+ def __init__(self, command: list[str]):
12
+ self.command = command
13
+ self.proc: Optional[asyncio.subprocess.Process] = None
14
+ self._lock = asyncio.Lock()
15
+ self._pending: dict[int, asyncio.Future] = {}
16
+ self._next_id = 1
17
+ self._reader_task: Optional[asyncio.Task] = None
18
+ self._initialized = False
19
+
20
+ async def start(self) -> None:
21
+ if self.proc and self.proc.returncode is None:
22
+ return
23
+ self.proc = await asyncio.create_subprocess_exec(
24
+ *self.command,
25
+ stdin=asyncio.subprocess.PIPE,
26
+ stdout=asyncio.subprocess.PIPE,
27
+ stderr=asyncio.subprocess.PIPE,
28
+ )
29
+ self._initialized = False
30
+ self._reader_task = asyncio.create_task(self._reader())
31
+ await self._initialize()
32
+
33
+ async def close(self) -> None:
34
+ if not self.proc:
35
+ return
36
+ try:
37
+ if self.proc.returncode is None:
38
+ self.proc.terminate()
39
+ await asyncio.wait_for(self.proc.wait(), timeout=5.0)
40
+ except Exception:
41
+ try:
42
+ self.proc.kill()
43
+ except Exception:
44
+ pass
45
+ finally:
46
+ self.proc = None
47
+ self._initialized = False
48
+ if self._reader_task:
49
+ self._reader_task.cancel()
50
+ self._reader_task = None
51
+
52
+ async def _reader(self) -> None:
53
+ assert self.proc and self.proc.stdout
54
+ while True:
55
+ line = await self.proc.stdout.readline()
56
+ if not line:
57
+ break
58
+ text = line.decode("utf-8", errors="ignore").strip()
59
+ if not text:
60
+ continue
61
+ try:
62
+ msg = json.loads(text)
63
+ except Exception:
64
+ continue
65
+ msg_id = msg.get("id")
66
+ if msg_id is None:
67
+ continue
68
+ fut = self._pending.pop(int(msg_id), None)
69
+ if fut and not fut.done():
70
+ fut.set_result(msg)
71
+
72
+ async def _rpc(self, method: str, params: Optional[dict] = None) -> dict:
73
+ await self.start()
74
+ assert self.proc and self.proc.stdin
75
+ async with self._lock:
76
+ msg_id = self._next_id
77
+ self._next_id += 1
78
+ loop = asyncio.get_running_loop()
79
+ fut: asyncio.Future = loop.create_future()
80
+ self._pending[msg_id] = fut
81
+ payload = {"jsonrpc": "2.0", "id": msg_id, "method": method}
82
+ if params is not None:
83
+ payload["params"] = params
84
+ self.proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
85
+ await self.proc.stdin.drain()
86
+ resp = await asyncio.wait_for(fut, timeout=600.0)
87
+ if "error" in resp:
88
+ raise HTTPException(status_code=500, detail=resp["error"])
89
+ return resp.get("result") or {}
90
+
91
+ async def _initialize(self) -> None:
92
+ if self._initialized:
93
+ return
94
+ _ = await self._rpc(
95
+ "initialize",
96
+ {
97
+ "protocolVersion": "2024-11-05",
98
+ "clientInfo": {"name": "autonomy-labs", "version": "1.0"},
99
+ "capabilities": {},
100
+ },
101
+ )
102
+ assert self.proc and self.proc.stdin
103
+ self.proc.stdin.write(
104
+ (json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n").encode("utf-8")
105
+ )
106
+ await self.proc.stdin.drain()
107
+ self._initialized = True
108
+
109
+ async def list_tools(self) -> dict:
110
+ return await self._rpc("tools/list", {})
111
+
112
+ async def call_tool(self, name: str, arguments: dict) -> dict:
113
+ return await self._rpc("tools/call", {"name": name, "arguments": arguments})
114
+
app/routes/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """FastAPI routers."""
2
+
app/routes/base.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter
7
+ from fastapi.responses import FileResponse
8
+
9
+ router = APIRouter()
10
+
11
+ _ROOT = Path(__file__).resolve().parents[2]
12
+ _STATIC = _ROOT / "static"
13
+
14
+
15
+ @router.get("/config")
16
+ async def get_config():
17
+ return {
18
+ "supabase_url": os.environ.get("SUPABASE_URL", "https://znhglkwefxdhgajvrqmb.supabase.co"),
19
+ "supabase_key": os.environ.get("SUPABASE_KEY"),
20
+ "default_base_url": os.environ.get("DEFAULT_BASE_URL", "https://router.huggingface.co/v1"),
21
+ "default_api_key": os.environ.get("DEFAULT_API_KEY", ""),
22
+ "default_model": os.environ.get("DEFAULT_MODEL", "gpt-3.5-turbo"),
23
+ }
24
+
25
+
26
+ @router.get("/")
27
+ async def read_index():
28
+ return FileResponse(str(_STATIC / "index.html"))
29
+
30
+
31
+ @router.get("/health")
32
+ async def health_check():
33
+ return {"status": "ok"}
34
+
app/routes/chat.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, List, Optional, Union
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+ from fastapi.responses import StreamingResponse
8
+ from openai import OpenAI
9
+ from pydantic import BaseModel
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ class ChatMessage(BaseModel):
15
+ role: str
16
+ # OpenAI-compatible: content can be plain text or an array of multimodal parts.
17
+ content: Union[str, List[Any]]
18
+
19
+
20
+ class ChatRequest(BaseModel):
21
+ messages: List[ChatMessage]
22
+ apiKey: Optional[str] = None
23
+ baseUrl: Optional[str] = None
24
+ model: Optional[str] = "gpt-3.5-turbo"
25
+
26
+
27
+ @router.post("/api/chat")
28
+ async def chat_endpoint(request: ChatRequest):
29
+ api_key = request.apiKey or os.environ.get("OPENAI_API_KEY")
30
+ base_url = request.baseUrl or os.environ.get("OPENAI_BASE_URL")
31
+
32
+ if not api_key:
33
+ raise HTTPException(status_code=400, detail="API Key is required")
34
+
35
+ client = OpenAI(api_key=api_key, base_url=base_url)
36
+
37
+ def generate():
38
+ try:
39
+ stream = client.chat.completions.create(
40
+ model=request.model,
41
+ messages=[{"role": m.role, "content": m.content} for m in request.messages],
42
+ stream=True,
43
+ )
44
+ for chunk in stream:
45
+ if chunk.choices[0].delta.content:
46
+ yield chunk.choices[0].delta.content
47
+ except Exception as e:
48
+ yield f"Error: {str(e)}"
49
+
50
+ return StreamingResponse(generate(), media_type="text/plain")
51
+
52
+
53
+ class ModelsRequest(BaseModel):
54
+ apiKey: Optional[str] = None
55
+ baseUrl: Optional[str] = None
56
+
57
+
58
+ @router.post("/api/proxy/models")
59
+ async def proxy_models(request: ModelsRequest):
60
+ api_key = request.apiKey or os.environ.get("OPENAI_API_KEY")
61
+ base_url = request.baseUrl or os.environ.get("OPENAI_BASE_URL")
62
+
63
+ if not base_url:
64
+ raise HTTPException(status_code=400, detail="Base URL is required")
65
+
66
+ try:
67
+ import httpx
68
+
69
+ headers = {}
70
+ if api_key:
71
+ headers["Authorization"] = f"Bearer {api_key}"
72
+
73
+ target_url = f"{base_url.rstrip('/')}/models"
74
+
75
+ async with httpx.AsyncClient() as client:
76
+ resp = await client.get(target_url, headers=headers, timeout=10.0)
77
+ if resp.status_code != 200:
78
+ raise HTTPException(status_code=resp.status_code, detail=f"Provider returned error: {resp.text}")
79
+ return resp.json()
80
+ except HTTPException:
81
+ raise
82
+ except Exception as e:
83
+ raise HTTPException(status_code=500, detail=str(e))
84
+
app/routes/codex.py ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+ from fastapi.responses import StreamingResponse
13
+ from pydantic import BaseModel
14
+
15
+ from app.auth import require_user_from_request
16
+ from app.settings import feature_enabled
17
+ from app.workdir import safe_user_workdir
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ class CodexRequest(BaseModel):
23
+ message: str
24
+ threadId: Optional[str] = None
25
+ model: Optional[str] = None
26
+ sandboxMode: Optional[str] = "workspace-write"
27
+ approvalPolicy: Optional[str] = "never"
28
+ apiKey: Optional[str] = None
29
+ baseUrl: Optional[str] = None
30
+ modelReasoningEffort: Optional[str] = "minimal"
31
+ workingDirectory: Optional[str] = None
32
+
33
+
34
+ @router.post("/api/codex")
35
+ async def codex_agent(request: CodexRequest, http_request: Request):
36
+ if not request.message.strip():
37
+ raise HTTPException(status_code=400, detail="Message is required")
38
+ if not feature_enabled("codex"):
39
+ raise HTTPException(status_code=403, detail="Codex is disabled")
40
+
41
+ user = await require_user_from_request(http_request)
42
+
43
+ node = os.environ.get("NODE_BIN", "node")
44
+ root = Path(__file__).resolve().parents[2]
45
+ script_path = root / "codex_agent.mjs"
46
+ if not script_path.exists():
47
+ raise HTTPException(status_code=500, detail="codex_agent.mjs not found")
48
+
49
+ payload = {
50
+ "message": request.message,
51
+ "threadId": request.threadId,
52
+ "model": request.model,
53
+ "sandboxMode": request.sandboxMode,
54
+ "approvalPolicy": request.approvalPolicy,
55
+ "modelReasoningEffort": request.modelReasoningEffort,
56
+ "workingDirectory": safe_user_workdir(user, request.workingDirectory),
57
+ }
58
+
59
+ try:
60
+ env = os.environ.copy()
61
+ env.setdefault("CODEX_PATH_OVERRIDE", "codex")
62
+ if request.apiKey:
63
+ env["CODEX_API_KEY"] = request.apiKey
64
+ env["OPENAI_API_KEY"] = request.apiKey
65
+ if request.apiKey and request.baseUrl:
66
+ env["OPENAI_BASE_URL"] = request.baseUrl
67
+
68
+ proc = await asyncio.create_subprocess_exec(
69
+ node,
70
+ str(script_path),
71
+ stdin=asyncio.subprocess.PIPE,
72
+ stdout=asyncio.subprocess.PIPE,
73
+ stderr=asyncio.subprocess.PIPE,
74
+ env=env,
75
+ )
76
+ stdout, stderr = await proc.communicate(json.dumps(payload).encode("utf-8"))
77
+ if proc.returncode != 0:
78
+ err_text = (stderr.decode("utf-8", errors="ignore") or "").strip()
79
+ if "401 Unauthorized" in err_text or "status 401" in err_text:
80
+ raise HTTPException(status_code=401, detail=err_text or "Unauthorized")
81
+ raise HTTPException(status_code=500, detail=(err_text or "Codex agent failed"))
82
+ return json.loads(stdout.decode("utf-8"))
83
+ except HTTPException:
84
+ raise
85
+ except Exception as e:
86
+ raise HTTPException(status_code=500, detail=str(e))
87
+
88
+
89
+ def _with_codex_agent_prefix(message: str) -> str:
90
+ msg = message.strip()
91
+ if msg.startswith("@"):
92
+ return message
93
+ return f"@codex {message}"
94
+
95
+
96
+ @router.post("/api/codex/cli")
97
+ async def codex_agent_cli(request: CodexRequest, http_request: Request):
98
+ if not request.message.strip():
99
+ raise HTTPException(status_code=400, detail="Message is required")
100
+ if not feature_enabled("codex"):
101
+ raise HTTPException(status_code=403, detail="Codex is disabled")
102
+
103
+ user = await require_user_from_request(http_request)
104
+ message = _with_codex_agent_prefix(request.message)
105
+
106
+ base_args = ["codex", "exec", "--json", "--color", "never", "--sandbox", request.sandboxMode or "workspace-write"]
107
+ if request.approvalPolicy:
108
+ base_args += ["--config", f'approval_policy="{request.approvalPolicy}"']
109
+ if request.model:
110
+ base_args += ["--model", request.model]
111
+ base_args += ["--cd", safe_user_workdir(user, request.workingDirectory), "--skip-git-repo-check"]
112
+
113
+ if request.threadId:
114
+ base_args += ["resume", request.threadId, message]
115
+ else:
116
+ base_args += [message]
117
+
118
+ env = os.environ.copy()
119
+ if request.apiKey:
120
+ env["OPENAI_API_KEY"] = request.apiKey
121
+ env["CODEX_API_KEY"] = request.apiKey
122
+ if request.baseUrl:
123
+ env["OPENAI_BASE_URL"] = request.baseUrl
124
+
125
+ try:
126
+ proc = await asyncio.create_subprocess_exec(
127
+ *base_args,
128
+ stdout=asyncio.subprocess.PIPE,
129
+ stderr=asyncio.subprocess.PIPE,
130
+ env=env,
131
+ )
132
+ stdout, stderr = await proc.communicate()
133
+
134
+ err_text = (stderr.decode("utf-8", errors="ignore") or "").strip()
135
+ if proc.returncode != 0:
136
+ out_text = (stdout.decode("utf-8", errors="ignore") or "").strip()
137
+ detail = err_text or out_text or "Codex CLI failed"
138
+ if "401 Unauthorized" in detail or "status 401" in detail:
139
+ raise HTTPException(status_code=401, detail=detail)
140
+ raise HTTPException(status_code=500, detail=detail)
141
+
142
+ thread_id = None
143
+ final_text = ""
144
+ usage = None
145
+ saw_event = False
146
+ for line in stdout.decode("utf-8", errors="ignore").splitlines():
147
+ line = line.strip()
148
+ if not line:
149
+ continue
150
+ try:
151
+ event = json.loads(line)
152
+ except Exception:
153
+ continue
154
+ saw_event = True
155
+ if event.get("type") == "thread.started":
156
+ thread_id = event.get("thread_id") or thread_id
157
+ if event.get("type") == "item.completed":
158
+ item = event.get("item") or {}
159
+ if item.get("type") == "agent_message":
160
+ final_text = item.get("text") or final_text
161
+ if event.get("type") == "turn.completed":
162
+ usage = event.get("usage") or usage
163
+ if event.get("type") == "turn.failed":
164
+ err = (event.get("error") or {}).get("message") or "Codex turn failed"
165
+ if "401" in err:
166
+ raise HTTPException(status_code=401, detail=err)
167
+ raise HTTPException(status_code=500, detail=err)
168
+
169
+ if not saw_event and err_text:
170
+ if "401 Unauthorized" in err_text or "status 401" in err_text:
171
+ raise HTTPException(status_code=401, detail=err_text)
172
+ if "Error:" in err_text or "Fatal error" in err_text:
173
+ raise HTTPException(status_code=500, detail=err_text)
174
+ if not saw_event and not final_text:
175
+ out_text = (stdout.decode("utf-8", errors="ignore") or "").strip()
176
+ if out_text:
177
+ raise HTTPException(status_code=500, detail=out_text)
178
+
179
+ return {"threadId": thread_id or request.threadId, "finalResponse": final_text, "usage": usage}
180
+ except HTTPException:
181
+ raise
182
+ except Exception as e:
183
+ raise HTTPException(status_code=500, detail=str(e))
184
+
185
+
186
+ @router.post("/api/codex/cli/stream")
187
+ async def codex_agent_cli_stream(request: CodexRequest, http_request: Request):
188
+ if not request.message.strip():
189
+ raise HTTPException(status_code=400, detail="Message is required")
190
+ if not feature_enabled("codex"):
191
+ raise HTTPException(status_code=403, detail="Codex is disabled")
192
+
193
+ user = await require_user_from_request(http_request)
194
+ message = _with_codex_agent_prefix(request.message)
195
+
196
+ base_args = ["codex", "exec", "--json", "--color", "never", "--sandbox", request.sandboxMode or "workspace-write"]
197
+ if request.approvalPolicy:
198
+ base_args += ["--config", f'approval_policy="{request.approvalPolicy}"']
199
+ if request.model:
200
+ base_args += ["--model", request.model]
201
+ base_args += ["--cd", safe_user_workdir(user, request.workingDirectory), "--skip-git-repo-check"]
202
+
203
+ if request.threadId:
204
+ base_args += ["resume", request.threadId, message]
205
+ else:
206
+ base_args += [message]
207
+
208
+ env = os.environ.copy()
209
+ if request.apiKey:
210
+ env["OPENAI_API_KEY"] = request.apiKey
211
+ env["CODEX_API_KEY"] = request.apiKey
212
+ if request.baseUrl:
213
+ env["OPENAI_BASE_URL"] = request.baseUrl
214
+
215
+ async def gen():
216
+ proc = await asyncio.create_subprocess_exec(
217
+ *base_args,
218
+ stdout=asyncio.subprocess.PIPE,
219
+ stderr=asyncio.subprocess.PIPE,
220
+ env=env,
221
+ )
222
+ assert proc.stdout is not None
223
+ assert proc.stderr is not None
224
+
225
+ thread_id = None
226
+ final_text = ""
227
+ usage = None
228
+
229
+ async def emit(obj: dict):
230
+ yield (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
231
+
232
+ try:
233
+ while True:
234
+ line = await proc.stdout.readline()
235
+ if not line:
236
+ break
237
+ raw = line.decode("utf-8", errors="ignore").strip()
238
+ if not raw:
239
+ continue
240
+ try:
241
+ event = json.loads(raw)
242
+ except Exception:
243
+ async for b in emit({"type": "log", "message": raw}):
244
+ yield b
245
+ continue
246
+
247
+ if event.get("type") == "thread.started":
248
+ thread_id = event.get("thread_id") or thread_id
249
+ if event.get("type") == "item.completed":
250
+ item = event.get("item") or {}
251
+ if item.get("type") == "agent_message":
252
+ final_text = item.get("text") or final_text
253
+ if event.get("type") == "turn.completed":
254
+ usage = event.get("usage") or usage
255
+ if event.get("type") == "turn.failed":
256
+ err = (event.get("error") or {}).get("message") or "Codex turn failed"
257
+ async for b in emit({"type": "error", "message": err}):
258
+ yield b
259
+ break
260
+
261
+ async for b in emit(event):
262
+ yield b
263
+ finally:
264
+ await proc.wait()
265
+ err_text = (await proc.stderr.read()).decode("utf-8", errors="ignore").strip()
266
+ if proc.returncode != 0 and err_text:
267
+ async for b in emit({"type": "stderr", "message": err_text, "returnCode": proc.returncode}):
268
+ yield b
269
+
270
+ async for b in emit(
271
+ {
272
+ "type": "done",
273
+ "threadId": thread_id or request.threadId,
274
+ "finalResponse": final_text,
275
+ "usage": usage,
276
+ "returnCode": proc.returncode,
277
+ }
278
+ ):
279
+ yield b
280
+
281
+ return StreamingResponse(gen(), media_type="application/x-ndjson")
282
+
283
+
284
+ @router.get("/api/codex/mcp")
285
+ async def codex_mcp_list(http_request: Request):
286
+ if not feature_enabled("mcp"):
287
+ raise HTTPException(status_code=403, detail="MCP is disabled")
288
+ _ = await require_user_from_request(http_request)
289
+ try:
290
+ proc = await asyncio.create_subprocess_exec(
291
+ "codex",
292
+ "mcp",
293
+ "list",
294
+ stdout=asyncio.subprocess.PIPE,
295
+ stderr=asyncio.subprocess.PIPE,
296
+ )
297
+ stdout, _stderr = await proc.communicate()
298
+ if proc.returncode != 0:
299
+ return {"servers": []}
300
+ text = stdout.decode("utf-8", errors="ignore")
301
+ servers = []
302
+ for line in text.splitlines():
303
+ name = (line.split() or [""])[0].strip()
304
+ if name and name.lower() != "name":
305
+ servers.append(name)
306
+ return {"servers": servers}
307
+ except Exception:
308
+ return {"servers": []}
309
+
310
+
311
+ @router.get("/api/codex/mcp/details")
312
+ async def codex_mcp_details(http_request: Request):
313
+ if not feature_enabled("mcp"):
314
+ raise HTTPException(status_code=403, detail="MCP is disabled")
315
+ _ = await require_user_from_request(http_request)
316
+ try:
317
+ servers_resp = await codex_mcp_list(http_request)
318
+ names = servers_resp.get("servers", []) if isinstance(servers_resp, dict) else []
319
+ details = []
320
+ for name in names:
321
+ try:
322
+ proc = await asyncio.create_subprocess_exec(
323
+ "codex",
324
+ "mcp",
325
+ "get",
326
+ name,
327
+ "--json",
328
+ stdout=asyncio.subprocess.PIPE,
329
+ stderr=asyncio.subprocess.PIPE,
330
+ )
331
+ stdout, _ = await proc.communicate()
332
+ if proc.returncode != 0:
333
+ continue
334
+ details.append(json.loads(stdout.decode("utf-8", errors="ignore")))
335
+ except Exception:
336
+ continue
337
+ return {"servers": details}
338
+ except Exception:
339
+ return {"servers": []}
340
+
341
+
342
+ @router.get("/api/codex/login/status")
343
+ async def codex_login_status(http_request: Request):
344
+ if not feature_enabled("codex"):
345
+ raise HTTPException(status_code=403, detail="Codex is disabled")
346
+ _ = await require_user_from_request(http_request)
347
+ try:
348
+ proc = await asyncio.create_subprocess_exec(
349
+ "codex",
350
+ "login",
351
+ "status",
352
+ stdout=asyncio.subprocess.PIPE,
353
+ stderr=asyncio.subprocess.PIPE,
354
+ )
355
+ stdout, stderr = await proc.communicate()
356
+ text = stdout.decode("utf-8", errors="ignore").strip()
357
+ err = stderr.decode("utf-8", errors="ignore").strip()
358
+ combined = text or err
359
+ logged_in = "Logged in" in combined
360
+ return {"loggedIn": logged_in, "statusText": combined, "exitCode": proc.returncode}
361
+ except Exception as e:
362
+ return {"loggedIn": False, "statusText": str(e), "exitCode": None}
363
+
364
+
365
+ @dataclass
366
+ class DeviceLoginAttempt:
367
+ id: str
368
+ proc: asyncio.subprocess.Process
369
+ created_at: float
370
+ url: Optional[str] = None
371
+ code: Optional[str] = None
372
+ output: List[str] = field(default_factory=list)
373
+ done: bool = False
374
+ returncode: Optional[int] = None
375
+
376
+
377
+ async def _read_device_login_output(attempt: DeviceLoginAttempt) -> None:
378
+ try:
379
+ assert attempt.proc.stdout is not None
380
+ while True:
381
+ line = await attempt.proc.stdout.readline()
382
+ if not line:
383
+ break
384
+ text = line.decode("utf-8", errors="ignore").rstrip("\n")
385
+ attempt.output.append(text)
386
+ if attempt.url is None and "auth.openai.com/codex/device" in text:
387
+ attempt.url = "https://auth.openai.com/codex/device"
388
+ if attempt.code is None:
389
+ import re
390
+
391
+ m = re.search(r"\b([A-Za-z0-9]{4,6}-[A-Za-z0-9]{4,6})\b", text)
392
+ if m:
393
+ attempt.code = m.group(1).upper()
394
+ await attempt.proc.wait()
395
+ finally:
396
+ attempt.done = True
397
+ attempt.returncode = attempt.proc.returncode
398
+
399
+
400
+ @router.post("/api/codex/login/device/start")
401
+ async def codex_login_device_start(http_request: Request):
402
+ if not feature_enabled("codex"):
403
+ raise HTTPException(status_code=403, detail="Codex is disabled")
404
+ _ = await require_user_from_request(http_request)
405
+ async with http_request.app.state.device_login_lock:
406
+ proc = await asyncio.create_subprocess_exec(
407
+ "codex",
408
+ "login",
409
+ "--device-auth",
410
+ stdout=asyncio.subprocess.PIPE,
411
+ stderr=asyncio.subprocess.STDOUT,
412
+ )
413
+ attempt_id = str(uuid.uuid4())
414
+ attempt = DeviceLoginAttempt(
415
+ id=attempt_id,
416
+ proc=proc,
417
+ created_at=asyncio.get_running_loop().time(),
418
+ )
419
+ http_request.app.state.device_login_attempts[attempt_id] = attempt
420
+ asyncio.create_task(_read_device_login_output(attempt))
421
+ return {"loginId": attempt_id}
422
+
423
+
424
+ @router.get("/api/codex/login/device/status")
425
+ async def codex_login_device_status(loginId: str, http_request: Request):
426
+ if not feature_enabled("codex"):
427
+ raise HTTPException(status_code=403, detail="Codex is disabled")
428
+ _ = await require_user_from_request(http_request)
429
+ attempt = http_request.app.state.device_login_attempts.get(loginId)
430
+ if not attempt:
431
+ raise HTTPException(status_code=404, detail="Unknown loginId")
432
+
433
+ tail = attempt.output[-50:]
434
+ status = "pending"
435
+ if attempt.done:
436
+ status = "success" if attempt.returncode == 0 else "failed"
437
+ return {
438
+ "loginId": attempt.id,
439
+ "status": status,
440
+ "url": attempt.url,
441
+ "code": attempt.code,
442
+ "outputTail": tail,
443
+ "returnCode": attempt.returncode,
444
+ }
app/routes/mcp.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ 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()
10
+
11
+
12
+ @router.get("/api/mcp/tools")
13
+ async def mcp_tools_list(http_request: Request):
14
+ if not feature_enabled("mcp"):
15
+ raise HTTPException(status_code=403, detail="MCP is disabled")
16
+ _ = await require_user_from_request(http_request)
17
+ try:
18
+ result = await http_request.app.state.codex_mcp_client.list_tools()
19
+ return result
20
+ except Exception as e:
21
+ raise HTTPException(status_code=500, detail=str(e))
22
+
23
+
24
+ class McpCallRequest(BaseModel):
25
+ name: str
26
+ arguments: dict
27
+
28
+
29
+ @router.post("/api/mcp/call")
30
+ async def mcp_tools_call(request: McpCallRequest, http_request: Request):
31
+ if not feature_enabled("mcp"):
32
+ raise HTTPException(status_code=403, detail="MCP is disabled")
33
+ _ = await require_user_from_request(http_request)
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=str(e))
38
+
app/routes/terminal.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import fcntl
5
+ import json
6
+ import os
7
+ import pty
8
+ import struct
9
+ import subprocess
10
+ import termios
11
+ from datetime import datetime, timezone
12
+
13
+ from fastapi import APIRouter, HTTPException, WebSocket
14
+
15
+ from app.auth import verify_supabase_access_token
16
+ from app.settings import feature_enabled
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ @router.websocket("/ws/terminal")
22
+ async def websocket_terminal(websocket: WebSocket):
23
+ await websocket.accept()
24
+
25
+ if not feature_enabled("terminal"):
26
+ await websocket.send_text("\r\n[terminal disabled]\r\n")
27
+ await websocket.close()
28
+ return
29
+
30
+ # Browser WebSocket APIs do not allow setting Authorization headers directly.
31
+ token = (websocket.query_params.get("token") or "").strip()
32
+ if not token:
33
+ await websocket.send_text("\r\n[unauthorized: missing token]\r\n")
34
+ await websocket.close()
35
+ return
36
+ try:
37
+ _user = await verify_supabase_access_token(token)
38
+ except HTTPException as e:
39
+ await websocket.send_text(f"\r\n[unauthorized: {e.detail}]\r\n")
40
+ await websocket.close()
41
+ return
42
+
43
+ # If token-based Codex auth is provided via env (HF Spaces Secrets), ensure the CLI auth file exists.
44
+ try:
45
+ id_token = os.environ.get("CODEX_ID_TOKEN") or os.environ.get("ID_TOKEN") or ""
46
+ access_token = os.environ.get("CODEX_ACCESS_TOKEN") or os.environ.get("ACCESS_TOKEN") or ""
47
+ refresh_token = os.environ.get("CODEX_REFRESH_TOKEN") or os.environ.get("REFRESH_TOKEN") or ""
48
+ account_id = os.environ.get("CODEX_ACCOUNT_ID") or os.environ.get("ACCOUNT_ID") or ""
49
+ if id_token or access_token or refresh_token:
50
+ codex_home = os.path.join(os.path.expanduser("~"), ".codex")
51
+ os.makedirs(codex_home, exist_ok=True)
52
+ auth = {
53
+ "OPENAI_API_KEY": None,
54
+ "tokens": {
55
+ "id_token": id_token,
56
+ "access_token": access_token,
57
+ "refresh_token": refresh_token,
58
+ "account_id": account_id,
59
+ },
60
+ "last_refresh": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
61
+ }
62
+ for filename in ("auth.json", ".auth.json"):
63
+ path = os.path.join(codex_home, filename)
64
+ with open(path, "w", encoding="utf-8") as f:
65
+ json.dump(auth, f, indent=2)
66
+ f.write("\n")
67
+ try:
68
+ os.chmod(path, 0o600)
69
+ except Exception:
70
+ pass
71
+ except Exception:
72
+ pass
73
+
74
+ try:
75
+ master_fd, slave_fd = pty.openpty()
76
+ except OSError as e:
77
+ await websocket.send_text(
78
+ "\r\n[terminal unavailable: PTY allocation failed]\r\n" f"{type(e).__name__}: {e}\r\n"
79
+ )
80
+ await websocket.close()
81
+ return
82
+
83
+ env = os.environ.copy()
84
+ env.setdefault("TERM", "xterm-256color")
85
+ env.setdefault("COLORTERM", "truecolor")
86
+ p = subprocess.Popen(
87
+ ["/bin/bash", "-i"],
88
+ preexec_fn=os.setsid,
89
+ stdin=slave_fd,
90
+ stdout=slave_fd,
91
+ stderr=slave_fd,
92
+ env=env,
93
+ close_fds=True,
94
+ )
95
+
96
+ os.close(slave_fd)
97
+ loop = asyncio.get_running_loop()
98
+
99
+ async def read_from_pty():
100
+ while True:
101
+ try:
102
+ data = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
103
+ if not data:
104
+ break
105
+ await websocket.send_text(data.decode(errors="ignore"))
106
+ except Exception:
107
+ break
108
+ await websocket.close()
109
+
110
+ async def write_to_pty():
111
+ try:
112
+ while True:
113
+ data = await websocket.receive_text()
114
+ if data.startswith("\x01resize:"):
115
+ try:
116
+ _, cols, rows = data.split(":")
117
+ cols_i = int(cols)
118
+ rows_i = int(rows)
119
+ if cols_i < 2 or rows_i < 2:
120
+ continue
121
+ winsize = struct.pack("HHHH", rows_i, cols_i, 0, 0)
122
+ fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize)
123
+ except Exception:
124
+ pass
125
+ else:
126
+ os.write(master_fd, data.encode())
127
+ except Exception:
128
+ pass
129
+
130
+ read_task = asyncio.create_task(read_from_pty())
131
+ write_task = asyncio.create_task(write_to_pty())
132
+
133
+ try:
134
+ await asyncio.wait([read_task, write_task], return_when=asyncio.FIRST_COMPLETED)
135
+ finally:
136
+ read_task.cancel()
137
+ write_task.cancel()
138
+ p.terminate()
139
+ os.close(master_fd)
140
+
app/server.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from app.mcp_client import McpStdioClient
12
+ from app.routes.base import router as base_router
13
+ from app.routes.chat import router as chat_router
14
+ from app.routes.codex import router as codex_router
15
+ from app.routes.mcp import router as mcp_router
16
+ from app.routes.terminal import router as terminal_router
17
+
18
+ _ROOT = Path(__file__).resolve().parent.parent
19
+
20
+
21
+ @asynccontextmanager
22
+ async def lifespan(app: FastAPI):
23
+ app.state.codex_mcp_client = McpStdioClient(["codex", "mcp-server"])
24
+ app.state.device_login_attempts = {}
25
+ app.state.device_login_lock = asyncio.Lock()
26
+ stop = asyncio.Event()
27
+
28
+ async def _cleanup_device_logins():
29
+ # Best-effort pruning to keep memory bounded.
30
+ while not stop.is_set():
31
+ await asyncio.sleep(60)
32
+ try:
33
+ now = asyncio.get_running_loop().time()
34
+ attempts = getattr(app.state, "device_login_attempts", {})
35
+ for key, attempt in list(attempts.items()):
36
+ created = getattr(attempt, "created_at", 0.0) or 0.0
37
+ age = now - created
38
+ done = bool(getattr(attempt, "done", False))
39
+ # Keep active attempts for up to 30 minutes; completed for 5 minutes.
40
+ if age > 1800 or (done and age > 300):
41
+ attempts.pop(key, None)
42
+ except Exception:
43
+ continue
44
+
45
+ cleanup_task = asyncio.create_task(_cleanup_device_logins())
46
+ try:
47
+ yield
48
+ finally:
49
+ stop.set()
50
+ cleanup_task.cancel()
51
+ try:
52
+ await app.state.codex_mcp_client.close()
53
+ except Exception:
54
+ pass
55
+
56
+
57
+ def create_app() -> FastAPI:
58
+ app = FastAPI(lifespan=lifespan)
59
+
60
+ app.add_middleware(
61
+ CORSMiddleware,
62
+ allow_origins=["*"],
63
+ allow_credentials=True,
64
+ allow_methods=["*"],
65
+ allow_headers=["*"],
66
+ )
67
+
68
+ static_dir = _ROOT / "static"
69
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
70
+
71
+ app.include_router(base_router)
72
+ app.include_router(chat_router)
73
+ app.include_router(codex_router)
74
+ app.include_router(mcp_router)
75
+ app.include_router(terminal_router)
76
+
77
+ return app
app/settings.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def env_truthy(name: str, default: bool = False) -> bool:
7
+ raw = os.environ.get(name)
8
+ if raw is None:
9
+ return default
10
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
11
+
12
+
13
+ def feature_enabled(feature: str) -> bool:
14
+ """
15
+ Safety: when Supabase isn't configured, disable dangerous features by default.
16
+ """
17
+ has_supabase = bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_KEY"))
18
+ defaults = {
19
+ "terminal": has_supabase,
20
+ "codex": has_supabase,
21
+ "mcp": has_supabase,
22
+ "indexing": False,
23
+ }
24
+ env_map = {
25
+ "terminal": "ENABLE_TERMINAL",
26
+ "codex": "ENABLE_CODEX",
27
+ "mcp": "ENABLE_MCP",
28
+ "indexing": "ENABLE_INDEXING",
29
+ }
30
+ if feature not in env_map:
31
+ return False
32
+ return env_truthy(env_map[feature], default=defaults[feature])
33
+
app/workdir.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, Optional
5
+
6
+
7
+ def safe_user_workdir(user: dict[str, Any], requested: Optional[str]) -> str:
8
+ """
9
+ Restrict Codex workdir to an allowlisted root to prevent traversal.
10
+ """
11
+ base_root = "/data/codex/workspace" if os.path.isdir("/data") else "/app"
12
+ user_id = (user.get("id") or "").strip()
13
+ user_root = os.path.join(base_root, user_id) if user_id else base_root
14
+
15
+ if requested:
16
+ req = requested.strip()
17
+ if req:
18
+ norm = os.path.normpath(req)
19
+ if os.path.isabs(norm):
20
+ candidate = norm
21
+ else:
22
+ candidate = os.path.join(user_root, norm)
23
+ candidate = os.path.normpath(candidate)
24
+ base_norm = os.path.normpath(base_root)
25
+ if candidate == base_norm or candidate.startswith(base_norm + os.sep):
26
+ os.makedirs(candidate, exist_ok=True)
27
+ return candidate
28
+
29
+ os.makedirs(user_root, exist_ok=True)
30
+ return user_root
31
+
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ ## Overview
4
+
5
+ `autonomy-labs` is a single-container FastAPI app intended to run on Hugging Face Spaces (Docker). It serves:
6
+ - static pages (`static/index.html`, `static/dashboard.html`)
7
+ - REST APIs for chat + Codex + MCP
8
+ - a WebSocket PTY-backed terminal (`/ws/terminal`)
9
+
10
+ ## Backend layout
11
+
12
+ - `main.py`: minimal entrypoint (loads dotenv, creates app).
13
+ - `app/server.py`: app factory + lifespan lifecycle.
14
+ - `app/routes/*`: feature routers:
15
+ - `base.py`: `/`, `/health`, `/config`
16
+ - `chat.py`: `/api/chat`, `/api/proxy/models`
17
+ - `codex.py`: `/api/codex*` and Codex login helpers
18
+ - `mcp.py`: `/api/mcp/*`
19
+ - `terminal.py`: `/ws/terminal`
20
+ - `app/auth.py`: Supabase access-token verification (server-side) with small TTL cache.
21
+
22
+ ## Frontend layout
23
+
24
+ Currently the UI is primarily `static/dashboard.html` with inline JS/CSS and CDN dependencies (Tailwind, xterm, etc).
25
+
26
+ ## Execution safety model
27
+
28
+ The following capabilities are high-risk and gated:
29
+ - web terminal
30
+ - Codex execution endpoints
31
+ - MCP tool calls
32
+
33
+ Auth is enforced server-side via Supabase access tokens. Feature flags can disable them entirely.
34
+
docs/SECURITY_DEPLOYMENT.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Deployment Guide
2
+
3
+ ## Defaults
4
+
5
+ If Supabase is not configured (`SUPABASE_URL` + `SUPABASE_KEY` missing), dangerous features are disabled by default.
6
+
7
+ ## Recommended settings (HF Spaces)
8
+
9
+ Use Spaces Secrets for:
10
+ - `SUPABASE_URL`
11
+ - `SUPABASE_KEY`
12
+ - Codex tokens (if using token-based auth) or use device auth inside terminal
13
+ - provider API keys (Gemini/Claude/OpenAI-compatible) as needed
14
+
15
+ Consider explicitly setting:
16
+ - `ENABLE_TERMINAL=0` unless you truly need it
17
+ - `ENABLE_CODEX=0` unless you truly need it
18
+ - `ENABLE_MCP=0` unless you truly need it
19
+
20
+ ## WebSocket auth
21
+
22
+ Browsers cannot set `Authorization` headers on WebSockets, so `/ws/terminal` expects a Supabase access token via `?token=...`.
23
+
24
+ Treat access tokens as sensitive; do not log them.
25
+
26
+ ## SSH keys
27
+
28
+ Preferred: generate a key inside the container and add the public key to your Git provider.
29
+
30
+ Optional: supply keys via secrets:
31
+ - `SSH_PRIVATE_KEY` (required)
32
+ - `SSH_PUBLIC_KEY` (optional)
33
+ - `SSH_KNOWN_HOSTS` (optional)
34
+
docs/TROUBLESHOOTING.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Troubleshooting
2
+
3
+ ## “Token data is not available.” (Codex CLI)
4
+
5
+ Common causes:
6
+ - The container wasn’t restarted after setting Secrets.
7
+ - The auth file wasn’t written to `~/.codex/.auth.json`.
8
+
9
+ In the terminal, check:
10
+ - `ls -la ~/.codex`
11
+ - `cat ~/.codex/.auth.json`
12
+
13
+ If you use env-based token auth, set one of:
14
+ - `CODEX_ID_TOKEN` or `ID_TOKEN`
15
+ - `CODEX_ACCESS_TOKEN` or `ACCESS_TOKEN`
16
+ - `CODEX_REFRESH_TOKEN` or `REFRESH_TOKEN`
17
+ - optional `CODEX_ACCOUNT_ID` or `ACCOUNT_ID`
18
+
19
+ ## Terminal shows vertical/1-column text
20
+
21
+ This usually means the terminal “fit” ran while the terminal view was hidden or at size 0.
22
+
23
+ Mitigations:
24
+ - Switch to the Terminal view after the page fully loads.
25
+ - Resize the browser window once to trigger a refit.
26
+
27
+ ## PTY allocation failed
28
+
29
+ If the backend prints `PTY allocation failed`, the runtime likely lacks `/dev/pts` or has exhausted PTYs.
30
+
31
+ HF Spaces generally supports PTYs, but custom runtimes may not.
32
+
main.py CHANGED
@@ -1,964 +1,8 @@
1
- import os
2
- import pty
3
- import select
4
- import subprocess
5
- import struct
6
- import fcntl
7
- import termios
8
- import asyncio
9
- from typing import Any, List, Optional, Union
10
- from pydantic import BaseModel
11
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
12
- from fastapi.staticfiles import StaticFiles
13
- from fastapi.responses import FileResponse, StreamingResponse
14
- from fastapi.middleware.cors import CORSMiddleware
15
  from dotenv import load_dotenv
16
- from openai import OpenAI
17
- import json
18
- import uuid
19
- from dataclasses import dataclass, field
20
- from datetime import datetime, timezone
21
- import time
22
- from fastapi import Request
23
 
24
- load_dotenv()
25
-
26
- app = FastAPI()
27
-
28
- app.add_middleware(
29
- CORSMiddleware,
30
- allow_origins=["*"],
31
- allow_credentials=True,
32
- allow_methods=["*"],
33
- allow_headers=["*"],
34
- )
35
-
36
- app.mount("/static", StaticFiles(directory="static"), name="static")
37
-
38
- def _env_truthy(name: str, default: bool = False) -> bool:
39
- raw = os.environ.get(name)
40
- if raw is None:
41
- return default
42
- return raw.strip().lower() in {"1", "true", "yes", "on"}
43
-
44
-
45
- def _feature_enabled(feature: str) -> bool:
46
- # Safe behavior: if Supabase isn't configured, disable dangerous features.
47
- has_supabase = bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_KEY"))
48
- defaults = {
49
- "terminal": has_supabase,
50
- "codex": has_supabase,
51
- "mcp": has_supabase,
52
- "indexing": False,
53
- }
54
- env_map = {
55
- "terminal": "ENABLE_TERMINAL",
56
- "codex": "ENABLE_CODEX",
57
- "mcp": "ENABLE_MCP",
58
- "indexing": "ENABLE_INDEXING",
59
- }
60
- return _env_truthy(env_map[feature], default=defaults[feature])
61
-
62
-
63
- _SUPABASE_TOKEN_CACHE: dict[str, tuple[float, dict]] = {}
64
-
65
-
66
- async def _verify_supabase_access_token(access_token: str) -> dict:
67
- """
68
- Verifies a Supabase access token by calling Supabase Auth `GET /auth/v1/user`.
69
- Uses a small in-memory TTL cache to avoid calling Supabase on every request.
70
- """
71
- access_token = (access_token or "").strip()
72
- if not access_token:
73
- raise HTTPException(status_code=401, detail="Missing access token")
74
-
75
- now = time.time()
76
- cached = _SUPABASE_TOKEN_CACHE.get(access_token)
77
- if cached and (now - cached[0]) < 30:
78
- return cached[1]
79
-
80
- supabase_url = os.environ.get("SUPABASE_URL")
81
- supabase_key = os.environ.get("SUPABASE_KEY")
82
- if not supabase_url or not supabase_key:
83
- raise HTTPException(status_code=503, detail="Supabase is not configured")
84
-
85
- import httpx
86
-
87
- headers = {
88
- "Authorization": f"Bearer {access_token}",
89
- "apikey": supabase_key,
90
- }
91
- url = f"{supabase_url.rstrip('/')}/auth/v1/user"
92
- async with httpx.AsyncClient(timeout=10.0) as client:
93
- resp = await client.get(url, headers=headers)
94
- if resp.status_code != 200:
95
- raise HTTPException(status_code=401, detail="Invalid or expired session")
96
- user = resp.json()
97
- _SUPABASE_TOKEN_CACHE[access_token] = (now, user)
98
- # Best-effort cache bound
99
- if len(_SUPABASE_TOKEN_CACHE) > 500:
100
- for k in list(_SUPABASE_TOKEN_CACHE.keys())[:200]:
101
- _SUPABASE_TOKEN_CACHE.pop(k, None)
102
- return user
103
-
104
-
105
- async def _require_user_from_request(request: Request) -> dict:
106
- auth = (request.headers.get("authorization") or "").strip()
107
- if not auth.lower().startswith("bearer "):
108
- raise HTTPException(status_code=401, detail="Missing Authorization bearer token")
109
- token = auth.split(None, 1)[1].strip()
110
- return await _verify_supabase_access_token(token)
111
-
112
-
113
- def _safe_user_workdir(user: dict, requested: Optional[str]) -> str:
114
- """
115
- Restrict Codex workdir to an allowlisted root to prevent traversal.
116
- """
117
- base_root = "/data/codex/workspace" if os.path.isdir("/data") else "/app"
118
- user_id = (user.get("id") or "").strip() if isinstance(user, dict) else ""
119
- user_root = os.path.join(base_root, user_id) if user_id else base_root
120
-
121
- if requested:
122
- req = requested.strip()
123
- if req:
124
- # Only allow inside base_root.
125
- norm = os.path.normpath(req)
126
- if os.path.isabs(norm):
127
- candidate = norm
128
- else:
129
- candidate = os.path.join(user_root, norm)
130
- candidate = os.path.normpath(candidate)
131
- base_norm = os.path.normpath(base_root)
132
- if candidate == base_norm or candidate.startswith(base_norm + os.sep):
133
- os.makedirs(candidate, exist_ok=True)
134
- return candidate
135
-
136
- os.makedirs(user_root, exist_ok=True)
137
- return user_root
138
-
139
-
140
- @app.get("/config")
141
- async def get_config():
142
- return {
143
- "supabase_url": os.environ.get("SUPABASE_URL", "https://znhglkwefxdhgajvrqmb.supabase.co"),
144
- "supabase_key": os.environ.get("SUPABASE_KEY"),
145
- "default_base_url": os.environ.get("DEFAULT_BASE_URL", "https://router.huggingface.co/v1"),
146
- "default_api_key": os.environ.get("DEFAULT_API_KEY", ""),
147
- "default_model": os.environ.get("DEFAULT_MODEL", "gpt-3.5-turbo"),
148
- }
149
-
150
- @app.get("/")
151
- async def read_index():
152
- return FileResponse('static/index.html')
153
-
154
- @app.get("/health")
155
- async def health_check():
156
- return {"status": "ok"}
157
-
158
- # --- Chatbot Implementation ---
159
-
160
- class ChatMessage(BaseModel):
161
- role: str
162
- # OpenAI-compatible: content can be plain text or an array of multimodal parts.
163
- content: Union[str, List[Any]]
164
-
165
- class ChatRequest(BaseModel):
166
- messages: List[ChatMessage]
167
- apiKey: Optional[str] = None
168
- baseUrl: Optional[str] = None
169
- model: Optional[str] = "gpt-3.5-turbo"
170
-
171
- @app.post("/api/chat")
172
- async def chat_endpoint(request: ChatRequest):
173
- api_key = request.apiKey or os.environ.get("OPENAI_API_KEY")
174
- base_url = request.baseUrl or os.environ.get("OPENAI_BASE_URL")
175
-
176
- if not api_key:
177
- raise HTTPException(status_code=400, detail="API Key is required")
178
-
179
- client = OpenAI(api_key=api_key, base_url=base_url)
180
-
181
- def generate():
182
- try:
183
- stream = client.chat.completions.create(
184
- model=request.model,
185
- messages=[{"role": m.role, "content": m.content} for m in request.messages],
186
- stream=True
187
- )
188
- for chunk in stream:
189
- if chunk.choices[0].delta.content:
190
- yield chunk.choices[0].delta.content
191
- except Exception as e:
192
- yield f"Error: {str(e)}"
193
-
194
- return StreamingResponse(generate(), media_type="text/plain")
195
-
196
- class ModelsRequest(BaseModel):
197
- apiKey: Optional[str] = None
198
- baseUrl: Optional[str] = None
199
-
200
- @app.post("/api/proxy/models")
201
- async def proxy_models(request: ModelsRequest):
202
- api_key = request.apiKey or os.environ.get("OPENAI_API_KEY")
203
- base_url = request.baseUrl or os.environ.get("OPENAI_BASE_URL")
204
-
205
- if not base_url:
206
- raise HTTPException(status_code=400, detail="Base URL is required")
207
-
208
- # Cleanup base_url to ensure it doesn't end with /v1 if we need to hit models,
209
- # but OpenAI client usually handles simple /models on top of base.
210
- # Actually, standard OpenAI client usage: client = OpenAI(base_url=...) -> client.models.list()
211
-
212
- try:
213
- # Use simple HTTP request to avoid instantiating full client if just checking models
214
- # Or use the OpenAI client which handles it well.
215
- import httpx
216
-
217
- headers = {}
218
- if api_key:
219
- headers["Authorization"] = f"Bearer {api_key}"
220
-
221
- # Ensure base_url ends correctly for appending /models.
222
- # If base_url is ".../v1", models endpoint is usually ".../v1/models"
223
- target_url = f"{base_url.rstrip('/')}/models"
224
-
225
- async with httpx.AsyncClient() as client:
226
- resp = await client.get(target_url, headers=headers, timeout=10.0)
227
- if resp.status_code != 200:
228
- raise HTTPException(status_code=resp.status_code, detail=f"Provider returned error: {resp.text}")
229
- return resp.json()
230
-
231
- except Exception as e:
232
- print(f"Error fetching models: {e}")
233
- raise HTTPException(status_code=500, detail=str(e))
234
-
235
- class CodexRequest(BaseModel):
236
- message: str
237
- threadId: Optional[str] = None
238
- model: Optional[str] = None
239
- sandboxMode: Optional[str] = "workspace-write"
240
- approvalPolicy: Optional[str] = "never"
241
- apiKey: Optional[str] = None
242
- baseUrl: Optional[str] = None
243
- modelReasoningEffort: Optional[str] = "minimal"
244
- workingDirectory: Optional[str] = None
245
-
246
-
247
- def _default_codex_workdir() -> str:
248
- preferred = "/data/codex/workspace"
249
- if os.path.isdir(preferred):
250
- return preferred
251
- return os.path.dirname(__file__)
252
-
253
- @app.post("/api/codex")
254
- async def codex_agent(request: CodexRequest, http_request: Request):
255
- """
256
- Runs the local Codex agent via the official @openai/codex-sdk wrapper (Node.js).
257
- Persists threads under ~/.codex/sessions (mapped to /data/.codex on Spaces by entrypoint).
258
- """
259
- if not request.message.strip():
260
- raise HTTPException(status_code=400, detail="Message is required")
261
- if not _feature_enabled("codex"):
262
- raise HTTPException(status_code=403, detail="Codex is disabled")
263
-
264
- user = await _require_user_from_request(http_request)
265
-
266
- node = os.environ.get("NODE_BIN", "node")
267
- script_path = os.path.join(os.path.dirname(__file__), "codex_agent.mjs")
268
- if not os.path.exists(script_path):
269
- raise HTTPException(status_code=500, detail="codex_agent.mjs not found")
270
-
271
- payload = {
272
- "message": request.message,
273
- "threadId": request.threadId,
274
- "model": request.model,
275
- "sandboxMode": request.sandboxMode,
276
- "approvalPolicy": request.approvalPolicy,
277
- "modelReasoningEffort": request.modelReasoningEffort,
278
- "workingDirectory": _safe_user_workdir(user, request.workingDirectory),
279
- }
280
-
281
- try:
282
- env = os.environ.copy()
283
- # Prefer using the global `codex` binary so device-auth (`codex login --device-auth`)
284
- # and `codex login status` share the same credential store.
285
- env.setdefault("CODEX_PATH_OVERRIDE", "codex")
286
- # If apiKey is not provided, assume device-auth and avoid setting API base URLs that
287
- # could force API-key auth codepaths and cause 401s.
288
- if request.apiKey:
289
- env["CODEX_API_KEY"] = request.apiKey
290
- env["OPENAI_API_KEY"] = request.apiKey
291
- if request.apiKey and request.baseUrl:
292
- env["OPENAI_BASE_URL"] = request.baseUrl
293
-
294
- proc = await asyncio.create_subprocess_exec(
295
- node,
296
- script_path,
297
- stdin=asyncio.subprocess.PIPE,
298
- stdout=asyncio.subprocess.PIPE,
299
- stderr=asyncio.subprocess.PIPE,
300
- env=env,
301
- )
302
- stdout, stderr = await proc.communicate(json.dumps(payload).encode("utf-8"))
303
- if proc.returncode != 0:
304
- err_text = (stderr.decode("utf-8", errors="ignore") or "").strip()
305
- if "401 Unauthorized" in err_text or "status 401" in err_text:
306
- raise HTTPException(status_code=401, detail=err_text or "Unauthorized")
307
- raise HTTPException(
308
- status_code=500,
309
- detail=(err_text or "Codex agent failed"),
310
- )
311
- return json.loads(stdout.decode("utf-8"))
312
- except HTTPException:
313
- raise
314
- except Exception as e:
315
- raise HTTPException(status_code=500, detail=str(e))
316
-
317
- @app.post("/api/codex/cli")
318
- async def codex_agent_cli(request: CodexRequest, http_request: Request):
319
- """
320
- Runs Codex directly via the CLI (`codex exec --json`) and extracts the final agent message.
321
-
322
- This avoids SDK/CLI mismatches and uses the same device-auth session as `codex login --device-auth`.
323
- """
324
- if not request.message.strip():
325
- raise HTTPException(status_code=400, detail="Message is required")
326
- if not _feature_enabled("codex"):
327
- raise HTTPException(status_code=403, detail="Codex is disabled")
328
-
329
- user = await _require_user_from_request(http_request)
330
-
331
- def _with_codex_agent_prefix(message: str) -> str:
332
- msg = message.strip()
333
- if msg.startswith("@"):
334
- return message
335
- return f"@codex {message}"
336
-
337
- message = _with_codex_agent_prefix(request.message)
338
-
339
- # Use --json to stream JSONL events on stdout; keep stderr for logs/errors.
340
- base_args = ["codex", "exec", "--json", "--color", "never", "--sandbox", request.sandboxMode or "workspace-write"]
341
- # Map approval policy into config (CLI flag differs between interactive and exec; config works everywhere).
342
- if request.approvalPolicy:
343
- base_args += ["--config", f'approval_policy="{request.approvalPolicy}"']
344
- # Optional model
345
- if request.model:
346
- base_args += ["--model", request.model]
347
- # Run inside app dir; allow even if not a git repo (Spaces copies are git, but keep safe)
348
- base_args += ["--cd", _safe_user_workdir(user, request.workingDirectory), "--skip-git-repo-check"]
349
-
350
- # Provide the prompt as an argument (avoids "Reading prompt from stdin..." paths).
351
- if request.threadId:
352
- base_args += ["resume", request.threadId, message]
353
- else:
354
- base_args += [message]
355
-
356
- env = os.environ.copy()
357
- if request.apiKey:
358
- env["OPENAI_API_KEY"] = request.apiKey
359
- env["CODEX_API_KEY"] = request.apiKey
360
- if request.baseUrl:
361
- env["OPENAI_BASE_URL"] = request.baseUrl
362
-
363
- try:
364
- proc = await asyncio.create_subprocess_exec(
365
- *base_args,
366
- stdout=asyncio.subprocess.PIPE,
367
- stderr=asyncio.subprocess.PIPE,
368
- env=env,
369
- )
370
- stdout, stderr = await proc.communicate()
371
-
372
- err_text = (stderr.decode("utf-8", errors="ignore") or "").strip()
373
- if proc.returncode != 0:
374
- out_text = (stdout.decode("utf-8", errors="ignore") or "").strip()
375
- detail = err_text or out_text or "Codex CLI failed"
376
- if "401 Unauthorized" in detail or "status 401" in detail:
377
- raise HTTPException(status_code=401, detail=detail)
378
- raise HTTPException(status_code=500, detail=detail)
379
-
380
- thread_id = None
381
- final_text = ""
382
- usage = None
383
- saw_event = False
384
- for line in stdout.decode("utf-8", errors="ignore").splitlines():
385
- line = line.strip()
386
- if not line:
387
- continue
388
- try:
389
- event = json.loads(line)
390
- except Exception:
391
- continue
392
- saw_event = True
393
- if event.get("type") == "thread.started":
394
- thread_id = event.get("thread_id") or thread_id
395
- if event.get("type") == "item.completed":
396
- item = event.get("item") or {}
397
- if item.get("type") == "agent_message":
398
- final_text = item.get("text") or final_text
399
- if event.get("type") == "turn.completed":
400
- usage = event.get("usage") or usage
401
- if event.get("type") == "turn.failed":
402
- err = (event.get("error") or {}).get("message") or "Codex turn failed"
403
- if "401" in err:
404
- raise HTTPException(status_code=401, detail=err)
405
- raise HTTPException(status_code=500, detail=err)
406
-
407
- # Codex sometimes prints fatal errors to stderr while exiting 0.
408
- if not saw_event and err_text:
409
- if "401 Unauthorized" in err_text or "status 401" in err_text:
410
- raise HTTPException(status_code=401, detail=err_text)
411
- if "Error:" in err_text or "Fatal error" in err_text:
412
- raise HTTPException(status_code=500, detail=err_text)
413
- if not saw_event and not final_text:
414
- out_text = (stdout.decode("utf-8", errors="ignore") or "").strip()
415
- if out_text:
416
- raise HTTPException(status_code=500, detail=out_text)
417
 
418
- return {"threadId": thread_id or request.threadId, "finalResponse": final_text, "usage": usage}
419
- except HTTPException:
420
- raise
421
- except Exception as e:
422
- raise HTTPException(status_code=500, detail=str(e))
423
-
424
- @app.post("/api/codex/cli/stream")
425
- async def codex_agent_cli_stream(request: CodexRequest, http_request: Request):
426
- """
427
- Streams Codex CLI JSONL events (NDJSON) as the agent runs.
428
-
429
- Each line is a JSON object (event). The stream ends with a final object:
430
- {"type":"done","threadId": "...", "finalResponse": "...", "usage": {...}}
431
- """
432
- if not request.message.strip():
433
- raise HTTPException(status_code=400, detail="Message is required")
434
- if not _feature_enabled("codex"):
435
- raise HTTPException(status_code=403, detail="Codex is disabled")
436
-
437
- user = await _require_user_from_request(http_request)
438
-
439
- def _with_codex_agent_prefix(message: str) -> str:
440
- msg = message.strip()
441
- if msg.startswith("@"):
442
- return message
443
- return f"@codex {message}"
444
-
445
- message = _with_codex_agent_prefix(request.message)
446
-
447
- base_args = ["codex", "exec", "--json", "--color", "never", "--sandbox", request.sandboxMode or "workspace-write"]
448
- if request.approvalPolicy:
449
- base_args += ["--config", f'approval_policy=\"{request.approvalPolicy}\"']
450
- if request.model:
451
- base_args += ["--model", request.model]
452
- base_args += ["--cd", _safe_user_workdir(user, request.workingDirectory), "--skip-git-repo-check"]
453
-
454
- if request.threadId:
455
- base_args += ["resume", request.threadId, message]
456
- else:
457
- base_args += [message]
458
-
459
- env = os.environ.copy()
460
- if request.apiKey:
461
- env["OPENAI_API_KEY"] = request.apiKey
462
- env["CODEX_API_KEY"] = request.apiKey
463
- if request.baseUrl:
464
- env["OPENAI_BASE_URL"] = request.baseUrl
465
-
466
- async def gen():
467
- proc = await asyncio.create_subprocess_exec(
468
- *base_args,
469
- stdout=asyncio.subprocess.PIPE,
470
- stderr=asyncio.subprocess.PIPE,
471
- env=env,
472
- )
473
- assert proc.stdout is not None
474
- assert proc.stderr is not None
475
-
476
- thread_id = None
477
- final_text = ""
478
- usage = None
479
-
480
- async def emit(obj: dict):
481
- yield (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
482
-
483
- # Stream stdout events line-by-line
484
- try:
485
- while True:
486
- line = await proc.stdout.readline()
487
- if not line:
488
- break
489
- raw = line.decode("utf-8", errors="ignore").strip()
490
- if not raw:
491
- continue
492
- try:
493
- event = json.loads(raw)
494
- except Exception:
495
- # forward raw line so UI can debug
496
- async for b in emit({"type": "log", "message": raw}):
497
- yield b
498
- continue
499
-
500
- if event.get("type") == "thread.started":
501
- thread_id = event.get("thread_id") or thread_id
502
- if event.get("type") == "item.completed":
503
- item = event.get("item") or {}
504
- if item.get("type") == "agent_message":
505
- final_text = item.get("text") or final_text
506
- if event.get("type") == "turn.completed":
507
- usage = event.get("usage") or usage
508
- if event.get("type") == "turn.failed":
509
- err = (event.get("error") or {}).get("message") or "Codex turn failed"
510
- async for b in emit({"type": "error", "message": err}):
511
- yield b
512
- break
513
-
514
- async for b in emit(event):
515
- yield b
516
- finally:
517
- await proc.wait()
518
- err_text = (await proc.stderr.read()).decode("utf-8", errors="ignore").strip()
519
- if proc.returncode != 0 and err_text:
520
- async for b in emit({"type": "stderr", "message": err_text, "returnCode": proc.returncode}):
521
- yield b
522
-
523
- async for b in emit({"type": "done", "threadId": thread_id or request.threadId, "finalResponse": final_text, "usage": usage, "returnCode": proc.returncode}):
524
- yield b
525
-
526
- return StreamingResponse(gen(), media_type="application/x-ndjson")
527
-
528
-
529
- @app.get("/api/codex/mcp")
530
- async def codex_mcp_list(http_request: Request):
531
- """
532
- Lists configured Codex MCP servers by shelling out to `codex mcp list`.
533
- """
534
- if not _feature_enabled("mcp"):
535
- raise HTTPException(status_code=403, detail="MCP is disabled")
536
- _ = await _require_user_from_request(http_request)
537
- try:
538
- proc = await asyncio.create_subprocess_exec(
539
- "codex",
540
- "mcp",
541
- "list",
542
- stdout=asyncio.subprocess.PIPE,
543
- stderr=asyncio.subprocess.PIPE,
544
- )
545
- stdout, _ = await proc.communicate()
546
- if proc.returncode != 0:
547
- return {"servers": []}
548
- text = stdout.decode("utf-8", errors="ignore")
549
- servers = []
550
- for line in text.splitlines():
551
- name = (line.split() or [""])[0].strip()
552
- if name and name.lower() != "name":
553
- servers.append(name)
554
- return {"servers": servers}
555
- except Exception:
556
- return {"servers": []}
557
-
558
-
559
- @app.get("/api/codex/mcp/details")
560
- async def codex_mcp_details(http_request: Request):
561
- """
562
- Returns `codex mcp get --json` for each configured server.
563
- """
564
- if not _feature_enabled("mcp"):
565
- raise HTTPException(status_code=403, detail="MCP is disabled")
566
- _ = await _require_user_from_request(http_request)
567
- try:
568
- servers_resp = await codex_mcp_list(http_request)
569
- names = servers_resp.get("servers", []) if isinstance(servers_resp, dict) else []
570
- details = []
571
- for name in names:
572
- try:
573
- proc = await asyncio.create_subprocess_exec(
574
- "codex",
575
- "mcp",
576
- "get",
577
- name,
578
- "--json",
579
- stdout=asyncio.subprocess.PIPE,
580
- stderr=asyncio.subprocess.PIPE,
581
- )
582
- stdout, _ = await proc.communicate()
583
- if proc.returncode != 0:
584
- continue
585
- details.append(json.loads(stdout.decode("utf-8", errors="ignore")))
586
- except Exception:
587
- continue
588
- return {"servers": details}
589
- except Exception:
590
- return {"servers": []}
591
-
592
-
593
- @app.get("/api/codex/login/status")
594
- async def codex_login_status(http_request: Request):
595
- """
596
- Returns Codex CLI login status for device-auth based sessions.
597
- """
598
- if not _feature_enabled("codex"):
599
- raise HTTPException(status_code=403, detail="Codex is disabled")
600
- _ = await _require_user_from_request(http_request)
601
- try:
602
- proc = await asyncio.create_subprocess_exec(
603
- "codex",
604
- "login",
605
- "status",
606
- stdout=asyncio.subprocess.PIPE,
607
- stderr=asyncio.subprocess.PIPE,
608
- )
609
- stdout, stderr = await proc.communicate()
610
- text = stdout.decode("utf-8", errors="ignore").strip()
611
- err = stderr.decode("utf-8", errors="ignore").strip()
612
- # Current CLI prints: "Logged in using ChatGPT" when authenticated
613
- combined = text or err
614
- logged_in = "Logged in" in combined
615
- return {"loggedIn": logged_in, "statusText": combined, "exitCode": proc.returncode}
616
- except Exception as e:
617
- return {"loggedIn": False, "statusText": str(e), "exitCode": None}
618
-
619
-
620
- @dataclass
621
- class DeviceLoginAttempt:
622
- id: str
623
- proc: asyncio.subprocess.Process
624
- created_at: float
625
- url: Optional[str] = None
626
- code: Optional[str] = None
627
- output: List[str] = field(default_factory=list)
628
- done: bool = False
629
- returncode: Optional[int] = None
630
-
631
-
632
- app.state.device_login_attempts: dict[str, DeviceLoginAttempt] = {}
633
- app.state.device_login_lock = asyncio.Lock()
634
-
635
- class McpStdioClient:
636
- def __init__(self, command: List[str]):
637
- self.command = command
638
- self.proc: Optional[asyncio.subprocess.Process] = None
639
- self._lock = asyncio.Lock()
640
- self._pending: dict[int, asyncio.Future] = {}
641
- self._next_id = 1
642
- self._reader_task: Optional[asyncio.Task] = None
643
- self._initialized = False
644
-
645
- async def start(self) -> None:
646
- if self.proc and self.proc.returncode is None:
647
- return
648
- self.proc = await asyncio.create_subprocess_exec(
649
- *self.command,
650
- stdin=asyncio.subprocess.PIPE,
651
- stdout=asyncio.subprocess.PIPE,
652
- stderr=asyncio.subprocess.PIPE,
653
- )
654
- self._initialized = False
655
- self._reader_task = asyncio.create_task(self._reader())
656
- await self._initialize()
657
-
658
- async def _reader(self) -> None:
659
- assert self.proc and self.proc.stdout
660
- while True:
661
- line = await self.proc.stdout.readline()
662
- if not line:
663
- break
664
- text = line.decode("utf-8", errors="ignore").strip()
665
- if not text:
666
- continue
667
- try:
668
- msg = json.loads(text)
669
- except Exception:
670
- continue
671
- msg_id = msg.get("id")
672
- if msg_id is None:
673
- continue
674
- fut = self._pending.pop(int(msg_id), None)
675
- if fut and not fut.done():
676
- fut.set_result(msg)
677
-
678
- async def _rpc(self, method: str, params: Optional[dict] = None) -> dict:
679
- await self.start()
680
- assert self.proc and self.proc.stdin
681
- async with self._lock:
682
- msg_id = self._next_id
683
- self._next_id += 1
684
- loop = asyncio.get_running_loop()
685
- fut: asyncio.Future = loop.create_future()
686
- self._pending[msg_id] = fut
687
- payload = {"jsonrpc": "2.0", "id": msg_id, "method": method}
688
- if params is not None:
689
- payload["params"] = params
690
- self.proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
691
- await self.proc.stdin.drain()
692
- resp = await asyncio.wait_for(fut, timeout=600.0)
693
- if "error" in resp:
694
- raise HTTPException(status_code=500, detail=resp["error"])
695
- return resp.get("result") or {}
696
-
697
- async def _initialize(self) -> None:
698
- if self._initialized:
699
- return
700
- # minimal initialize; codex mcp-server advertises tools
701
- result = await self._rpc(
702
- "initialize",
703
- {
704
- "protocolVersion": "2024-11-05",
705
- "clientInfo": {"name": "autonomy-labs", "version": "1.0"},
706
- "capabilities": {},
707
- },
708
- )
709
- # Notify initialized (no response)
710
- assert self.proc and self.proc.stdin
711
- self.proc.stdin.write(
712
- (json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n").encode("utf-8")
713
- )
714
- await self.proc.stdin.drain()
715
- self._initialized = True
716
- _ = result
717
-
718
- async def list_tools(self) -> dict:
719
- return await self._rpc("tools/list", {})
720
-
721
- async def call_tool(self, name: str, arguments: dict) -> dict:
722
- return await self._rpc("tools/call", {"name": name, "arguments": arguments})
723
-
724
-
725
- app.state.codex_mcp_client = McpStdioClient(["codex", "mcp-server"])
726
-
727
-
728
- async def _read_device_login_output(attempt: DeviceLoginAttempt) -> None:
729
- try:
730
- assert attempt.proc.stdout is not None
731
- while True:
732
- line = await attempt.proc.stdout.readline()
733
- if not line:
734
- break
735
- text = line.decode("utf-8", errors="ignore").rstrip("\n")
736
- attempt.output.append(text)
737
- # Parse link/code from Codex output
738
- if attempt.url is None and "https://" in text and "auth.openai.com/codex/device" in text:
739
- attempt.url = "https://auth.openai.com/codex/device"
740
- if attempt.code is None:
741
- # Device code looks like 4-6 alnum, dash, 4-6 alnum (often uppercase).
742
- import re
743
- m = re.search(r"\b([A-Za-z0-9]{4,6}-[A-Za-z0-9]{4,6})\b", text)
744
- if m:
745
- attempt.code = m.group(1).upper()
746
- await attempt.proc.wait()
747
- finally:
748
- attempt.done = True
749
- attempt.returncode = attempt.proc.returncode
750
-
751
-
752
- @app.post("/api/codex/login/device/start")
753
- async def codex_login_device_start(http_request: Request):
754
- """
755
- Starts `codex login --device-auth` and returns the device URL + code (when available).
756
- """
757
- if not _feature_enabled("codex"):
758
- raise HTTPException(status_code=403, detail="Codex is disabled")
759
- _ = await _require_user_from_request(http_request)
760
- async with app.state.device_login_lock:
761
- proc = await asyncio.create_subprocess_exec(
762
- "codex",
763
- "login",
764
- "--device-auth",
765
- stdout=asyncio.subprocess.PIPE,
766
- stderr=asyncio.subprocess.STDOUT,
767
- )
768
- attempt_id = str(uuid.uuid4())
769
- attempt = DeviceLoginAttempt(
770
- id=attempt_id,
771
- proc=proc,
772
- created_at=asyncio.get_running_loop().time(),
773
- )
774
- app.state.device_login_attempts[attempt_id] = attempt
775
- asyncio.create_task(_read_device_login_output(attempt))
776
- return {"loginId": attempt_id}
777
-
778
-
779
- @app.get("/api/codex/login/device/status")
780
- async def codex_login_device_status(loginId: str, http_request: Request):
781
- if not _feature_enabled("codex"):
782
- raise HTTPException(status_code=403, detail="Codex is disabled")
783
- _ = await _require_user_from_request(http_request)
784
- attempt = app.state.device_login_attempts.get(loginId)
785
- if not attempt:
786
- raise HTTPException(status_code=404, detail="Unknown loginId")
787
-
788
- # keep last ~50 lines
789
- tail = attempt.output[-50:]
790
- status = "pending"
791
- if attempt.done:
792
- status = "success" if attempt.returncode == 0 else "failed"
793
- return {
794
- "loginId": attempt.id,
795
- "status": status,
796
- "url": attempt.url,
797
- "code": attempt.code,
798
- "outputTail": tail,
799
- "returnCode": attempt.returncode,
800
- }
801
-
802
-
803
- @app.get("/api/mcp/tools")
804
- async def mcp_tools_list(http_request: Request):
805
- """
806
- List tools available from the local Codex MCP server (`codex mcp-server`).
807
- """
808
- if not _feature_enabled("mcp"):
809
- raise HTTPException(status_code=403, detail="MCP is disabled")
810
- _ = await _require_user_from_request(http_request)
811
- try:
812
- result = await app.state.codex_mcp_client.list_tools()
813
- return result
814
- except Exception as e:
815
- raise HTTPException(status_code=500, detail=str(e))
816
-
817
-
818
- class McpCallRequest(BaseModel):
819
- name: str
820
- arguments: dict
821
-
822
-
823
- @app.post("/api/mcp/call")
824
- async def mcp_tools_call(request: McpCallRequest, http_request: Request):
825
- """
826
- Call a tool on the local Codex MCP server (`codex mcp-server`).
827
- """
828
- if not _feature_enabled("mcp"):
829
- raise HTTPException(status_code=403, detail="MCP is disabled")
830
- _ = await _require_user_from_request(http_request)
831
- try:
832
- return await app.state.codex_mcp_client.call_tool(request.name, request.arguments)
833
- except Exception as e:
834
- raise HTTPException(status_code=500, detail=str(e))
835
-
836
- @app.websocket("/ws/terminal")
837
- async def websocket_terminal(websocket: WebSocket):
838
- await websocket.accept()
839
-
840
- if not _feature_enabled("terminal"):
841
- await websocket.send_text("\r\n[terminal disabled]\r\n")
842
- await websocket.close()
843
- return
844
-
845
- # Authenticate the WebSocket using a Supabase access token passed via query param.
846
- # Browser WebSocket APIs do not allow setting Authorization headers directly.
847
- token = (websocket.query_params.get("token") or "").strip()
848
- if not token:
849
- await websocket.send_text("\r\n[unauthorized: missing token]\r\n")
850
- await websocket.close()
851
- return
852
- try:
853
- user = await _verify_supabase_access_token(token)
854
- except HTTPException as e:
855
- await websocket.send_text(f"\r\n[unauthorized: {e.detail}]\r\n")
856
- await websocket.close()
857
- return
858
-
859
- # If token-based Codex auth is provided via env (HF Spaces Secrets), ensure the CLI auth file exists.
860
- # This makes `codex` work inside the web terminal even if the entrypoint didn't run (e.g., local dev).
861
- try:
862
- id_token = os.environ.get("CODEX_ID_TOKEN") or os.environ.get("ID_TOKEN") or ""
863
- access_token = os.environ.get("CODEX_ACCESS_TOKEN") or os.environ.get("ACCESS_TOKEN") or ""
864
- refresh_token = os.environ.get("CODEX_REFRESH_TOKEN") or os.environ.get("REFRESH_TOKEN") or ""
865
- account_id = os.environ.get("CODEX_ACCOUNT_ID") or os.environ.get("ACCOUNT_ID") or ""
866
- if id_token or access_token or refresh_token:
867
- codex_home = os.path.join(os.path.expanduser("~"), ".codex")
868
- os.makedirs(codex_home, exist_ok=True)
869
- auth = {
870
- "OPENAI_API_KEY": None,
871
- "tokens": {
872
- "id_token": id_token,
873
- "access_token": access_token,
874
- "refresh_token": refresh_token,
875
- "account_id": account_id,
876
- },
877
- "last_refresh": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
878
- }
879
- for filename in ("auth.json", ".auth.json"):
880
- path = os.path.join(codex_home, filename)
881
- with open(path, "w", encoding="utf-8") as f:
882
- json.dump(auth, f, indent=2)
883
- f.write("\n")
884
- try:
885
- os.chmod(path, 0o600)
886
- except Exception:
887
- pass
888
- except Exception:
889
- pass
890
-
891
- # Create PTY (required for an interactive shell). If the runtime has no PTY
892
- # devices (e.g., /dev/pts not mounted / exhausted), fail gracefully.
893
- try:
894
- master_fd, slave_fd = pty.openpty()
895
- except OSError as e:
896
- await websocket.send_text(
897
- "\r\n[terminal unavailable: PTY allocation failed]\r\n"
898
- f"{type(e).__name__}: {e}\r\n"
899
- )
900
- await websocket.close()
901
- return
902
-
903
- # Start shell
904
- env = os.environ.copy()
905
- env.setdefault("TERM", "xterm-256color")
906
- env.setdefault("COLORTERM", "truecolor")
907
- p = subprocess.Popen(
908
- ["/bin/bash", "-i"],
909
- preexec_fn=os.setsid,
910
- stdin=slave_fd,
911
- stdout=slave_fd,
912
- stderr=slave_fd,
913
- env=env,
914
- close_fds=True
915
- )
916
-
917
- os.close(slave_fd)
918
-
919
- loop = asyncio.get_running_loop()
920
-
921
- async def read_from_pty():
922
- while True:
923
- try:
924
- # Run in executor to avoid blocking the event loop
925
- data = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
926
- if not data:
927
- break
928
- await websocket.send_text(data.decode(errors='ignore'))
929
- except Exception:
930
- break
931
- await websocket.close()
932
-
933
- async def write_to_pty():
934
- try:
935
- while True:
936
- data = await websocket.receive_text()
937
- if data.startswith('\x01resize:'): # Custom resize protocol
938
- # Format: ^Aresize:cols:rows
939
- try:
940
- _, cols, rows = data.split(':')
941
- cols_i = int(cols)
942
- rows_i = int(rows)
943
- if cols_i < 2 or rows_i < 2:
944
- continue
945
- winsize = struct.pack("HHHH", rows_i, cols_i, 0, 0)
946
- fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize)
947
- except:
948
- pass
949
- else:
950
- os.write(master_fd, data.encode())
951
- except Exception:
952
- pass
953
 
954
- # Run tasks
955
- read_task = asyncio.create_task(read_from_pty())
956
- write_task = asyncio.create_task(write_to_pty())
957
 
958
- try:
959
- await asyncio.wait([read_task, write_task], return_when=asyncio.FIRST_COMPLETED)
960
- finally:
961
- read_task.cancel()
962
- write_task.cancel()
963
- p.terminate()
964
- os.close(master_fd)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from dotenv import load_dotenv
 
 
 
 
 
 
 
2
 
3
+ from app.server import create_app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
+ load_dotenv()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
+ app = create_app()
 
 
8
 
 
 
 
 
 
 
 
pyproject.toml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [tool.ruff]
2
+ target-version = "py311"
3
+ line-length = 120
4
+
5
+ [tool.ruff.lint]
6
+ select = ["E", "F", "I", "UP", "B"]
7
+
requirements-dev.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ pytest==8.3.4
2
+ ruff==0.8.4
3
+
tests/test_security_gates.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+ from starlette.websockets import WebSocketDisconnect
6
+
7
+ from app.server import create_app
8
+
9
+
10
+ @pytest.fixture()
11
+ def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
12
+ monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
13
+ monkeypatch.setenv("SUPABASE_KEY", "dummy")
14
+ monkeypatch.setenv("ENABLE_TERMINAL", "1")
15
+ monkeypatch.setenv("ENABLE_CODEX", "1")
16
+ monkeypatch.setenv("ENABLE_MCP", "1")
17
+ app = create_app()
18
+ return TestClient(app)
19
+
20
+
21
+ def test_codex_login_status_requires_auth(client: TestClient):
22
+ res = client.get("/api/codex/login/status")
23
+ assert res.status_code == 401
24
+
25
+
26
+ def test_mcp_tools_requires_auth(client: TestClient):
27
+ res = client.get("/api/mcp/tools")
28
+ assert res.status_code == 401
29
+
30
+
31
+ def test_terminal_ws_requires_token(client: TestClient):
32
+ with client.websocket_connect("/ws/terminal") as ws:
33
+ # Server accepts then sends an error + closes.
34
+ try:
35
+ msg = ws.receive_text()
36
+ except WebSocketDisconnect:
37
+ msg = ""
38
+ assert "unauthorized" in msg.lower() or msg == ""
39
+
40
+
41
+ def test_features_can_be_disabled(monkeypatch: pytest.MonkeyPatch):
42
+ monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
43
+ monkeypatch.setenv("SUPABASE_KEY", "dummy")
44
+ monkeypatch.setenv("ENABLE_CODEX", "0")
45
+ app = create_app()
46
+ c = TestClient(app)
47
+
48
+ res = c.get("/api/codex/login/status")
49
+ assert res.status_code == 403
50
+