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