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:
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.
|
||||
Reference in New Issue
Block a user