ArunKr commited on
Commit
8f88962
·
verified ·
1 Parent(s): 16de162

Upload folder using huggingface_hub

Browse files
Files changed (7) hide show
  1. AGENTS.md +85 -17
  2. PLAN.md +87 -0
  3. README.md +2 -1
  4. SECURITY.md +24 -0
  5. docker-entrypoint.sh +45 -0
  6. main.py +171 -14
  7. static/dashboard.html +142 -32
AGENTS.md CHANGED
@@ -1,17 +1,85 @@
1
- # Instructions
2
- - Review the codebase and enhance this working code.
3
- - Convert the UI code to reactjs.
4
- - Include vim, git, codex, gemini-cli and claude-cli too in the container.
5
- - Embedd one web browser too, put it as tab.
6
- - Fix markdown rendering.
7
- - Include one @agent agent_name for triggering n8n workflow. Figure-out authentications.
8
- - Include multimodal input options.
9
- - Include one autonomous agent mode for running the code in terminal and in brouser. While running in agent mode split the chat and terminal/web browser window side-by-side.
10
- - Include feature to provide agent feedback and screenshots for autonomous agent mode.
11
- - Include options to integrate the app as MCP host Add admin panel for MCP server configuration.
12
- - Include some default MCP servers inbuilt such as n8n-mcp docs, n8n-mcp api, filesystem, gemini, claude, codex, canva, github as mcp servers, include admin panel too for MCP server configuration.
13
-
14
- such as https://github.com/czlonkowski/n8n-mcp, https://n8n.mcp.kapa.ai/
15
-
16
- INclude any other enhancement you feel interesting.
17
- Make the UI attractive too.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Autonomy Labs — Agent Notes
2
+
3
+ This repo powers a FastAPI web app deployed to Hugging Face Spaces (Docker). It includes:
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
+ ## Non-negotiables
11
+ - **Never commit secrets** (API keys, tokens, cookies, private SSH keys). Use env vars / HF Spaces Secrets.
12
+ - **Assume hostile clients**: UI checks are not security. Dangerous endpoints must be protected server-side.
13
+ - **Prefer minimal, reversible changes** with clear validation steps.
14
+
15
+ ## Repo map
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
+ ## How to run (local)
26
+ - Python deps: `pip install -r requirements.txt`
27
+ - Start: `uvicorn main:app --host 0.0.0.0 --port 7860`
28
+ - Open: `http://localhost:7860`
29
+
30
+ ## Key environment variables
31
+ ### Supabase
32
+ - `SUPABASE_URL`
33
+ - `SUPABASE_KEY` (anon key used by the frontend)
34
+
35
+ ### Chat defaults (UI convenience)
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
+ ### Codex (HF Spaces Secrets recommended)
41
+ Supported token env names (either set works):
42
+ - `CODEX_ID_TOKEN` or `ID_TOKEN`
43
+ - `CODEX_ACCESS_TOKEN` or `ACCESS_TOKEN`
44
+ - `CODEX_REFRESH_TOKEN` or `REFRESH_TOKEN`
45
+ - optional: `CODEX_ACCOUNT_ID` or `ACCOUNT_ID`
46
+
47
+ At runtime, the container writes:
48
+ - `~/.codex/.auth.json`
49
+ - `~/.codex/auth.json`
50
+
51
+ ### Gemini / Claude
52
+ Prefer env-based auth (keep tokens out of the UI and git):
53
+ - Gemini: typically `GEMINI_API_KEY` (or Google GenAI envs, depending on mode)
54
+ - Claude: typically `ANTHROPIC_API_KEY`
55
+
56
+ If adding “Codex-like” auth files for these CLIs, document **exact paths + formats** and keep them **generated at runtime** from Secrets.
57
+
58
+ ### SSH (optional)
59
+ Default behavior: container may generate `~/.ssh/id_ed25519` and persist to `/data/.ssh` (Spaces).
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 posture (important)
65
+ The web terminal and any agent/Codex/MCP execution is effectively remote code execution if exposed.
66
+
67
+ Before shipping features, ensure:
68
+ - server-side auth checks for `/ws/terminal` and all `/api/codex*` + `/api/mcp*` endpoints
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
+
PLAN.md ADDED
@@ -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
+
README.md CHANGED
@@ -70,9 +70,10 @@ This repo includes a manual workflow at `.github/workflows/codex-autofix.yml`.
70
  ## Notes
71
 
72
  - **Secrets**: don’t hardcode API keys in source. GitHub push protection will block pushes containing tokens.
 
73
  - **Terminal PTY**: the host/container must have PTY devices (`/dev/pts`) available for interactive terminals.
74
  - **Codex login (Hugging Face Spaces/web terminal)**: Spaces expose a single port, so localhost callback URLs (like `http://localhost:1455/auth/callback?...`) won’t work; use device auth: `codex login --device-auth` (alias: `codex-login`).
75
  - **Codex login persistence (Spaces)**: on startup the container will use `/data/.codex` (if available) for `~/.codex`, so device-auth stays logged in across restarts.
76
  - **Codex tokens (Spaces Secrets)**: if you already have tokens, set `CODEX_ID_TOKEN`, `CODEX_ACCESS_TOKEN`, `CODEX_REFRESH_TOKEN` (and optionally `CODEX_ACCOUNT_ID`) as Spaces Secrets; the container will write `~/.codex/.auth.json` (and `~/.codex/auth.json`) on startup.
77
  - **Gemini CLI**: installed as `gemini` via `npm i -g @google/gemini-cli`. Set one of `GEMINI_API_KEY`, `GOOGLE_GENAI_USE_VERTEXAI`, or `GOOGLE_GENAI_USE_GCA` (Spaces Secret recommended).
78
- - **Git over SSH (web terminal/Docker)**: the container auto-generates `~/.ssh/id_ed25519` on first start and prints the public key; add it to your Git provider, then use `git@github.com:ORG/REPO.git` URLs.
 
70
  ## Notes
71
 
72
  - **Secrets**: don’t hardcode API keys in source. GitHub push protection will block pushes containing tokens.
73
+ - **Security**: the web terminal and agent/Codex endpoints are gated by Supabase auth. Keep Supabase configured and avoid exposing execution features publicly without auth.
74
  - **Terminal PTY**: the host/container must have PTY devices (`/dev/pts`) available for interactive terminals.
75
  - **Codex login (Hugging Face Spaces/web terminal)**: Spaces expose a single port, so localhost callback URLs (like `http://localhost:1455/auth/callback?...`) won’t work; use device auth: `codex login --device-auth` (alias: `codex-login`).
76
  - **Codex login persistence (Spaces)**: on startup the container will use `/data/.codex` (if available) for `~/.codex`, so device-auth stays logged in across restarts.
77
  - **Codex tokens (Spaces Secrets)**: if you already have tokens, set `CODEX_ID_TOKEN`, `CODEX_ACCESS_TOKEN`, `CODEX_REFRESH_TOKEN` (and optionally `CODEX_ACCOUNT_ID`) as Spaces Secrets; the container will write `~/.codex/.auth.json` (and `~/.codex/auth.json`) on startup.
78
  - **Gemini CLI**: installed as `gemini` via `npm i -g @google/gemini-cli`. Set one of `GEMINI_API_KEY`, `GOOGLE_GENAI_USE_VERTEXAI`, or `GOOGLE_GENAI_USE_GCA` (Spaces Secret recommended).
79
+ - **Git over SSH (web terminal/Docker)**: the container auto-generates `~/.ssh/id_ed25519` on first start and prints the public key; add it to your Git provider, then use `git@github.com:ORG/REPO.git` URLs. To provide a key via Secrets instead, set `SSH_PRIVATE_KEY` (and optionally `SSH_PUBLIC_KEY`, `SSH_KNOWN_HOSTS`).
SECURITY.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Notes
2
+
3
+ This app includes features that can become remote-code-execution (RCE) if exposed publicly:
4
+ - Web terminal (`/ws/terminal`)
5
+ - Agent/Codex execution (`/api/codex*`)
6
+ - MCP tool calls (`/api/mcp*`)
7
+
8
+ ## Current protections
9
+ - The dashboard UI requires a Supabase session.
10
+ - The backend also enforces Supabase authentication for terminal/Codex/MCP endpoints (server-side).
11
+
12
+ ## Deployment guidance
13
+ - Do not run this app publicly without authentication.
14
+ - Use Hugging Face Spaces Secrets (or env vars) for all credentials.
15
+ - Consider disabling dangerous capabilities unless you explicitly need them:
16
+ - `ENABLE_TERMINAL`
17
+ - `ENABLE_CODEX`
18
+ - `ENABLE_MCP`
19
+ - `ENABLE_INDEXING`
20
+
21
+ ## Notes
22
+ - Browser clients cannot set `Authorization` headers for WebSockets, so the terminal WebSocket uses a Supabase access token passed via a query param. Treat app access tokens as sensitive.
23
+ - If you add RAG indexing, crawling, or repository ingestion, apply the same auth + rate limiting + allowlisting patterns.
24
+
docker-entrypoint.sh CHANGED
@@ -154,6 +154,50 @@ EOF
154
  fi
155
  }
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  persist_codex_dir_if_possible
158
  ensure_codex_home_permissions
159
  ensure_codex_auth_from_env
@@ -161,6 +205,7 @@ persist_ssh_dir_if_possible
161
  ensure_codex_workspace_dir
162
 
163
  if command -v ssh-keygen >/dev/null 2>&1; then
 
164
  ensure_ssh_keypair
165
  else
166
  echo "[git ssh] ssh-keygen not found; install openssh-client to enable SSH key generation." >&2
 
154
  fi
155
  }
156
 
157
+ ensure_ssh_from_env() {
158
+ local ssh_dir="${HOME}/.ssh"
159
+ local key_path="${ssh_dir}/id_ed25519"
160
+
161
+ if [[ -z "${SSH_PRIVATE_KEY:-}" ]]; then
162
+ return 0
163
+ fi
164
+
165
+ mkdir -p "${ssh_dir}"
166
+ chmod 700 "${ssh_dir}"
167
+
168
+ if [[ -f "${key_path}" ]] && [[ "${SSH_OVERWRITE:-}" != "1" ]]; then
169
+ echo "[ssh] SSH_PRIVATE_KEY provided but ${key_path} already exists; set SSH_OVERWRITE=1 to replace."
170
+ return 0
171
+ fi
172
+
173
+ printf '%s\n' "${SSH_PRIVATE_KEY}" >"${key_path}"
174
+ chmod 600 "${key_path}"
175
+
176
+ if [[ -n "${SSH_PUBLIC_KEY:-}" ]]; then
177
+ printf '%s\n' "${SSH_PUBLIC_KEY}" >"${key_path}.pub"
178
+ chmod 644 "${key_path}.pub" || true
179
+ elif command -v ssh-keygen >/dev/null 2>&1; then
180
+ ssh-keygen -y -f "${key_path}" >"${key_path}.pub" 2>/dev/null || true
181
+ chmod 644 "${key_path}.pub" || true
182
+ fi
183
+
184
+ if [[ -n "${SSH_KNOWN_HOSTS:-}" ]]; then
185
+ printf '%s\n' "${SSH_KNOWN_HOSTS}" >"${ssh_dir}/known_hosts"
186
+ chmod 600 "${ssh_dir}/known_hosts" || true
187
+ fi
188
+
189
+ cat >"${ssh_dir}/config" <<'EOF'
190
+ Host *
191
+ AddKeysToAgent no
192
+ IdentitiesOnly yes
193
+ StrictHostKeyChecking accept-new
194
+ EOF
195
+ chmod 600 "${ssh_dir}/config" || true
196
+ chown -R "$(id -u)":"$(id -g)" "${ssh_dir}" 2>/dev/null || true
197
+
198
+ echo "[ssh] Installed SSH key from env into ${key_path}"
199
+ }
200
+
201
  persist_codex_dir_if_possible
202
  ensure_codex_home_permissions
203
  ensure_codex_auth_from_env
 
205
  ensure_codex_workspace_dir
206
 
207
  if command -v ssh-keygen >/dev/null 2>&1; then
208
+ ensure_ssh_from_env
209
  ensure_ssh_keypair
210
  else
211
  echo "[git ssh] ssh-keygen not found; install openssh-client to enable SSH key generation." >&2
main.py CHANGED
@@ -18,6 +18,8 @@ import json
18
  import uuid
19
  from dataclasses import dataclass, field
20
  from datetime import datetime, timezone
 
 
21
 
22
  load_dotenv()
23
 
@@ -33,6 +35,108 @@ app.add_middleware(
33
 
34
  app.mount("/static", StaticFiles(directory="static"), name="static")
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  @app.get("/config")
37
  async def get_config():
38
  return {
@@ -137,6 +241,7 @@ class CodexRequest(BaseModel):
137
  apiKey: Optional[str] = None
138
  baseUrl: Optional[str] = None
139
  modelReasoningEffort: Optional[str] = "minimal"
 
140
 
141
 
142
  def _default_codex_workdir() -> str:
@@ -146,13 +251,17 @@ def _default_codex_workdir() -> str:
146
  return os.path.dirname(__file__)
147
 
148
  @app.post("/api/codex")
149
- async def codex_agent(request: CodexRequest):
150
  """
151
  Runs the local Codex agent via the official @openai/codex-sdk wrapper (Node.js).
152
  Persists threads under ~/.codex/sessions (mapped to /data/.codex on Spaces by entrypoint).
153
  """
154
  if not request.message.strip():
155
  raise HTTPException(status_code=400, detail="Message is required")
 
 
 
 
156
 
157
  node = os.environ.get("NODE_BIN", "node")
158
  script_path = os.path.join(os.path.dirname(__file__), "codex_agent.mjs")
@@ -166,7 +275,7 @@ async def codex_agent(request: CodexRequest):
166
  "sandboxMode": request.sandboxMode,
167
  "approvalPolicy": request.approvalPolicy,
168
  "modelReasoningEffort": request.modelReasoningEffort,
169
- "workingDirectory": _default_codex_workdir(),
170
  }
171
 
172
  try:
@@ -206,7 +315,7 @@ async def codex_agent(request: CodexRequest):
206
  raise HTTPException(status_code=500, detail=str(e))
207
 
208
  @app.post("/api/codex/cli")
209
- async def codex_agent_cli(request: CodexRequest):
210
  """
211
  Runs Codex directly via the CLI (`codex exec --json`) and extracts the final agent message.
212
 
@@ -214,6 +323,10 @@ async def codex_agent_cli(request: CodexRequest):
214
  """
215
  if not request.message.strip():
216
  raise HTTPException(status_code=400, detail="Message is required")
 
 
 
 
217
 
218
  def _with_codex_agent_prefix(message: str) -> str:
219
  msg = message.strip()
@@ -232,7 +345,7 @@ async def codex_agent_cli(request: CodexRequest):
232
  if request.model:
233
  base_args += ["--model", request.model]
234
  # Run inside app dir; allow even if not a git repo (Spaces copies are git, but keep safe)
235
- base_args += ["--cd", _default_codex_workdir(), "--skip-git-repo-check"]
236
 
237
  # Provide the prompt as an argument (avoids "Reading prompt from stdin..." paths).
238
  if request.threadId:
@@ -309,7 +422,7 @@ async def codex_agent_cli(request: CodexRequest):
309
  raise HTTPException(status_code=500, detail=str(e))
310
 
311
  @app.post("/api/codex/cli/stream")
312
- async def codex_agent_cli_stream(request: CodexRequest):
313
  """
314
  Streams Codex CLI JSONL events (NDJSON) as the agent runs.
315
 
@@ -318,6 +431,10 @@ async def codex_agent_cli_stream(request: CodexRequest):
318
  """
319
  if not request.message.strip():
320
  raise HTTPException(status_code=400, detail="Message is required")
 
 
 
 
321
 
322
  def _with_codex_agent_prefix(message: str) -> str:
323
  msg = message.strip()
@@ -332,7 +449,7 @@ async def codex_agent_cli_stream(request: CodexRequest):
332
  base_args += ["--config", f'approval_policy=\"{request.approvalPolicy}\"']
333
  if request.model:
334
  base_args += ["--model", request.model]
335
- base_args += ["--cd", _default_codex_workdir(), "--skip-git-repo-check"]
336
 
337
  if request.threadId:
338
  base_args += ["resume", request.threadId, message]
@@ -410,10 +527,13 @@ async def codex_agent_cli_stream(request: CodexRequest):
410
 
411
 
412
  @app.get("/api/codex/mcp")
413
- async def codex_mcp_list():
414
  """
415
  Lists configured Codex MCP servers by shelling out to `codex mcp list`.
416
  """
 
 
 
417
  try:
418
  proc = await asyncio.create_subprocess_exec(
419
  "codex",
@@ -437,12 +557,15 @@ async def codex_mcp_list():
437
 
438
 
439
  @app.get("/api/codex/mcp/details")
440
- async def codex_mcp_details():
441
  """
442
  Returns `codex mcp get --json` for each configured server.
443
  """
 
 
 
444
  try:
445
- servers_resp = await codex_mcp_list()
446
  names = servers_resp.get("servers", []) if isinstance(servers_resp, dict) else []
447
  details = []
448
  for name in names:
@@ -468,10 +591,13 @@ async def codex_mcp_details():
468
 
469
 
470
  @app.get("/api/codex/login/status")
471
- async def codex_login_status():
472
  """
473
  Returns Codex CLI login status for device-auth based sessions.
474
  """
 
 
 
475
  try:
476
  proc = await asyncio.create_subprocess_exec(
477
  "codex",
@@ -624,10 +750,13 @@ async def _read_device_login_output(attempt: DeviceLoginAttempt) -> None:
624
 
625
 
626
  @app.post("/api/codex/login/device/start")
627
- async def codex_login_device_start():
628
  """
629
  Starts `codex login --device-auth` and returns the device URL + code (when available).
630
  """
 
 
 
631
  async with app.state.device_login_lock:
632
  proc = await asyncio.create_subprocess_exec(
633
  "codex",
@@ -648,7 +777,10 @@ async def codex_login_device_start():
648
 
649
 
650
  @app.get("/api/codex/login/device/status")
651
- async def codex_login_device_status(loginId: str):
 
 
 
652
  attempt = app.state.device_login_attempts.get(loginId)
653
  if not attempt:
654
  raise HTTPException(status_code=404, detail="Unknown loginId")
@@ -669,10 +801,13 @@ async def codex_login_device_status(loginId: str):
669
 
670
 
671
  @app.get("/api/mcp/tools")
672
- async def mcp_tools_list():
673
  """
674
  List tools available from the local Codex MCP server (`codex mcp-server`).
675
  """
 
 
 
676
  try:
677
  result = await app.state.codex_mcp_client.list_tools()
678
  return result
@@ -686,10 +821,13 @@ class McpCallRequest(BaseModel):
686
 
687
 
688
  @app.post("/api/mcp/call")
689
- async def mcp_tools_call(request: McpCallRequest):
690
  """
691
  Call a tool on the local Codex MCP server (`codex mcp-server`).
692
  """
 
 
 
693
  try:
694
  return await app.state.codex_mcp_client.call_tool(request.name, request.arguments)
695
  except Exception as e:
@@ -699,6 +837,25 @@ async def mcp_tools_call(request: McpCallRequest):
699
  async def websocket_terminal(websocket: WebSocket):
700
  await websocket.accept()
701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  # If token-based Codex auth is provided via env (HF Spaces Secrets), ensure the CLI auth file exists.
703
  # This makes `codex` work inside the web terminal even if the entrypoint didn't run (e.g., local dev).
704
  try:
 
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
 
 
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 {
 
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:
 
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")
 
275
  "sandboxMode": request.sandboxMode,
276
  "approvalPolicy": request.approvalPolicy,
277
  "modelReasoningEffort": request.modelReasoningEffort,
278
+ "workingDirectory": _safe_user_workdir(user, request.workingDirectory),
279
  }
280
 
281
  try:
 
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
 
 
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()
 
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:
 
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
 
 
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()
 
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]
 
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",
 
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:
 
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",
 
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",
 
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")
 
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
 
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:
 
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:
static/dashboard.html CHANGED
@@ -716,6 +716,13 @@
716
  </select>
717
  <div class="text-xs text-gray-500 mt-1">Use <code>danger-full-access</code> only if you fully trust the model and this environment.</div>
718
  </div>
 
 
 
 
 
 
 
719
  <div class="mt-3">
720
  <label class="flex items-center justify-between gap-3 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
721
  <div>
@@ -1329,6 +1336,15 @@
1329
  localStorage.setItem('codex_sandbox_mode_v1', mode);
1330
  }
1331
 
 
 
 
 
 
 
 
 
 
1332
  function getAgentUseCodexCli() {
1333
  return localStorage.getItem('agent_use_codex_cli_v1') === '1';
1334
  }
@@ -1339,11 +1355,31 @@
1339
  if (el) el.checked = !!enabled;
1340
  }
1341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1342
  async function refreshCodexMcpServers() {
1343
  const out = document.getElementById('codex-mcp-list');
1344
  if (out) out.textContent = 'Loading...';
1345
  try {
1346
- const res = await fetch('/api/codex/mcp');
1347
  const data = await res.json();
1348
  const names = Array.isArray(data?.servers) ? data.servers : [];
1349
  if (out) out.textContent = names.length ? `Configured: ${names.join(', ')}` : 'No MCP servers configured.';
@@ -1361,6 +1397,10 @@
1361
 
1362
  const { data: { session } } = await supabase.auth.getSession();
1363
  if (!session) { window.location.href = '/'; return; }
 
 
 
 
1364
 
1365
  // Theme
1366
  const savedTheme = localStorage.getItem('theme_v1');
@@ -1394,6 +1434,9 @@
1394
  if (localStorage.getItem('codex_sandbox_mode_v1') == null) {
1395
  localStorage.setItem('codex_sandbox_mode_v1', 'danger-full-access');
1396
  }
 
 
 
1397
 
1398
  // Logged-in user badge (email + username)
1399
  try {
@@ -1438,10 +1481,12 @@
1438
  const codexKey = document.getElementById('codex-api-key');
1439
  const codexModel = document.getElementById('codex-model');
1440
  const codexSandbox = document.getElementById('codex-sandbox-mode');
 
1441
  if (codexBase) codexBase.value = codexSettings.baseUrl;
1442
  if (codexKey) codexKey.value = codexSettings.apiKey;
1443
  if (codexModel) codexModel.value = codexSettings.model;
1444
  if (codexSandbox) codexSandbox.value = getCodexSandboxMode();
 
1445
  setCodexShowJsonl(getCodexShowJsonl());
1446
  setAgentUseCodexCli(getAgentUseCodexCli());
1447
 
@@ -1618,7 +1663,9 @@
1618
  term.loadAddon(fitAddon);
1619
  term.open(termDiv);
1620
 
1621
- const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/terminal`);
 
 
1622
  // Don't fit immediately here; terminals are often created while their view is hidden.
1623
  ws.onopen = () => { setTimeout(() => fitTerm(id), 0); };
1624
  ws.onmessage = (event) => { term.write(event.data); };
@@ -2454,7 +2501,7 @@
2454
 
2455
  async function getCodexLoginStatus() {
2456
  try {
2457
- const res = await fetch('/api/codex/login/status');
2458
  if (!res.ok) return { loggedIn: false, statusText: `HTTP ${res.status}` };
2459
  return await res.json();
2460
  } catch (e) {
@@ -2504,7 +2551,7 @@
2504
  aiMsgEl.innerHTML = '';
2505
 
2506
  try {
2507
- const res = await fetch('/api/codex/cli/stream', {
2508
  method: 'POST',
2509
  headers: { 'Content-Type': 'application/json' },
2510
  body: JSON.stringify({
@@ -2514,6 +2561,7 @@
2514
  sandboxMode,
2515
  approvalPolicy: 'never',
2516
  modelReasoningEffort: 'minimal',
 
2517
  apiKey: apiKey || null,
2518
  baseUrl: baseUrl || null
2519
  })
@@ -2625,7 +2673,7 @@
2625
  addMessageToUI('user', '/mcp');
2626
  const ai = addMessageToUI('assistant', 'Loading MCP tools...');
2627
  try {
2628
- const res = await fetch('/api/mcp/tools');
2629
  const data = await res.json();
2630
  const tools = Array.isArray(data?.tools) ? data.tools : [];
2631
  if (!tools.length) {
@@ -2704,42 +2752,104 @@
2704
  try {
2705
  agentAbortController = new AbortController();
2706
  setAgentGenerating(true);
2707
- const res = await (async () => {
2708
- if (getAgentUseCodexCli()) {
2709
- const ok = await ensureCodexAuthenticated();
2710
- if (!ok) throw new Error('Codex not authenticated');
2711
- const codex = getCodexSdkSettings();
2712
- const codexModel = (codex.model || '').trim();
2713
- return fetch('/api/codex/cli', {
2714
- method: 'POST',
2715
- headers: { 'Content-Type': 'application/json' },
2716
- body: JSON.stringify({
2717
- message: agentChatHistory.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n\n'),
2718
- threadId: null,
2719
- model: codexModel || null,
2720
- sandboxMode: 'workspace-write',
2721
- approvalPolicy: 'never'
2722
- }),
2723
- signal: agentAbortController.signal
2724
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2725
  }
2726
 
 
 
 
 
2727
  const apiKey = document.getElementById('chat-api-key').value;
2728
  const baseUrl = document.getElementById('chat-base-url').value;
2729
- return fetch('/api/chat', {
2730
  method: 'POST',
2731
  headers: { 'Content-Type': 'application/json' },
2732
  body: JSON.stringify({ messages: agentChatHistory, apiKey, baseUrl, model }),
2733
  signal: agentAbortController.signal
2734
  });
2735
- })();
2736
-
2737
- if (getAgentUseCodexCli()) {
2738
- const data = await res.json();
2739
- aiContent = data?.finalResponse || '';
2740
- renderMarkdownInto(aiMsgEl, aiContent);
2741
- agentChatHistory.push({ role: 'assistant', content: aiContent });
2742
- } else {
2743
  const reader = res.body.getReader();
2744
  const decoder = new TextDecoder();
2745
  aiMsgEl.innerHTML = '';
 
716
  </select>
717
  <div class="text-xs text-gray-500 mt-1">Use <code>danger-full-access</code> only if you fully trust the model and this environment.</div>
718
  </div>
719
+ <div class="mt-3">
720
+ <label class="block text-xs font-semibold text-gray-400 mb-1 uppercase">Codex Workspace Directory (optional)</label>
721
+ <input type="text" id="codex-workdir" placeholder="/data/codex/workspace"
722
+ class="w-full bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500"
723
+ onchange="saveCodexWorkdir()">
724
+ <div class="text-xs text-gray-500 mt-1">Used as <code>--cd</code> for Codex runs. Restricted to <code>/data/codex/workspace</code> when available.</div>
725
+ </div>
726
  <div class="mt-3">
727
  <label class="flex items-center justify-between gap-3 bg-gray-800/40 border border-gray-700 rounded-lg px-3 py-2">
728
  <div>
 
1336
  localStorage.setItem('codex_sandbox_mode_v1', mode);
1337
  }
1338
 
1339
+ function getCodexWorkdir() {
1340
+ return localStorage.getItem('codex_workdir_v1') || '';
1341
+ }
1342
+
1343
+ function saveCodexWorkdir() {
1344
+ const dir = document.getElementById('codex-workdir')?.value || '';
1345
+ localStorage.setItem('codex_workdir_v1', dir.trim());
1346
+ }
1347
+
1348
  function getAgentUseCodexCli() {
1349
  return localStorage.getItem('agent_use_codex_cli_v1') === '1';
1350
  }
 
1355
  if (el) el.checked = !!enabled;
1356
  }
1357
 
1358
+ async function getAccessToken() {
1359
+ const direct = (window.__sbAccessToken || '').trim();
1360
+ if (direct) return direct;
1361
+ try {
1362
+ const { data: { session } } = await supabase.auth.getSession();
1363
+ const tok = (session?.access_token || '').trim();
1364
+ if (tok) window.__sbAccessToken = tok;
1365
+ return tok;
1366
+ } catch {
1367
+ return '';
1368
+ }
1369
+ }
1370
+
1371
+ async function authFetch(url, options = {}) {
1372
+ const token = await getAccessToken();
1373
+ const headers = new Headers(options.headers || {});
1374
+ if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
1375
+ return fetch(url, { ...options, headers });
1376
+ }
1377
+
1378
  async function refreshCodexMcpServers() {
1379
  const out = document.getElementById('codex-mcp-list');
1380
  if (out) out.textContent = 'Loading...';
1381
  try {
1382
+ const res = await authFetch('/api/codex/mcp');
1383
  const data = await res.json();
1384
  const names = Array.isArray(data?.servers) ? data.servers : [];
1385
  if (out) out.textContent = names.length ? `Configured: ${names.join(', ')}` : 'No MCP servers configured.';
 
1397
 
1398
  const { data: { session } } = await supabase.auth.getSession();
1399
  if (!session) { window.location.href = '/'; return; }
1400
+ window.__sbAccessToken = (session.access_token || '').trim();
1401
+ supabase.auth.onAuthStateChange((_event, nextSession) => {
1402
+ window.__sbAccessToken = (nextSession?.access_token || '').trim();
1403
+ });
1404
 
1405
  // Theme
1406
  const savedTheme = localStorage.getItem('theme_v1');
 
1434
  if (localStorage.getItem('codex_sandbox_mode_v1') == null) {
1435
  localStorage.setItem('codex_sandbox_mode_v1', 'danger-full-access');
1436
  }
1437
+ if (localStorage.getItem('codex_workdir_v1') == null) {
1438
+ localStorage.setItem('codex_workdir_v1', '');
1439
+ }
1440
 
1441
  // Logged-in user badge (email + username)
1442
  try {
 
1481
  const codexKey = document.getElementById('codex-api-key');
1482
  const codexModel = document.getElementById('codex-model');
1483
  const codexSandbox = document.getElementById('codex-sandbox-mode');
1484
+ const codexWorkdir = document.getElementById('codex-workdir');
1485
  if (codexBase) codexBase.value = codexSettings.baseUrl;
1486
  if (codexKey) codexKey.value = codexSettings.apiKey;
1487
  if (codexModel) codexModel.value = codexSettings.model;
1488
  if (codexSandbox) codexSandbox.value = getCodexSandboxMode();
1489
+ if (codexWorkdir) codexWorkdir.value = getCodexWorkdir();
1490
  setCodexShowJsonl(getCodexShowJsonl());
1491
  setAgentUseCodexCli(getAgentUseCodexCli());
1492
 
 
1663
  term.loadAddon(fitAddon);
1664
  term.open(termDiv);
1665
 
1666
+ const token = (window.__sbAccessToken || '').trim();
1667
+ const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/terminal` + (token ? `?token=${encodeURIComponent(token)}` : '');
1668
+ const ws = new WebSocket(wsUrl);
1669
  // Don't fit immediately here; terminals are often created while their view is hidden.
1670
  ws.onopen = () => { setTimeout(() => fitTerm(id), 0); };
1671
  ws.onmessage = (event) => { term.write(event.data); };
 
2501
 
2502
  async function getCodexLoginStatus() {
2503
  try {
2504
+ const res = await authFetch('/api/codex/login/status');
2505
  if (!res.ok) return { loggedIn: false, statusText: `HTTP ${res.status}` };
2506
  return await res.json();
2507
  } catch (e) {
 
2551
  aiMsgEl.innerHTML = '';
2552
 
2553
  try {
2554
+ const res = await authFetch('/api/codex/cli/stream', {
2555
  method: 'POST',
2556
  headers: { 'Content-Type': 'application/json' },
2557
  body: JSON.stringify({
 
2561
  sandboxMode,
2562
  approvalPolicy: 'never',
2563
  modelReasoningEffort: 'minimal',
2564
+ workingDirectory: getCodexWorkdir() || null,
2565
  apiKey: apiKey || null,
2566
  baseUrl: baseUrl || null
2567
  })
 
2673
  addMessageToUI('user', '/mcp');
2674
  const ai = addMessageToUI('assistant', 'Loading MCP tools...');
2675
  try {
2676
+ const res = await authFetch('/api/mcp/tools');
2677
  const data = await res.json();
2678
  const tools = Array.isArray(data?.tools) ? data.tools : [];
2679
  if (!tools.length) {
 
2752
  try {
2753
  agentAbortController = new AbortController();
2754
  setAgentGenerating(true);
2755
+ if (getAgentUseCodexCli()) {
2756
+ const ok = await ensureCodexAuthenticated();
2757
+ if (!ok) throw new Error('Codex not authenticated');
2758
+ const codex = getCodexSdkSettings();
2759
+ const codexModel = (codex.model || '').trim();
2760
+
2761
+ const res = await authFetch('/api/codex/cli/stream', {
2762
+ method: 'POST',
2763
+ headers: { 'Content-Type': 'application/json' },
2764
+ body: JSON.stringify({
2765
+ message: agentChatHistory.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n\n'),
2766
+ threadId: null,
2767
+ model: codexModel || null,
2768
+ sandboxMode: 'workspace-write',
2769
+ approvalPolicy: 'never',
2770
+ modelReasoningEffort: 'minimal',
2771
+ workingDirectory: getCodexWorkdir() || null
2772
+ }),
2773
+ signal: agentAbortController.signal
2774
+ });
2775
+
2776
+ if (!res.ok) {
2777
+ const txt = await res.text();
2778
+ throw new Error(txt || `HTTP ${res.status}`);
2779
+ }
2780
+
2781
+ const reader = res.body.getReader();
2782
+ const decoder = new TextDecoder();
2783
+ let buffer = '';
2784
+ let finalText = '';
2785
+ const progress = [];
2786
+
2787
+ const pushProgress = (line) => {
2788
+ if (!line) return;
2789
+ progress.push(line);
2790
+ if (progress.length > 200) progress.splice(0, progress.length - 200);
2791
+ };
2792
+
2793
+ const summarizeEvent = (evt) => {
2794
+ const t = evt?.type || 'event';
2795
+ if (t === 'thread.started') return `thread.started: ${evt.thread_id || ''}`.trim();
2796
+ if (t === 'turn.completed') return `turn.completed: in=${evt.usage?.input_tokens || 0} out=${evt.usage?.output_tokens || 0}`;
2797
+ if (t === 'turn.failed') return `turn.failed: ${evt.error?.message || evt.message || ''}`.trim();
2798
+ if (t === 'stderr') return `stderr: ${evt.message || ''}`.trim();
2799
+ if (t === 'done') return `done: rc=${evt.returnCode ?? ''}`.trim();
2800
+ const itemType = evt?.item?.type;
2801
+ if (itemType && (t === 'item.started' || t === 'item.updated' || t === 'item.completed')) {
2802
+ const name = evt?.item?.name || evt?.item?.tool_name || '';
2803
+ const extra = name ? ` (${name})` : '';
2804
+ return `${t}: ${itemType}${extra}`;
2805
+ }
2806
+ return t;
2807
+ };
2808
+
2809
+ const renderProgress = () => {
2810
+ const content = [
2811
+ progress.length ? 'Progress:' : '',
2812
+ progress.length ? '```text\n' + progress.slice(-18).join('\n') + '\n```' : '',
2813
+ finalText ? '\n\n' + finalText : ''
2814
+ ].filter(Boolean).join('\n');
2815
+ renderMarkdownInto(aiMsgEl, content || '...');
2816
+ scrollAgentToBottom();
2817
+ };
2818
+
2819
+ while (true) {
2820
+ const { done, value } = await reader.read();
2821
+ if (done) break;
2822
+ buffer += decoder.decode(value, { stream: true });
2823
+ const lines = buffer.split('\n');
2824
+ buffer = lines.pop() || '';
2825
+ for (const line of lines) {
2826
+ if (!line.trim()) continue;
2827
+ let evt;
2828
+ try { evt = JSON.parse(line); } catch { continue; }
2829
+ pushProgress(summarizeEvent(evt));
2830
+ if (evt.type === 'item.updated' && evt.item?.type === 'agent_message' && evt.item?.text) {
2831
+ finalText = evt.item.text || finalText;
2832
+ } else if (evt.type === 'item.completed' && evt.item?.type === 'agent_message') {
2833
+ finalText = evt.item.text || finalText;
2834
+ } else if (evt.type === 'done') {
2835
+ if (evt.finalResponse) finalText = evt.finalResponse;
2836
+ }
2837
+ renderProgress();
2838
+ }
2839
  }
2840
 
2841
+ aiContent = finalText;
2842
+ renderMarkdownInto(aiMsgEl, aiContent || (progress.length ? progress.slice(-18).join('\n') : ''));
2843
+ agentChatHistory.push({ role: 'assistant', content: aiContent || '' });
2844
+ } else {
2845
  const apiKey = document.getElementById('chat-api-key').value;
2846
  const baseUrl = document.getElementById('chat-base-url').value;
2847
+ const res = await fetch('/api/chat', {
2848
  method: 'POST',
2849
  headers: { 'Content-Type': 'application/json' },
2850
  body: JSON.stringify({ messages: agentChatHistory, apiKey, baseUrl, model }),
2851
  signal: agentAbortController.signal
2852
  });
 
 
 
 
 
 
 
 
2853
  const reader = res.body.getReader();
2854
  const decoder = new TextDecoder();
2855
  aiMsgEl.innerHTML = '';