Files
odysseus/integrations/codex/scripts/odysseus_api.py

219 lines
8.1 KiB
Python
Raw Normal View History

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.
2026-06-03 22:38:05 +09:00
#!/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 emails draft-doc JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr)
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner: * Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file. * `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400. * `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt). Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll. `serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes. Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop. Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles. `adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd. Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
print(" odysseus_api.py cookbook servers", file=sys.stderr)
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
print(" odysseus_api.py cookbook presets", file=sys.stderr)
print(" odysseus_api.py cookbook output SESSION_ID [tail]", file=sys.stderr)
print(" odysseus_api.py cookbook serve REPO_ID 'CMD' [REMOTE_HOST]", file=sys.stderr)
print(" odysseus_api.py cookbook preset NAME", file=sys.stderr)
print(" odysseus_api.py cookbook adopt SESSION_ID MODEL [HOST] [PORT]", file=sys.stderr)
print(" odysseus_api.py cookbook stop SESSION_ID", file=sys.stderr)
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.
2026-06-03 22:38:05 +09:00
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
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/emails/draft-document"
body = " ".join(sys.argv[3:])
else:
return _usage()
elif command in ("documents", "docs"):
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 "50"
path = f"/api/codex/documents?limit={limit}"
body = None
elif action == "read" and len(sys.argv) >= 4:
method = "GET"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
elif action == "create" and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/documents"
body = " ".join(sys.argv[3:])
elif action == "delete" and len(sys.argv) >= 4:
method = "DELETE"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
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.
2026-06-03 22:38:05 +09:00
else:
return _usage()
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner: * Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file. * `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400. * `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt). Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll. `serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes. Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop. Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles. `adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd. Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
elif command == "cookbook":
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "tasks":
method = "GET"
path = "/api/codex/cookbook/tasks"
body = None
elif action == "servers":
method = "GET"
path = "/api/codex/cookbook/servers"
body = None
elif action == "output" and len(sys.argv) >= 4:
method = "GET"
sid = sys.argv[3]
tail = sys.argv[4] if len(sys.argv) >= 5 else "400"
path = f"/api/codex/cookbook/output/{sid}?tail={tail}"
body = None
elif action == "cached":
method = "GET"
if len(sys.argv) >= 4:
from urllib.parse import quote
path = f"/api/codex/cookbook/cached?host={quote(sys.argv[3])}"
else:
path = "/api/codex/cookbook/cached"
body = None
elif action == "presets":
method = "GET"
path = "/api/codex/cookbook/presets"
body = None
elif action == "preset" and len(sys.argv) >= 4:
from urllib.parse import quote
method = "POST"
path = f"/api/codex/cookbook/preset/{quote(sys.argv[3])}"
body = None
elif action == "adopt" and len(sys.argv) >= 5:
method = "POST"
path = "/api/codex/cookbook/adopt"
payload = {"tmux_session": sys.argv[3], "model": sys.argv[4]}
if len(sys.argv) >= 6: payload["host"] = sys.argv[5]
if len(sys.argv) >= 7: payload["port"] = int(sys.argv[6])
body = json.dumps(payload)
elif action == "serve" and len(sys.argv) >= 5:
method = "POST"
path = "/api/codex/cookbook/serve"
payload = {"repo_id": sys.argv[3], "cmd": sys.argv[4]}
if len(sys.argv) >= 6:
payload["remote_host"] = sys.argv[5]
body = json.dumps(payload)
elif action == "stop" and len(sys.argv) >= 4:
method = "POST"
path = f"/api/codex/cookbook/stop/{sys.argv[3]}"
body = None
else:
return _usage()
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.
2026-06-03 22:38:05 +09:00
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())