Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- AGENTS.md +85 -17
- PLAN.md +87 -0
- README.md +2 -1
- SECURITY.md +24 -0
- docker-entrypoint.sh +45 -0
- main.py +171 -14
- static/dashboard.html +142 -32
AGENTS.md
CHANGED
|
@@ -1,17 +1,85 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
-
|
| 5 |
-
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
-
|
| 12 |
-
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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",
|
| 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",
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
| 2708 |
-
|
| 2709 |
-
|
| 2710 |
-
|
| 2711 |
-
|
| 2712 |
-
|
| 2713 |
-
|
| 2714 |
-
|
| 2715 |
-
|
| 2716 |
-
|
| 2717 |
-
|
| 2718 |
-
|
| 2719 |
-
|
| 2720 |
-
|
| 2721 |
-
|
| 2722 |
-
|
| 2723 |
-
|
| 2724 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2725 |
}
|
| 2726 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2727 |
const apiKey = document.getElementById('chat-api-key').value;
|
| 2728 |
const baseUrl = document.getElementById('chat-base-url').value;
|
| 2729 |
-
|
| 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 = '';
|