Codex Agent integration: HTTP surface + plugin bundle + Settings UI
This persists work that had been living only in the cookbook docker
container's writable layer — never committed to the host source. Brought
back to git intact, app.py registration re-applied surgically on top of
current main (not the older container copy, which would have regressed
the Windows MIME fix, asynccontextmanager lifespan, and webhook auth
exempts).
routes/codex_routes.py (new):
- GET /api/codex/capabilities — what this Odysseus exposes.
- GET /api/codex/plugin.zip — downloads integrations/codex as a zip.
- GET /api/codex/todos — scope-gated todos:read|write.
- POST /api/codex/todos — scope-gated todos:write.
- GET /api/codex/emails — scope-gated email:read|draft|send.
- GET /api/codex/emails/{uid} — single-message fetch.
- _scope_owner() enforces api_token scopes before touching user data.
routes/api_token_routes.py (+103 lines):
- Adds Codex-token-specific issuance + revocation paths.
integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip):
- README.md — install instructions.
- .codex-plugin/plugin.json — Codex plugin manifest.
- scripts/odysseus_api.py — Python client used by the skill.
- skills/odysseus/SKILL.md — Codex skill definition.
static/js/settings.js (+253 lines):
- New "Codex Agent" option in the Integrations dropdown.
- Add / edit panel with plugin-bundle download link + curl-with-token
install instructions per agent.
app.py:
- 7-line surgical change: capture email_router = setup_email_routes()
and register setup_codex_routes(email_router=email_router) after the
email module so the Codex routes can borrow its helpers.
This commit is contained in:
11
app.py
11
app.py
@@ -690,7 +690,16 @@ app.include_router(setup_note_routes(task_scheduler))
|
|||||||
|
|
||||||
# Email
|
# Email
|
||||||
from routes.email_routes import setup_email_routes
|
from routes.email_routes import setup_email_routes
|
||||||
app.include_router(setup_email_routes())
|
email_router = setup_email_routes()
|
||||||
|
app.include_router(email_router)
|
||||||
|
|
||||||
|
# Codex integration — HTTP surface for the Codex plugin/MCP bridge. Reuses
|
||||||
|
# api_token scopes (todos:read|write, email:read|draft|send) so external
|
||||||
|
# Codex sessions can only touch the data the user explicitly allowed. Mounted
|
||||||
|
# AFTER email so the codex_routes can borrow the email router for shared
|
||||||
|
# search/threading helpers.
|
||||||
|
from routes.codex_routes import setup_codex_routes
|
||||||
|
app.include_router(setup_codex_routes(email_router=email_router))
|
||||||
|
|
||||||
from routes.vault_routes import setup_vault_routes
|
from routes.vault_routes import setup_vault_routes
|
||||||
app.include_router(setup_vault_routes())
|
app.include_router(setup_vault_routes())
|
||||||
|
|||||||
22
integrations/codex/.codex-plugin/plugin.json
Normal file
22
integrations/codex/.codex-plugin/plugin.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "odysseus",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Connect Codex to a scoped Odysseus instance.",
|
||||||
|
"author": {
|
||||||
|
"name": "Odysseus"
|
||||||
|
},
|
||||||
|
"skills": "./skills/",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Odysseus",
|
||||||
|
"shortDescription": "Use scoped Odysseus tools from Codex.",
|
||||||
|
"longDescription": "Connects Codex terminal sessions to Odysseus through user-controlled scoped API tokens. Codex must use /api/codex/* endpoints so Odysseus Settings can enforce tool access.",
|
||||||
|
"developerName": "Odysseus",
|
||||||
|
"category": "Productivity",
|
||||||
|
"capabilities": [
|
||||||
|
"todos",
|
||||||
|
"email",
|
||||||
|
"scoped-api"
|
||||||
|
],
|
||||||
|
"defaultPrompt": "Use Odysseus only through configured scoped access. Check capabilities before reading or writing data."
|
||||||
|
}
|
||||||
|
}
|
||||||
51
integrations/codex/README.md
Normal file
51
integrations/codex/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Odysseus Codex Integration
|
||||||
|
|
||||||
|
This directory contains the Codex plugin/skill bundle for Odysseus.
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
1. Open Odysseus Settings > Integrations.
|
||||||
|
2. Add a Codex Agent.
|
||||||
|
3. Copy the full setup commands shown after the generated token.
|
||||||
|
4. Toggle the tools Codex is allowed to use.
|
||||||
|
5. Configure the terminal Codex session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ODYSSEUS_URL=http://your-odysseus-host:7000
|
||||||
|
export ODYSSEUS_API_TOKEN=ody_generated_token
|
||||||
|
mkdir -p ~/plugins
|
||||||
|
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
|
||||||
|
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
p = Path.home() / ".agents" / "plugins" / "marketplace.json"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if p.exists():
|
||||||
|
data = json.loads(p.read_text())
|
||||||
|
else:
|
||||||
|
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
|
||||||
|
|
||||||
|
data.setdefault("name", "personal")
|
||||||
|
data.setdefault("interface", {}).setdefault("displayName", "Personal")
|
||||||
|
plugins = data.setdefault("plugins", [])
|
||||||
|
entry = {
|
||||||
|
"name": "odysseus",
|
||||||
|
"source": {"source": "local", "path": "./plugins/odysseus"},
|
||||||
|
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
|
||||||
|
"category": "Productivity",
|
||||||
|
}
|
||||||
|
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
|
||||||
|
p.write_text(json.dumps(data, indent=2) + "\n")
|
||||||
|
PY
|
||||||
|
codex plugin add odysseus@personal
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
Codex must use `/api/codex/*` endpoints. SSH, Docker, direct Python imports, database queries, and MCP internals bypass Odysseus Settings and must not be used for user data access.
|
||||||
122
integrations/codex/scripts/odysseus_api.py
Executable file
122
integrations/codex/scripts/odysseus_api.py
Executable file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small Odysseus scoped API helper for Codex terminal sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def _usage() -> int:
|
||||||
|
print("usage:", file=sys.stderr)
|
||||||
|
print(" odysseus_api.py capabilities", file=sys.stderr)
|
||||||
|
print(" odysseus_api.py todos list", file=sys.stderr)
|
||||||
|
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
||||||
|
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
||||||
|
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
||||||
|
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> tuple[str, str] | None:
|
||||||
|
base_url = os.environ.get("ODYSSEUS_URL", "").strip().rstrip("/")
|
||||||
|
token = os.environ.get("ODYSSEUS_API_TOKEN", "").strip()
|
||||||
|
missing = []
|
||||||
|
if not base_url:
|
||||||
|
missing.append("ODYSSEUS_URL")
|
||||||
|
if not token:
|
||||||
|
missing.append("ODYSSEUS_API_TOKEN")
|
||||||
|
if missing:
|
||||||
|
print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
return base_url, token
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
return _usage()
|
||||||
|
|
||||||
|
command = sys.argv[1].lower()
|
||||||
|
if command == "capabilities":
|
||||||
|
method = "GET"
|
||||||
|
path = "/api/codex/capabilities"
|
||||||
|
body = None
|
||||||
|
elif command == "todos":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
return _usage()
|
||||||
|
action = sys.argv[2].lower()
|
||||||
|
path = "/api/codex/todos"
|
||||||
|
if action == "list":
|
||||||
|
method = "GET"
|
||||||
|
body = None
|
||||||
|
elif action == "add" and len(sys.argv) >= 4:
|
||||||
|
method = "POST"
|
||||||
|
body = json.dumps({"action": "add", "title": " ".join(sys.argv[3:])})
|
||||||
|
else:
|
||||||
|
return _usage()
|
||||||
|
elif command == "emails":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
return _usage()
|
||||||
|
action = sys.argv[2].lower()
|
||||||
|
if action == "list":
|
||||||
|
method = "GET"
|
||||||
|
limit = sys.argv[3] if len(sys.argv) >= 4 else "10"
|
||||||
|
path = f"/api/codex/emails?folder=INBOX&limit={limit}&offset=0&filter=all"
|
||||||
|
body = None
|
||||||
|
elif action == "read" and len(sys.argv) >= 4:
|
||||||
|
method = "GET"
|
||||||
|
path = f"/api/codex/emails/{sys.argv[3]}"
|
||||||
|
body = None
|
||||||
|
else:
|
||||||
|
return _usage()
|
||||||
|
else:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
return _usage()
|
||||||
|
method = sys.argv[1].upper()
|
||||||
|
path = sys.argv[2]
|
||||||
|
body = sys.argv[3] if len(sys.argv) > 3 else None
|
||||||
|
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
if not path.startswith("/api/codex/"):
|
||||||
|
print("refusing non-/api/codex path; use scoped Odysseus integration endpoints only", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
config = _config()
|
||||||
|
if config is None:
|
||||||
|
return 2
|
||||||
|
base_url, token = config
|
||||||
|
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
}
|
||||||
|
if body is not None:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
print(f"invalid json body: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
data = json.dumps(parsed).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
req = urllib.request.Request(base_url + path, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
print(resp.read().decode("utf-8"))
|
||||||
|
return 0
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
text = exc.read().decode("utf-8", errors="replace")
|
||||||
|
print(text or f"HTTP {exc.code}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"request failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
64
integrations/codex/skills/odysseus/SKILL.md
Normal file
64
integrations/codex/skills/odysseus/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: odysseus
|
||||||
|
description: Use when the user asks Codex to read or write Odysseus data from a terminal Codex session through the scoped Codex Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Odysseus
|
||||||
|
|
||||||
|
Use this skill when a user asks to interact with Odysseus from Codex.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Expect these environment variables:
|
||||||
|
|
||||||
|
- `ODYSSEUS_URL`: Base URL for the user's Odysseus instance, for example `http://127.0.0.1:7000`.
|
||||||
|
- `ODYSSEUS_API_TOKEN`: Scoped API token created in Odysseus Settings > Integrations > Add Integration > Codex Agent.
|
||||||
|
|
||||||
|
If either value is missing, do not guess credentials. Tell the user to create a Codex Agent token in Odysseus Settings and expose both values to the terminal session.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
- All Odysseus data access MUST go through the scoped HTTP API under `/api/codex/*`.
|
||||||
|
- Check `/api/codex/capabilities` before using a tool surface.
|
||||||
|
- Treat `403` as an intentional Settings restriction. Do not work around it.
|
||||||
|
- Do not use SSH, Docker, direct Python imports, SQLite queries, MCP internals, browser cookies, or local files to read/write Odysseus user data.
|
||||||
|
- Do not call helpers like `do_manage_notes`, email MCP internals, or database sessions directly for user data, even if shell access exists.
|
||||||
|
- Never send email directly unless the user explicitly asks to send and the token has a send-capable scope.
|
||||||
|
- Keep actions scoped to the token owner.
|
||||||
|
|
||||||
|
## Todos
|
||||||
|
|
||||||
|
The Codex API supports todos/checklists:
|
||||||
|
|
||||||
|
- `GET /api/codex/todos`
|
||||||
|
- `POST /api/codex/todos`
|
||||||
|
|
||||||
|
Use the bundled helper script when available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 integrations/codex/scripts/odysseus_api.py capabilities
|
||||||
|
python3 integrations/codex/scripts/odysseus_api.py todos list
|
||||||
|
python3 integrations/codex/scripts/odysseus_api.py todos add "Follow up"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported todo actions are `list`, `add`, `update`, `delete`, and `toggle_item`.
|
||||||
|
|
||||||
|
## Email
|
||||||
|
|
||||||
|
The Codex API supports scoped email reads:
|
||||||
|
|
||||||
|
- `GET /api/codex/emails?folder=INBOX&limit=10&offset=0&filter=all`
|
||||||
|
- `GET /api/codex/emails/{uid}?folder=INBOX`
|
||||||
|
|
||||||
|
Use the bundled helper script when available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 integrations/codex/scripts/odysseus_api.py emails list 5
|
||||||
|
python3 integrations/codex/scripts/odysseus_api.py emails read UID
|
||||||
|
```
|
||||||
|
|
||||||
|
If `/api/codex/capabilities` does not show `email.read: true`, do not inspect email. Ask the user to enable Email read in the Codex Agent settings.
|
||||||
|
|
||||||
|
## Forbidden Bypass Pattern
|
||||||
|
|
||||||
|
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Codex Agent tool toggle instead.
|
||||||
@@ -12,6 +12,61 @@ from src.auth_helpers import get_current_user
|
|||||||
|
|
||||||
MAX_NAME_LEN = 100
|
MAX_NAME_LEN = 100
|
||||||
DEFAULT_SCOPES = "chat"
|
DEFAULT_SCOPES = "chat"
|
||||||
|
ALLOWED_SCOPES = {
|
||||||
|
"chat",
|
||||||
|
"todos:read",
|
||||||
|
"todos:write",
|
||||||
|
"documents:read",
|
||||||
|
"documents:write",
|
||||||
|
"email:read",
|
||||||
|
"email:draft",
|
||||||
|
"email:send",
|
||||||
|
"calendar:read",
|
||||||
|
"calendar:write",
|
||||||
|
"memory:read",
|
||||||
|
"memory:write",
|
||||||
|
}
|
||||||
|
TOKEN_PROFILES = {
|
||||||
|
"chat": ["chat"],
|
||||||
|
"codex_todos": ["todos:read", "todos:write"],
|
||||||
|
"codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_scopes(scopes: str | list[str] | None = None, profile: str | None = None) -> list[str]:
|
||||||
|
profile = profile if isinstance(profile, str) else None
|
||||||
|
profile_key = (profile or "").strip()
|
||||||
|
if profile_key:
|
||||||
|
if profile_key not in TOKEN_PROFILES:
|
||||||
|
raise HTTPException(400, "Unknown token profile")
|
||||||
|
requested = list(TOKEN_PROFILES[profile_key])
|
||||||
|
elif isinstance(scopes, list):
|
||||||
|
requested = [str(s).strip() for s in scopes if str(s).strip()]
|
||||||
|
elif isinstance(scopes, str) and scopes:
|
||||||
|
requested = [s.strip() for s in scopes.replace(" ", ",").split(",") if s.strip()]
|
||||||
|
else:
|
||||||
|
requested = [DEFAULT_SCOPES]
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
for scope in requested:
|
||||||
|
if scope not in ALLOWED_SCOPES:
|
||||||
|
raise HTTPException(400, f"Unknown token scope: {scope}")
|
||||||
|
if scope not in normalized:
|
||||||
|
normalized.append(scope)
|
||||||
|
|
||||||
|
def ensure_before(write_scope: str, read_scope: str):
|
||||||
|
if write_scope not in normalized or read_scope in normalized:
|
||||||
|
return
|
||||||
|
idx = normalized.index(write_scope)
|
||||||
|
normalized.insert(idx, read_scope)
|
||||||
|
|
||||||
|
ensure_before("todos:write", "todos:read")
|
||||||
|
ensure_before("documents:write", "documents:read")
|
||||||
|
ensure_before("calendar:write", "calendar:read")
|
||||||
|
ensure_before("memory:write", "memory:read")
|
||||||
|
ensure_before("email:draft", "email:read")
|
||||||
|
|
||||||
|
return normalized or [DEFAULT_SCOPES]
|
||||||
|
|
||||||
|
|
||||||
def setup_api_token_routes() -> APIRouter:
|
def setup_api_token_routes() -> APIRouter:
|
||||||
@@ -45,13 +100,28 @@ def setup_api_token_routes() -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@router.get("/tokens/profiles")
|
||||||
|
def token_profiles(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
return {
|
||||||
|
"profiles": TOKEN_PROFILES,
|
||||||
|
"allowed_scopes": sorted(ALLOWED_SCOPES),
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/tokens")
|
@router.post("/tokens")
|
||||||
def create_token(request: Request, name: str = Form("")):
|
def create_token(
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(""),
|
||||||
|
scopes: str = Form(None),
|
||||||
|
profile: str = Form(None),
|
||||||
|
):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
name = name.strip()[:MAX_NAME_LEN]
|
name = name.strip()[:MAX_NAME_LEN]
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(400, "Token name is required")
|
raise HTTPException(400, "Token name is required")
|
||||||
owner = get_current_user(request)
|
owner = get_current_user(request)
|
||||||
|
scope_list = _normalize_scopes(scopes, profile)
|
||||||
|
scopes_value = ",".join(scope_list)
|
||||||
|
|
||||||
raw_token = "ody_" + secrets.token_urlsafe(32)
|
raw_token = "ody_" + secrets.token_urlsafe(32)
|
||||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||||
@@ -64,7 +134,7 @@ def setup_api_token_routes() -> APIRouter:
|
|||||||
name=name,
|
name=name,
|
||||||
token_hash=token_hash,
|
token_hash=token_hash,
|
||||||
token_prefix=raw_token[:8],
|
token_prefix=raw_token[:8],
|
||||||
scopes=DEFAULT_SCOPES,
|
scopes=scopes_value,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
))
|
))
|
||||||
_invalidate_cache(request)
|
_invalidate_cache(request)
|
||||||
@@ -75,9 +145,36 @@ def setup_api_token_routes() -> APIRouter:
|
|||||||
"owner": owner,
|
"owner": owner,
|
||||||
"token": raw_token,
|
"token": raw_token,
|
||||||
"token_prefix": raw_token[:8],
|
"token_prefix": raw_token[:8],
|
||||||
"scopes": DEFAULT_SCOPES.split(","),
|
"scopes": scope_list,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.patch("/tokens/{token_id}")
|
||||||
|
async def update_token(request: Request, token_id: str):
|
||||||
|
require_admin(request)
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
scope_list = _normalize_scopes(payload.get("scopes"))
|
||||||
|
scopes_value = ",".join(scope_list)
|
||||||
|
with get_db_session() as db:
|
||||||
|
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(404, "Token not found")
|
||||||
|
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
||||||
|
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
||||||
|
token.scopes = scopes_value
|
||||||
|
db.add(token)
|
||||||
|
response = {
|
||||||
|
"id": token_id,
|
||||||
|
"name": getattr(token, "name", ""),
|
||||||
|
"owner": getattr(token, "owner", None),
|
||||||
|
"token_prefix": getattr(token, "token_prefix", ""),
|
||||||
|
"scopes": scope_list,
|
||||||
|
}
|
||||||
|
_invalidate_cache(request)
|
||||||
|
return response
|
||||||
|
|
||||||
@router.delete("/tokens/{token_id}")
|
@router.delete("/tokens/{token_id}")
|
||||||
def delete_token(request: Request, token_id: str):
|
def delete_token(request: Request, token_id: str):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
|||||||
169
routes/codex_routes.py
Normal file
169
routes/codex_routes.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""Codex integration routes.
|
||||||
|
|
||||||
|
These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They
|
||||||
|
reuse existing Odysseus helpers and enforce API-token scopes before touching
|
||||||
|
user data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from src.auth_helpers import require_user
|
||||||
|
from src.tool_implementations import do_manage_notes
|
||||||
|
|
||||||
|
|
||||||
|
TODO_READ_SCOPES = {"todos:read", "todos:write"}
|
||||||
|
TODO_WRITE_SCOPES = {"todos:write"}
|
||||||
|
EMAIL_READ_SCOPES = {"email:read", "email:draft", "email:send"}
|
||||||
|
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||||
|
"""Return the data owner if the caller is allowed for this Codex action."""
|
||||||
|
if getattr(request.state, "api_token", False):
|
||||||
|
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||||
|
if not scopes.intersection(allowed):
|
||||||
|
required = " or ".join(sorted(allowed))
|
||||||
|
raise HTTPException(403, f"API token missing required scope: {required}")
|
||||||
|
owner = getattr(request.state, "api_token_owner", None)
|
||||||
|
if not owner:
|
||||||
|
raise HTTPException(403, "API token has no owner")
|
||||||
|
return owner
|
||||||
|
return require_user(request)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
||||||
|
if router is None:
|
||||||
|
return None
|
||||||
|
for route in getattr(router, "routes", []):
|
||||||
|
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def setup_codex_routes(email_router: APIRouter | None = None) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/api/codex", tags=["codex"])
|
||||||
|
email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
|
||||||
|
email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}")
|
||||||
|
|
||||||
|
@router.get("/capabilities")
|
||||||
|
def capabilities(request: Request):
|
||||||
|
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||||
|
has_token = bool(getattr(request.state, "api_token", False))
|
||||||
|
return {
|
||||||
|
"integration": "codex",
|
||||||
|
"token_scopes": sorted(token_scopes),
|
||||||
|
"tools": {
|
||||||
|
"todos": {
|
||||||
|
"read": bool(token_scopes.intersection(TODO_READ_SCOPES)) if has_token else True,
|
||||||
|
"write": bool(token_scopes.intersection(TODO_WRITE_SCOPES)) if has_token else True,
|
||||||
|
"actions": ["list", "add", "update", "delete", "toggle_item"],
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"read": bool(token_scopes.intersection(EMAIL_READ_SCOPES)) if has_token else True,
|
||||||
|
"draft": "email:draft" in token_scopes if has_token else True,
|
||||||
|
"send": "email:send" in token_scopes if has_token else True,
|
||||||
|
"actions": ["list", "read"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"safety": {
|
||||||
|
"email_send_requires_confirmation": True,
|
||||||
|
"destructive_actions_should_confirm": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/plugin.zip")
|
||||||
|
def plugin_zip(request: Request):
|
||||||
|
require_user(request)
|
||||||
|
root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
|
||||||
|
if not root.exists():
|
||||||
|
raise HTTPException(404, "Codex plugin bundle not found")
|
||||||
|
buf = BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if path.is_dir() or "__pycache__" in path.parts or path.suffix == ".pyc":
|
||||||
|
continue
|
||||||
|
zf.write(path, Path("odysseus") / path.relative_to(root))
|
||||||
|
buf.seek(0)
|
||||||
|
headers = {"Content-Disposition": 'attachment; filename="odysseus-codex-plugin.zip"'}
|
||||||
|
return StreamingResponse(buf, media_type="application/zip", headers=headers)
|
||||||
|
|
||||||
|
@router.get("/todos")
|
||||||
|
async def list_todos(request: Request, archived: bool = False, label: str | None = None):
|
||||||
|
owner = _scope_owner(request, TODO_READ_SCOPES)
|
||||||
|
args: dict[str, Any] = {"action": "list", "archived": archived}
|
||||||
|
if label:
|
||||||
|
args["label"] = label
|
||||||
|
return await do_manage_notes(json.dumps(args), owner=owner)
|
||||||
|
|
||||||
|
@router.post("/todos")
|
||||||
|
async def manage_todos(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||||
|
action = str(body.get("action") or "add").replace("-", "_").strip().lower()
|
||||||
|
allowed = TODO_WRITE_SCOPES if action in WRITE_ACTIONS else TODO_READ_SCOPES
|
||||||
|
owner = _scope_owner(request, allowed)
|
||||||
|
args = dict(body)
|
||||||
|
args["action"] = action
|
||||||
|
return await do_manage_notes(json.dumps(args), owner=owner)
|
||||||
|
|
||||||
|
@router.get("/emails")
|
||||||
|
async def list_emails(
|
||||||
|
request: Request,
|
||||||
|
folder: str = "INBOX",
|
||||||
|
limit: int = 10,
|
||||||
|
offset: int = 0,
|
||||||
|
filter: str = "all",
|
||||||
|
from_addr: str | None = None,
|
||||||
|
account_id: str | None = None,
|
||||||
|
has_attachments: int = 0,
|
||||||
|
):
|
||||||
|
owner = _scope_owner(request, EMAIL_READ_SCOPES)
|
||||||
|
if email_list_endpoint is None:
|
||||||
|
raise HTTPException(503, "Email integration is not available")
|
||||||
|
limit = max(1, min(int(limit or 10), 50))
|
||||||
|
offset = max(0, int(offset or 0))
|
||||||
|
if account_id:
|
||||||
|
from routes.email_helpers import _assert_owns_account
|
||||||
|
|
||||||
|
_assert_owns_account(account_id, owner)
|
||||||
|
return await email_list_endpoint(
|
||||||
|
folder=folder,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
filter=filter,
|
||||||
|
from_addr=from_addr,
|
||||||
|
account_id=account_id,
|
||||||
|
has_attachments=has_attachments,
|
||||||
|
cache_bust=None,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/emails/{uid}")
|
||||||
|
async def read_email(
|
||||||
|
request: Request,
|
||||||
|
uid: str,
|
||||||
|
folder: str = "INBOX",
|
||||||
|
account_id: str | None = None,
|
||||||
|
mark_seen: bool = False,
|
||||||
|
):
|
||||||
|
owner = _scope_owner(request, EMAIL_READ_SCOPES)
|
||||||
|
if email_read_endpoint is None:
|
||||||
|
raise HTTPException(503, "Email integration is not available")
|
||||||
|
if account_id:
|
||||||
|
from routes.email_helpers import _assert_owns_account
|
||||||
|
|
||||||
|
_assert_owns_account(account_id, owner)
|
||||||
|
return await email_read_endpoint(
|
||||||
|
uid=uid,
|
||||||
|
folder=folder,
|
||||||
|
account_id=account_id,
|
||||||
|
mark_seen=mark_seen,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -3093,6 +3093,7 @@ const INTG_TYPES = {
|
|||||||
carddav: { label: 'CardDAV', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' },
|
carddav: { label: 'CardDAV', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' },
|
||||||
email: { label: 'Email', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>' },
|
email: { label: 'Email', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>' },
|
||||||
mcp: { label: 'MCP', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>' },
|
mcp: { label: 'MCP', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>' },
|
||||||
|
codex: { label: 'Codex', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m8 9 3 3-3 3"/><path d="M13 15h3"/></svg>' },
|
||||||
vault: { label: 'Vault', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
|
vault: { label: 'Vault', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3113,7 +3114,7 @@ async function initUnifiedIntegrations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAll() {
|
async function fetchAll() {
|
||||||
const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes] = await Promise.all([
|
const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes, tokenRes] = await Promise.all([
|
||||||
fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })),
|
fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })),
|
||||||
fetch('/api/calendar/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
fetch('/api/calendar/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
||||||
fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
||||||
@@ -3121,6 +3122,7 @@ async function initUnifiedIntegrations() {
|
|||||||
fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
|
fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
|
||||||
fetch('/api/mcp/servers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
|
fetch('/api/mcp/servers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
|
||||||
fetch('/api/vault/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
fetch('/api/vault/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
||||||
|
fetch('/api/tokens', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
|
||||||
]);
|
]);
|
||||||
const items = [];
|
const items = [];
|
||||||
// API integrations
|
// API integrations
|
||||||
@@ -3165,6 +3167,13 @@ async function initUnifiedIntegrations() {
|
|||||||
const statusText = srv.needs_oauth ? 'needs auth' : srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : srv.status === 'error' ? 'error' : 'disconnected';
|
const statusText = srv.needs_oauth ? 'needs auth' : srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : srv.status === 'error' ? 'error' : 'disconnected';
|
||||||
items.push({ type: 'mcp', id: srv.id || srv.name, name: srv.name || 'MCP Server', detail: statusText, enabled: srv.is_enabled !== false, data: srv });
|
items.push({ type: 'mcp', id: srv.id || srv.name, name: srv.name || 'MCP Server', detail: statusText, enabled: srv.is_enabled !== false, data: srv });
|
||||||
}
|
}
|
||||||
|
for (const tok of (Array.isArray(tokenRes) ? tokenRes : [])) {
|
||||||
|
const scopes = tok.scopes || [];
|
||||||
|
const isCodex = (tok.name || '').toLowerCase().includes('codex') || scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'));
|
||||||
|
if (!isCodex) continue;
|
||||||
|
const detail = `${tok.token_prefix || 'token'}... - ${scopes.join(', ') || 'chat'}`;
|
||||||
|
items.push({ type: 'codex', id: tok.id, name: tok.name || 'Codex Agent', detail, enabled: true, data: tok });
|
||||||
|
}
|
||||||
// Vaultwarden removed as an integration option.
|
// Vaultwarden removed as an integration option.
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -3236,6 +3245,7 @@ async function initUnifiedIntegrations() {
|
|||||||
}
|
}
|
||||||
else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
else if (type === 'codex') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' });
|
else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
formEl.style.display = 'none';
|
formEl.style.display = 'none';
|
||||||
@@ -3252,6 +3262,7 @@ async function initUnifiedIntegrations() {
|
|||||||
else if (type === 'contacts' || type === 'carddav') showCardDavForm();
|
else if (type === 'contacts' || type === 'carddav') showCardDavForm();
|
||||||
else if (type === 'email') showEmailForm(editId);
|
else if (type === 'email') showEmailForm(editId);
|
||||||
else if (type === 'mcp') showMcpForm(editId);
|
else if (type === 'mcp') showMcpForm(editId);
|
||||||
|
else if (type === 'codex') showCodexForm(editId);
|
||||||
else if (type === 'vault') showVaultForm();
|
else if (type === 'vault') showVaultForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4302,6 +4313,245 @@ async function initUnifiedIntegrations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showCodexForm(editId) {
|
||||||
|
let tokens = [];
|
||||||
|
try {
|
||||||
|
const tokRes = await fetch('/api/tokens', { credentials: 'same-origin' });
|
||||||
|
if (tokRes.ok) tokens = await tokRes.json();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const toolScopes = [
|
||||||
|
{ key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
|
||||||
|
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
|
||||||
|
{ key: 'documents:read', label: 'Documents', detail: 'Read documents when a Codex document API is enabled' },
|
||||||
|
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
|
||||||
|
{ key: 'email:read', label: 'Email', detail: 'Read email when a Codex email API is enabled' },
|
||||||
|
{ key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts without sending' },
|
||||||
|
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
|
||||||
|
{ key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events when enabled' },
|
||||||
|
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
|
||||||
|
{ key: 'memory:read', label: 'Memory', detail: 'Read memory when enabled' },
|
||||||
|
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
|
||||||
|
];
|
||||||
|
const codexTokens = (Array.isArray(tokens) ? tokens : []).filter(tok => {
|
||||||
|
const scopes = tok.scopes || [];
|
||||||
|
return (tok.name || '').toLowerCase().includes('codex') || scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'));
|
||||||
|
});
|
||||||
|
const current = codexTokens.find(t => String(t.id) === String(editId));
|
||||||
|
const scopeToggles = (t) => {
|
||||||
|
const scopes = new Set(t.scopes || []);
|
||||||
|
return toolScopes.map(scope => `
|
||||||
|
<label class="settings-row" style="align-items:flex-start;gap:10px;">
|
||||||
|
<span class="settings-label" style="padding-top:2px;">${esc(scope.label)}</span>
|
||||||
|
<span style="display:flex;align-items:flex-start;gap:8px;flex:1;min-width:0;">
|
||||||
|
<label class="admin-switch" style="margin-left:0;flex-shrink:0;"><input type="checkbox" class="uf-codex-scope" data-token-id="${esc(t.id)}" data-scope="${esc(scope.key)}" ${scopes.has(scope.key) ? 'checked' : ''}><span class="admin-slider"></span></label>
|
||||||
|
<span style="font-size:11px;line-height:1.35;opacity:0.62;">${esc(scope.detail)}</span>
|
||||||
|
</span>
|
||||||
|
</label>`).join('');
|
||||||
|
};
|
||||||
|
const tokenRows = codexTokens.length ? codexTokens.map(t => `
|
||||||
|
<div class="uf-codex-token" data-token-id="${esc(t.id)}" style="border:1px solid var(--border);border-radius:6px;padding:9px 10px;margin-top:8px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:12px;font-weight:600;">${esc(t.name || 'Codex Agent')}</div>
|
||||||
|
<div style="font-size:10px;opacity:0.52;">${esc(t.token_prefix || 'token')}...${t.last_used_at ? ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}` : ' · Never used'}</div>
|
||||||
|
</div>
|
||||||
|
<button class="admin-btn-delete uf-codex-revoke" data-token-id="${esc(t.id)}">Revoke</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;font-weight:600;opacity:0.62;margin-bottom:4px;">Tool access</div>
|
||||||
|
${scopeToggles(t)}
|
||||||
|
<div class="uf-codex-scope-msg" data-token-id="${esc(t.id)}" style="font-size:11px;min-height:14px;"></div>
|
||||||
|
</div>`).join('') : '<div style="opacity:0.45;font-size:11px;padding:8px 0;">No Codex tokens yet.</div>';
|
||||||
|
const origin = window.location.origin || '';
|
||||||
|
const setupForToken = (token) => `export ODYSSEUS_URL=${origin}
|
||||||
|
export ODYSSEUS_API_TOKEN='${token}'
|
||||||
|
mkdir -p ~/plugins
|
||||||
|
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
|
||||||
|
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
p = Path.home() / ".agents" / "plugins" / "marketplace.json"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if p.exists():
|
||||||
|
data = json.loads(p.read_text())
|
||||||
|
else:
|
||||||
|
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
|
||||||
|
|
||||||
|
data.setdefault("name", "personal")
|
||||||
|
data.setdefault("interface", {}).setdefault("displayName", "Personal")
|
||||||
|
plugins = data.setdefault("plugins", [])
|
||||||
|
entry = {
|
||||||
|
"name": "odysseus",
|
||||||
|
"source": {"source": "local", "path": "./plugins/odysseus"},
|
||||||
|
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
|
||||||
|
"category": "Productivity",
|
||||||
|
}
|
||||||
|
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
|
||||||
|
p.write_text(json.dumps(data, indent=2) + "\\n")
|
||||||
|
PY
|
||||||
|
codex plugin add odysseus@personal
|
||||||
|
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`;
|
||||||
|
const setupPlaceholder = `Add an agent to generate a one-time token and copy the full setup commands.`;
|
||||||
|
|
||||||
|
formEl.innerHTML = `
|
||||||
|
<div class="admin-card" style="margin-top:8px">
|
||||||
|
<h2 style="font-size:13px">${current ? 'Codex Agent' : 'Add Codex Agent'}</h2>
|
||||||
|
<div class="settings-col">
|
||||||
|
<div style="font-size:11px;line-height:1.45;opacity:0.68;margin-bottom:4px;">Create a Codex agent token, then toggle exactly which Odysseus tools it can use.</div>
|
||||||
|
<div style="font-size:11px;line-height:1.45;padding:8px 10px;border:1px solid var(--border);border-left:3px solid var(--accent, var(--red));border-radius:6px;background:rgba(0,0,0,0.06);">
|
||||||
|
<div style="font-weight:600;margin-bottom:4px;">Codex setup</div>
|
||||||
|
<div style="opacity:0.68;margin-bottom:6px;">Odysseus ships a Codex plugin in <code>integrations/codex</code>. <a href="/api/codex/plugin.zip" style="color:var(--accent,var(--red));">Download plugin bundle</a>, then set this instance URL and the token shown after adding an agent in the terminal where Codex runs.</div>
|
||||||
|
<pre style="margin:0;white-space:pre-wrap;word-break:break-word;font-size:10px;line-height:1.45;"><code id="uf-codex-setup-code">${esc(setupPlaceholder)}</code></pre>
|
||||||
|
<button class="admin-btn-sm" id="uf-codex-copy-setup" style="margin-top:6px;opacity:0.55;">Copy setup</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-codex-name" class="settings-input" value="${esc(current?.name || 'Codex Agent')}" placeholder="Codex Agent"></div>
|
||||||
|
<div class="settings-row" style="margin-top:4px">
|
||||||
|
<button class="admin-btn-sm" id="uf-codex-create">Add Agent</button>
|
||||||
|
<button class="admin-btn-sm" id="uf-codex-cancel" style="opacity:0.7">Cancel</button>
|
||||||
|
<span id="uf-codex-msg" style="font-size:11px"></span>
|
||||||
|
</div>
|
||||||
|
<div id="uf-codex-reveal" style="display:none;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:rgba(0,0,0,0.08);">
|
||||||
|
<div style="font-size:11px;opacity:0.65;margin-bottom:4px;">Copy this token now. It will not be shown again. New agents start with chat only; use Configure access before testing todos or email.</div>
|
||||||
|
<code id="uf-codex-token" style="display:block;word-break:break-all;font-size:11px;"></code>
|
||||||
|
<button class="admin-btn-sm" id="uf-codex-copy-token" style="margin-top:6px;">Copy token</button>
|
||||||
|
<button class="admin-btn-sm" id="uf-codex-configure" style="display:none;margin-top:6px;">Configure access</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;font-weight:600;opacity:0.62;margin-top:8px;">Agents</div>
|
||||||
|
<div id="uf-codex-token-list">${tokenRows}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
el('uf-codex-cancel')?.addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||||
|
el('uf-codex-create')?.addEventListener('click', async () => {
|
||||||
|
const msg = el('uf-codex-msg');
|
||||||
|
const name = el('uf-codex-name').value.trim();
|
||||||
|
if (!name) { msg.textContent = 'Name required'; msg.style.color = 'var(--red)'; return; }
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('name', name);
|
||||||
|
fd.append('scopes', 'chat');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/tokens', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.detail || 'Failed');
|
||||||
|
el('uf-codex-token').textContent = d.token || '';
|
||||||
|
el('uf-codex-reveal').style.display = '';
|
||||||
|
const configureBtn = el('uf-codex-configure');
|
||||||
|
if (configureBtn) {
|
||||||
|
configureBtn.dataset.tokenId = d.id || '';
|
||||||
|
configureBtn.style.display = '';
|
||||||
|
}
|
||||||
|
const setupBtn = el('uf-codex-copy-setup');
|
||||||
|
if (setupBtn) {
|
||||||
|
setupBtn.dataset.token = d.token || '';
|
||||||
|
setupBtn.style.opacity = '';
|
||||||
|
}
|
||||||
|
const setupCode = el('uf-codex-setup-code');
|
||||||
|
if (setupCode) setupCode.textContent = setupForToken(d.token || '');
|
||||||
|
msg.textContent = 'Created. Configure access before testing tools.';
|
||||||
|
msg.style.color = 'var(--green, #50fa7b)';
|
||||||
|
await renderList();
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = err?.message || 'Failed';
|
||||||
|
msg.style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const _copyCodexToken = async (text) => {
|
||||||
|
const value = String(text || '');
|
||||||
|
if (!value) return false;
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return true;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = value;
|
||||||
|
ta.setAttribute('readonly', 'readonly');
|
||||||
|
ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
ta.setSelectionRange(0, value.length);
|
||||||
|
let ok = false;
|
||||||
|
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
|
||||||
|
ta.remove();
|
||||||
|
return ok;
|
||||||
|
};
|
||||||
|
const _selectTextFallback = (text, containerId) => {
|
||||||
|
const code = document.createElement('pre');
|
||||||
|
code.textContent = text;
|
||||||
|
code.style.cssText = 'white-space:pre-wrap;word-break:break-word;font-size:10px;margin:6px 0 0;';
|
||||||
|
el(containerId)?.appendChild(code);
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(code);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
};
|
||||||
|
el('uf-codex-copy-setup')?.addEventListener('click', async () => {
|
||||||
|
const token = el('uf-codex-copy-setup')?.dataset.token || '';
|
||||||
|
const btn = el('uf-codex-copy-setup');
|
||||||
|
if (!token) {
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = 'Add agent first';
|
||||||
|
setTimeout(() => { const latest = el('uf-codex-copy-setup'); if (latest) latest.textContent = 'Copy setup'; }, 1600);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setup = setupForToken(token);
|
||||||
|
const ok = await _copyCodexToken(setup);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.textContent = ok ? 'Copied setup' : 'Select setup';
|
||||||
|
if (!ok) _selectTextFallback(setup, 'uf-codex-reveal');
|
||||||
|
setTimeout(() => { const latest = el('uf-codex-copy-setup'); if (latest) latest.textContent = 'Copy setup'; }, 1600);
|
||||||
|
});
|
||||||
|
el('uf-codex-copy-token')?.addEventListener('click', async () => {
|
||||||
|
const token = el('uf-codex-token')?.textContent || '';
|
||||||
|
const ok = await _copyCodexToken(token);
|
||||||
|
const btn = el('uf-codex-copy-token');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.textContent = ok ? 'Copied token' : 'Select token';
|
||||||
|
if (!ok) _selectTextFallback(token, 'uf-codex-reveal');
|
||||||
|
setTimeout(() => { const latest = el('uf-codex-copy-token'); if (latest) latest.textContent = 'Copy token'; }, 1600);
|
||||||
|
});
|
||||||
|
el('uf-codex-configure')?.addEventListener('click', async () => {
|
||||||
|
await showCodexForm(el('uf-codex-configure')?.dataset.tokenId || null);
|
||||||
|
});
|
||||||
|
formEl.querySelectorAll('.uf-codex-revoke').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!await window.styledConfirm('Revoke this Codex token? Terminal agents using it will lose access.', { confirmText: 'Revoke', danger: true })) return;
|
||||||
|
await fetch(`/api/tokens/${btn.dataset.tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
await showCodexForm(null);
|
||||||
|
await renderList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
formEl.querySelectorAll('.uf-codex-scope').forEach(cb => {
|
||||||
|
cb.addEventListener('change', async () => {
|
||||||
|
const tokenId = cb.dataset.tokenId;
|
||||||
|
const panel = formEl.querySelector(`.uf-codex-token[data-token-id="${CSS.escape(tokenId)}"]`);
|
||||||
|
const msg = formEl.querySelector(`.uf-codex-scope-msg[data-token-id="${CSS.escape(tokenId)}"]`);
|
||||||
|
const scopes = Array.from(panel.querySelectorAll('.uf-codex-scope:checked')).map(input => input.dataset.scope);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/tokens/${tokenId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ scopes }),
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(d.detail || 'Failed');
|
||||||
|
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
|
||||||
|
await renderList();
|
||||||
|
} catch (err) {
|
||||||
|
cb.checked = !cb.checked;
|
||||||
|
if (msg) { msg.textContent = err?.message || 'Failed'; msg.style.color = 'var(--red)'; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Add button with type picker ──
|
// ── Add button with type picker ──
|
||||||
if (addBtn) {
|
if (addBtn) {
|
||||||
addBtn.addEventListener('click', () => {
|
addBtn.addEventListener('click', () => {
|
||||||
@@ -4319,6 +4569,7 @@ async function initUnifiedIntegrations() {
|
|||||||
<option value="carddav">Contacts (CardDAV)</option>
|
<option value="carddav">Contacts (CardDAV)</option>
|
||||||
<option value="email">Email (IMAP/SMTP)</option>
|
<option value="email">Email (IMAP/SMTP)</option>
|
||||||
<option value="mcp">MCP Tool Server</option>
|
<option value="mcp">MCP Tool Server</option>
|
||||||
|
<option value="codex">Codex Agent</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user