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).
783 lines
36 KiB
Python
783 lines
36 KiB
Python
"""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 asyncio
|
|
import json
|
|
import zipfile
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from src.auth_helpers import require_user
|
|
from src.tool_implementations import do_manage_notes
|
|
|
|
|
|
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
|
|
COOKBOOK_LAUNCH_SCOPES = {"cookbook:launch"}
|
|
TODO_READ_SCOPES = {"todos:read", "todos:write"}
|
|
TODO_WRITE_SCOPES = {"todos:write"}
|
|
EMAIL_READ_SCOPES = {"email:read", "email:draft", "email:send"}
|
|
EMAIL_DRAFT_SCOPES = {"email:draft", "email:send"}
|
|
EMAIL_SEND_SCOPES = {"email:send"}
|
|
MEMORY_READ_SCOPES = {"memory:read", "memory:write"}
|
|
MEMORY_WRITE_SCOPES = {"memory:write"}
|
|
CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"}
|
|
CALENDAR_WRITE_SCOPES = {"calendar:write"}
|
|
DOCS_READ_SCOPES = {"documents:read", "documents:write"}
|
|
DOCS_WRITE_SCOPES = {"documents:write"}
|
|
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
|
|
|
|
|
|
async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
|
"""Run an existing route handler with request.state.current_user temporarily
|
|
set to ``owner`` so its internal get_current_user/require_user calls see
|
|
the scope-gated owner (not the "api" pseudo-user the bearer middleware sets).
|
|
Restores the original value when done. Works for sync and async handlers."""
|
|
orig = getattr(request.state, "current_user", None)
|
|
request.state.current_user = owner
|
|
try:
|
|
result = fn(*args, **kwargs)
|
|
if asyncio.iscoroutine(result):
|
|
result = await result
|
|
return result
|
|
finally:
|
|
request.state.current_user = orig
|
|
|
|
|
|
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,
|
|
memory_router: APIRouter | None = None,
|
|
calendar_router: APIRouter | None = None,
|
|
document_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}")
|
|
email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send")
|
|
email_draft_endpoint = _find_endpoint(email_router, "POST", "/api/email/draft")
|
|
memory_list_endpoint = _find_endpoint(memory_router, "GET", "/api/memory")
|
|
memory_add_endpoint = _find_endpoint(memory_router, "POST", "/api/memory/add")
|
|
calendar_list_events = _find_endpoint(calendar_router, "GET", "/api/calendar/events")
|
|
calendar_create_event = _find_endpoint(calendar_router, "POST", "/api/calendar/events")
|
|
documents_library_endpoint = _find_endpoint(document_router, "GET", "/api/documents/library")
|
|
documents_get_endpoint = _find_endpoint(document_router, "GET", "/api/document/{doc_id}")
|
|
documents_create_endpoint = _find_endpoint(document_router, "POST", "/api/document")
|
|
|
|
@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))
|
|
def scoped(allowed):
|
|
return bool(token_scopes.intersection(allowed)) if has_token else True
|
|
return {
|
|
"integration": "codex",
|
|
"token_scopes": sorted(token_scopes),
|
|
"tools": {
|
|
"todos": {
|
|
"read": scoped(TODO_READ_SCOPES),
|
|
"write": scoped(TODO_WRITE_SCOPES),
|
|
"actions": ["list", "add", "update", "delete", "toggle_item"],
|
|
},
|
|
"email": {
|
|
"read": scoped(EMAIL_READ_SCOPES),
|
|
"draft": scoped(EMAIL_DRAFT_SCOPES),
|
|
"send": scoped(EMAIL_SEND_SCOPES),
|
|
"actions": ["list", "read", "draft", "send"],
|
|
},
|
|
"memory": {
|
|
"read": scoped(MEMORY_READ_SCOPES),
|
|
"write": scoped(MEMORY_WRITE_SCOPES),
|
|
"actions": ["list", "add", "delete"],
|
|
"available": memory_list_endpoint is not None,
|
|
},
|
|
"calendar": {
|
|
"read": scoped(CALENDAR_READ_SCOPES),
|
|
"write": scoped(CALENDAR_WRITE_SCOPES),
|
|
"actions": ["list_events", "create_event", "delete_event"],
|
|
"available": calendar_list_events is not None,
|
|
},
|
|
"documents": {
|
|
"read": scoped(DOCS_READ_SCOPES),
|
|
"write": scoped(DOCS_WRITE_SCOPES),
|
|
"actions": ["library", "read", "create", "delete"],
|
|
"available": documents_library_endpoint is not None,
|
|
},
|
|
"cookbook": {
|
|
"read": scoped(COOKBOOK_READ_SCOPES),
|
|
"launch": scoped(COOKBOOK_LAUNCH_SCOPES),
|
|
"actions": ["tasks", "servers", "output", "serve", "stop"],
|
|
},
|
|
},
|
|
"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,
|
|
)
|
|
|
|
# ── Email draft + send ────────────────────────────────────────────────
|
|
# Both handlers in routes/email_routes.py already accept `owner=` via
|
|
# FastAPI Depends, so we call them directly without patching state.
|
|
|
|
@router.post("/emails/draft")
|
|
async def codex_email_draft(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
|
owner = _scope_owner(request, EMAIL_DRAFT_SCOPES)
|
|
if email_draft_endpoint is None:
|
|
raise HTTPException(503, "Email integration is not available")
|
|
from routes.email_routes import SendEmailRequest
|
|
|
|
try:
|
|
req = SendEmailRequest(**body)
|
|
except Exception as exc:
|
|
raise HTTPException(400, f"Invalid draft payload: {exc}")
|
|
return await email_draft_endpoint(req=req, owner=owner)
|
|
|
|
@router.post("/emails/send")
|
|
async def codex_email_send(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
|
owner = _scope_owner(request, EMAIL_SEND_SCOPES)
|
|
if email_send_endpoint is None:
|
|
raise HTTPException(503, "Email integration is not available")
|
|
from routes.email_routes import SendEmailRequest
|
|
|
|
try:
|
|
req = SendEmailRequest(**body)
|
|
except Exception as exc:
|
|
raise HTTPException(400, f"Invalid send payload: {exc}")
|
|
return await email_send_endpoint(req=req, background_tasks=BackgroundTasks(), owner=owner)
|
|
|
|
# ── Memory ────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/memory")
|
|
async def codex_memory_list(request: Request):
|
|
owner = _scope_owner(request, MEMORY_READ_SCOPES)
|
|
if memory_list_endpoint is None:
|
|
raise HTTPException(503, "Memory integration is not available")
|
|
return await _as_owner(request, owner, memory_list_endpoint, request)
|
|
|
|
@router.post("/memory")
|
|
async def codex_memory_add(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
|
owner = _scope_owner(request, MEMORY_WRITE_SCOPES)
|
|
if memory_add_endpoint is None:
|
|
raise HTTPException(503, "Memory integration is not available")
|
|
from src.request_models import MemoryAddRequest
|
|
|
|
try:
|
|
memory_data = MemoryAddRequest(
|
|
text=str(body.get("text") or "").strip(),
|
|
category=body.get("category", "fact"),
|
|
source=body.get("source", "user"),
|
|
session_id=body.get("session_id"),
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(400, f"Invalid memory payload: {exc}")
|
|
if not memory_data.text:
|
|
raise HTTPException(400, "Empty memory text")
|
|
return await _as_owner(request, owner, memory_add_endpoint, request, memory_data)
|
|
|
|
# ── Calendar ──────────────────────────────────────────────────────────
|
|
|
|
@router.get("/calendar/events")
|
|
async def codex_calendar_list(request: Request, start: str, end: str, calendar: str = ""):
|
|
owner = _scope_owner(request, CALENDAR_READ_SCOPES)
|
|
if calendar_list_events is None:
|
|
raise HTTPException(503, "Calendar integration is not available")
|
|
return await _as_owner(request, owner, calendar_list_events, request, start, end, calendar)
|
|
|
|
@router.post("/calendar/events")
|
|
async def codex_calendar_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
|
owner = _scope_owner(request, CALENDAR_WRITE_SCOPES)
|
|
if calendar_create_event is None:
|
|
raise HTTPException(503, "Calendar integration is not available")
|
|
from routes.calendar_routes import EventCreate
|
|
|
|
try:
|
|
data = EventCreate(**body)
|
|
except Exception as exc:
|
|
raise HTTPException(400, f"Invalid event payload: {exc}")
|
|
return await _as_owner(request, owner, calendar_create_event, request, data)
|
|
|
|
# ── Documents ─────────────────────────────────────────────────────────
|
|
|
|
@router.get("/documents")
|
|
async def codex_documents_library(
|
|
request: Request,
|
|
search: str | None = None,
|
|
language: str | None = None,
|
|
sort: str = "recent",
|
|
offset: int = 0,
|
|
limit: int = 50,
|
|
archived: bool = False,
|
|
):
|
|
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
|
if documents_library_endpoint is None:
|
|
raise HTTPException(503, "Documents integration is not available")
|
|
return await _as_owner(
|
|
request, owner, documents_library_endpoint,
|
|
request, search, language, sort, offset, limit, archived,
|
|
)
|
|
|
|
@router.get("/documents/{doc_id}")
|
|
async def codex_documents_get(request: Request, doc_id: str):
|
|
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
|
if documents_get_endpoint is None:
|
|
raise HTTPException(503, "Documents integration is not available")
|
|
return await _as_owner(request, owner, documents_get_endpoint, request, doc_id)
|
|
|
|
# ── DELETE endpoints so agents can clean up after themselves ──────────
|
|
|
|
memory_delete_endpoint = _find_endpoint(memory_router, "DELETE", "/api/memory/{memory_id}")
|
|
calendar_delete_event = _find_endpoint(calendar_router, "DELETE", "/api/calendar/events/{uid}")
|
|
documents_delete_endpoint = _find_endpoint(document_router, "DELETE", "/api/document/{doc_id}")
|
|
|
|
@router.delete("/memory/{memory_id}")
|
|
async def codex_memory_delete(request: Request, memory_id: str):
|
|
owner = _scope_owner(request, MEMORY_WRITE_SCOPES)
|
|
if memory_delete_endpoint is None:
|
|
raise HTTPException(503, "Memory delete not available")
|
|
return await _as_owner(request, owner, memory_delete_endpoint, request, memory_id)
|
|
|
|
@router.delete("/calendar/events/{uid}")
|
|
async def codex_calendar_delete(request: Request, uid: str):
|
|
owner = _scope_owner(request, CALENDAR_WRITE_SCOPES)
|
|
if calendar_delete_event is None:
|
|
raise HTTPException(503, "Calendar delete not available")
|
|
return await _as_owner(request, owner, calendar_delete_event, request, uid)
|
|
|
|
@router.delete("/documents/{doc_id}")
|
|
async def codex_documents_delete(request: Request, doc_id: str):
|
|
owner = _scope_owner(request, DOCS_WRITE_SCOPES)
|
|
if documents_delete_endpoint is None:
|
|
raise HTTPException(503, "Documents delete not available")
|
|
return await _as_owner(request, owner, documents_delete_endpoint, request, doc_id)
|
|
|
|
@router.post("/documents")
|
|
async def codex_documents_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
|
owner = _scope_owner(request, DOCS_WRITE_SCOPES)
|
|
if documents_create_endpoint is None:
|
|
raise HTTPException(503, "Documents integration is not available")
|
|
from routes.document_routes import DocumentCreate
|
|
|
|
try:
|
|
req = DocumentCreate(**body)
|
|
except Exception as exc:
|
|
raise HTTPException(400, f"Invalid document payload: {exc}")
|
|
return await _as_owner(request, owner, documents_create_endpoint, request, req)
|
|
|
|
# ── Cookbook surface ──
|
|
# Lets the agent run the same launch / monitor / kill loop the user
|
|
# would do by hand in the Cookbook UI: read the current task list +
|
|
# tmux output, launch a serve task, stop one. Two scopes:
|
|
# cookbook:read — list tasks + tail output + list servers
|
|
# cookbook:launch — also start/stop serves (host shell exec)
|
|
# `cookbook:launch` is genuinely powerful: /api/model/serve runs SSH'd
|
|
# commands on the user's hosts. The existing _validate_serve_cmd
|
|
# allowlist (vllm/python3/sglang/llama-server/etc., no shell metachars)
|
|
# keeps the agent inside the same sandbox the UI uses.
|
|
|
|
async def _run_shell(cmd: str, timeout: float = 15.0) -> dict:
|
|
"""Run a shell command, return {exit_code, stdout, stderr}."""
|
|
import asyncio as _asyncio
|
|
try:
|
|
proc = await _asyncio.create_subprocess_shell(
|
|
cmd,
|
|
stdout=_asyncio.subprocess.PIPE,
|
|
stderr=_asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout_b, stderr_b = await _asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except _asyncio.TimeoutError:
|
|
proc.kill()
|
|
return {"exit_code": -1, "stdout": "", "stderr": "timed out"}
|
|
return {
|
|
"exit_code": proc.returncode,
|
|
"stdout": stdout_b.decode(errors="replace"),
|
|
"stderr": stderr_b.decode(errors="replace"),
|
|
}
|
|
except Exception as exc:
|
|
return {"exit_code": -1, "stdout": "", "stderr": str(exc)}
|
|
|
|
def _read_cookbook_state() -> dict:
|
|
from pathlib import Path as _Path
|
|
import os as _os, json as _json
|
|
p = _Path(_os.environ.get("DATA_DIR", "data")) / "cookbook_state.json"
|
|
if not p.exists():
|
|
return {}
|
|
try:
|
|
return _json.loads(p.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
|
|
def _redact_task(t: dict) -> dict:
|
|
"""Strip secrets before returning to the agent."""
|
|
clean = {k: v for k, v in t.items() if k not in ("hf_token", "_secrets")}
|
|
if isinstance(clean.get("payload"), dict):
|
|
pl = clean["payload"]
|
|
clean["payload"] = {k: v for k, v in pl.items()
|
|
if k not in ("hf_token", "_secrets")}
|
|
return clean
|
|
|
|
@router.get("/cookbook/tasks")
|
|
async def codex_cookbook_tasks(request: Request):
|
|
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
|
state = _read_cookbook_state()
|
|
tasks = state.get("tasks") or []
|
|
return {"tasks": [_redact_task(t) for t in tasks]}
|
|
|
|
@router.get("/cookbook/servers")
|
|
async def codex_cookbook_servers(request: Request):
|
|
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
|
state = _read_cookbook_state()
|
|
servers = state.get("env", {}).get("servers") or []
|
|
# Strip ssh creds / passwords; keep only what's needed to pick a host.
|
|
cleaned = []
|
|
for s in servers:
|
|
cleaned.append({
|
|
"name": s.get("name"),
|
|
"host": s.get("host"),
|
|
"port": s.get("port"),
|
|
"env": s.get("env"),
|
|
"envPath": s.get("envPath"),
|
|
"platform": s.get("platform"),
|
|
"modelDirs": s.get("modelDirs"),
|
|
})
|
|
return {"servers": cleaned}
|
|
|
|
@router.get("/cookbook/output/{session_id}")
|
|
async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
|
|
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
|
# Defensive: session_id must be the tmux-style id we issue
|
|
# (`serve-XXXX` / `cookbook-XXXX` / `queue-XXXX`); anything else
|
|
# would let the agent run arbitrary `tmux capture-pane` targets.
|
|
import re as _re
|
|
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
|
|
raise HTTPException(400, "Invalid session id")
|
|
tail = max(20, min(int(tail or 400), 4000))
|
|
# Resolve the task's host (if any) from cookbook state so we can
|
|
# ssh to the right box, exactly as the UI does in _reconnectTask.
|
|
state = _read_cookbook_state()
|
|
tasks = state.get("tasks") or []
|
|
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
|
if task is None:
|
|
raise HTTPException(404, "task not found")
|
|
host = (task.get("remoteHost") or "").strip()
|
|
ssh_port = (task.get("sshPort") or "").strip()
|
|
# Prefer the persisted log file over the tmux pane. The pane gets
|
|
# overwritten by the post-crash neofetch banner + bash prompt the
|
|
# moment vllm exits; the log file is the raw stdout/stderr and
|
|
# survives unchanged. Falls back to pane for older tasks predating
|
|
# the tee-to-log runner change.
|
|
log_path = f"/tmp/odysseus-tmux/{session_id}.log"
|
|
inner = (
|
|
f"if [ -s {log_path} ]; then tail -n {tail} {log_path}; "
|
|
f"else tmux capture-pane -t {session_id} -p -S -{tail}; fi"
|
|
)
|
|
if host:
|
|
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
|
import shlex
|
|
cmd = f"ssh {port_flag}{host} {shlex.quote(inner)}"
|
|
else:
|
|
cmd = inner
|
|
result = await _run_shell(cmd, timeout=15)
|
|
return {
|
|
"session_id": session_id,
|
|
"host": host or "local",
|
|
"exit_code": result.get("exit_code"),
|
|
"output": result.get("stdout", ""),
|
|
"task": _redact_task(task),
|
|
}
|
|
|
|
@router.post("/cookbook/serve")
|
|
async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
|
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
|
# Wraps /api/model/serve with the SAME validation the UI uses.
|
|
# _validate_serve_cmd (called inside model_serve) rejects shell
|
|
# metachars and requires the leading binary to be in the
|
|
# cookbook allowlist (vllm / python3 / sglang / llama-server / ...).
|
|
from routes.cookbook_helpers import ServeRequest
|
|
# Accept friendly aliases agents naturally reach for. Without these,
|
|
# passing `host` silently maps to nothing and the serve runs LOCAL
|
|
# instead of on the intended remote — exactly the bug an agent
|
|
# would never debug on its own.
|
|
norm = dict(body or {})
|
|
if "host" in norm and "remote_host" not in norm:
|
|
norm["remote_host"] = norm.pop("host")
|
|
if "model" in norm and "repo_id" not in norm:
|
|
norm["repo_id"] = norm.pop("model")
|
|
if "ssh_port" not in norm and "port" in norm and (str(norm.get("port") or "").isdigit() and int(norm["port"]) >= 1000):
|
|
# Heuristic: if `port` looks like an SSH port (≥1000) and there's
|
|
# no explicit ssh_port, treat it as such. UI ports (8000, 8001,
|
|
# 30000) belong inside the cmd string, not here.
|
|
pass # leave as-is — user's `port` here is ambiguous; skip remap.
|
|
try:
|
|
req = ServeRequest(**norm)
|
|
except Exception as exc:
|
|
raise HTTPException(400, f"Invalid serve payload: {exc}")
|
|
serve_endpoint = _find_endpoint(None, "POST", "/api/model/serve")
|
|
# Fall back to importing from the cookbook router registered on app.
|
|
if serve_endpoint is None:
|
|
from fastapi import FastAPI
|
|
app: FastAPI = request.app
|
|
for route in app.routes:
|
|
if getattr(route, "path", None) == "/api/model/serve" and "POST" in getattr(route, "methods", set()):
|
|
serve_endpoint = route.endpoint
|
|
break
|
|
if serve_endpoint is None:
|
|
raise HTTPException(503, "model serve endpoint unavailable")
|
|
return await serve_endpoint(request, req)
|
|
|
|
@router.post("/cookbook/stop/{session_id}")
|
|
async def codex_cookbook_stop(request: Request, session_id: str):
|
|
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
|
import re as _re
|
|
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
|
|
raise HTTPException(400, "Invalid session id")
|
|
state = _read_cookbook_state()
|
|
tasks = state.get("tasks") or []
|
|
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
|
host = ((task or {}).get("remoteHost") or "").strip()
|
|
ssh_port = ((task or {}).get("sshPort") or "").strip()
|
|
if host:
|
|
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
|
cmd = f"ssh {port_flag}{host} \"tmux kill-session -t {session_id}\""
|
|
else:
|
|
cmd = f"tmux kill-session -t {session_id}"
|
|
result = await _run_shell(cmd, timeout=10)
|
|
return {"session_id": session_id, "exit_code": result.get("exit_code"), "host": host or "local"}
|
|
|
|
@router.get("/cookbook/cached")
|
|
async def codex_cookbook_cached(request: Request, host: str | None = None):
|
|
"""List cached models on a configured server (or local if host is omitted).
|
|
Mirrors `list_cached_models` from the chat agent so external agents have
|
|
the same inventory view before deciding what to serve/download."""
|
|
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
|
# Hit /api/model/cached internally, with the same modelDirs the chat
|
|
# agent's list_cached_models would resolve from cookbook state.
|
|
state = _read_cookbook_state()
|
|
env = state.get("env") if isinstance(state, dict) else {}
|
|
servers = (env.get("servers") if isinstance(env, dict) else None) or []
|
|
HF_DEFAULTS = {"~/.cache/huggingface/hub", "~/.cache/huggingface"}
|
|
def _dirs_for(srv: dict) -> str:
|
|
mds = srv.get("modelDirs") if isinstance(srv, dict) else None
|
|
if isinstance(mds, list):
|
|
extras = [d for d in mds if isinstance(d, str) and d.strip() and d.strip() not in HF_DEFAULTS]
|
|
return ",".join(extras)
|
|
if isinstance(mds, str) and mds.strip() not in HF_DEFAULTS:
|
|
return mds
|
|
return ""
|
|
# Resolve friendly host name → real host (matches list_cached_models flow).
|
|
resolved_host = host or ""
|
|
srv: dict[str, Any] = {}
|
|
if host:
|
|
srv = next(
|
|
(s for s in servers if isinstance(s, dict)
|
|
and (s.get("name") == host or s.get("host") == host)),
|
|
{},
|
|
)
|
|
if srv and srv.get("host"):
|
|
resolved_host = srv["host"]
|
|
else:
|
|
srv = next((s for s in servers if isinstance(s, dict) and not (s.get("host") or "").strip()), {})
|
|
params: dict[str, str] = {}
|
|
if resolved_host:
|
|
params["host"] = resolved_host
|
|
md = _dirs_for(srv)
|
|
if md:
|
|
params["model_dir"] = md
|
|
if srv.get("port"):
|
|
params["ssh_port"] = str(srv["port"])
|
|
if srv.get("platform"):
|
|
params["platform"] = srv["platform"]
|
|
cached_endpoint = _find_endpoint(None, "GET", "/api/model/cached")
|
|
if cached_endpoint is None:
|
|
from fastapi import FastAPI
|
|
app: FastAPI = request.app
|
|
for route in app.routes:
|
|
if getattr(route, "path", None) == "/api/model/cached" and "GET" in getattr(route, "methods", set()):
|
|
cached_endpoint = route.endpoint
|
|
break
|
|
if cached_endpoint is None:
|
|
raise HTTPException(503, "model cached endpoint unavailable")
|
|
# The endpoint reads host/model_dir/ssh_port/platform as kwargs.
|
|
return await cached_endpoint(
|
|
request,
|
|
host=params.get("host") or None,
|
|
model_dir=params.get("model_dir") or None,
|
|
ssh_port=params.get("ssh_port") or None,
|
|
platform=params.get("platform") or None,
|
|
)
|
|
|
|
@router.get("/cookbook/presets")
|
|
async def codex_cookbook_presets(request: Request):
|
|
"""List saved serve presets (model + host + port + launch cmd).
|
|
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve`
|
|
body — the user's saved preset usually has the working cmd already."""
|
|
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
|
state = _read_cookbook_state()
|
|
presets = state.get("presets") or []
|
|
out = []
|
|
for p in presets:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
out.append({
|
|
"name": p.get("name"),
|
|
"model": p.get("model") or p.get("modelId"),
|
|
"host": p.get("host") or p.get("remoteHost"),
|
|
"port": p.get("port"),
|
|
"cmd": p.get("cmd"),
|
|
})
|
|
return {"presets": out, "default_host": (state.get("env") or {}).get("defaultServer", "")}
|
|
|
|
@router.post("/cookbook/preset/{name}")
|
|
async def codex_cookbook_serve_preset(request: Request, name: str):
|
|
"""Launch a saved preset by name. Reuses the working cmd + host the
|
|
user already saved, avoiding the cmd-allowlist trial-and-error loop."""
|
|
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
|
import re as _re
|
|
if not _re.fullmatch(r"[A-Za-z0-9 _.:@\-]+", name):
|
|
raise HTTPException(400, "Invalid preset name")
|
|
state = _read_cookbook_state()
|
|
presets = state.get("presets") or []
|
|
lname = name.lower().strip()
|
|
chosen = next(
|
|
(p for p in presets if isinstance(p, dict) and (p.get("name") or "").lower() == lname),
|
|
None,
|
|
)
|
|
if chosen is None:
|
|
chosen = next(
|
|
(p for p in presets if isinstance(p, dict) and lname in (p.get("name") or "").lower()),
|
|
None,
|
|
)
|
|
if chosen is None:
|
|
raise HTTPException(404, f"No preset matching {name!r}")
|
|
repo_id = chosen.get("model") or chosen.get("modelId") or ""
|
|
cmd = (chosen.get("cmd") or "").strip()
|
|
host = chosen.get("host") or chosen.get("remoteHost") or ""
|
|
if not repo_id or not cmd or cmd.startswith("(adopted"):
|
|
raise HTTPException(400, f"Preset {chosen.get('name')!r} has no launchable cmd "
|
|
"(adopted from external launch). Use POST /cookbook/serve "
|
|
"with the actual cmd instead.")
|
|
# Reuse the serve handler we already validated.
|
|
from routes.cookbook_helpers import ServeRequest
|
|
body = {"repo_id": repo_id, "cmd": cmd}
|
|
if host:
|
|
body["remote_host"] = host
|
|
try:
|
|
req = ServeRequest(**body)
|
|
except Exception as exc:
|
|
raise HTTPException(400, f"Preset payload invalid: {exc}")
|
|
serve_endpoint = _find_endpoint(None, "POST", "/api/model/serve")
|
|
if serve_endpoint is None:
|
|
from fastapi import FastAPI
|
|
app: FastAPI = request.app
|
|
for route in app.routes:
|
|
if getattr(route, "path", None) == "/api/model/serve" and "POST" in getattr(route, "methods", set()):
|
|
serve_endpoint = route.endpoint
|
|
break
|
|
if serve_endpoint is None:
|
|
raise HTTPException(503, "model serve endpoint unavailable")
|
|
return await serve_endpoint(request, req)
|
|
|
|
@router.post("/cookbook/adopt")
|
|
async def codex_cookbook_adopt(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
|
"""Adopt an existing tmux session (one started via raw ssh+tmux) into
|
|
cookbook tracking. Needed when serve_model rejects a cmd and the
|
|
agent falls back to direct ssh — without adoption the session is
|
|
invisible to the UI. Body: {tmux_session, model, host?, port?}."""
|
|
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
|
norm = dict(body or {})
|
|
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
|
|
model = (norm.get("model") or norm.get("repo_id") or "").strip()
|
|
host = (norm.get("host") or norm.get("remote_host") or "").strip()
|
|
port = norm.get("port") or 8000
|
|
import re as _re
|
|
if not sess or not _re.fullmatch(r"[a-zA-Z0-9_-]+", sess):
|
|
raise HTTPException(400, "tmux_session required, [a-zA-Z0-9_-]+ only")
|
|
if not model:
|
|
raise HTTPException(400, "model required")
|
|
# Verify the tmux session exists on the target host before adopting.
|
|
import shlex
|
|
if host:
|
|
check = f"ssh {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)}'"
|
|
else:
|
|
check = f"tmux has-session -t {shlex.quote(sess)}"
|
|
chk = await _run_shell(check, timeout=8)
|
|
if chk.get("exit_code") not in (0, None):
|
|
raise HTTPException(404, f"tmux session {sess!r} not found on {host or 'local'}")
|
|
# Write into cookbook_state.json.
|
|
import time as _t, json as _json
|
|
from core.atomic_io import atomic_write_json
|
|
from pathlib import Path as _Path
|
|
cookbook_state_path = _Path("/app/data/cookbook_state.json")
|
|
try:
|
|
state = _json.loads(cookbook_state_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
state = {}
|
|
tasks = state.setdefault("tasks", [])
|
|
if any(isinstance(t, dict) and t.get("sessionId") == sess for t in tasks):
|
|
return {"ok": True, "already_tracked": True, "session_id": sess}
|
|
tasks.append({
|
|
"id": sess, "sessionId": sess,
|
|
"name": model.split("/")[-1] if "/" in model else model,
|
|
"type": "serve", "status": "running",
|
|
"output": f"Adopted externally-launched session {sess!r} on {host or 'local'}.",
|
|
"ts": int(_t.time() * 1000),
|
|
"payload": {"repo_id": model, "remote_host": host, "_cmd": "(adopted — launched outside cookbook)", "port": int(port)},
|
|
"remoteHost": host, "sshPort": "", "platform": "linux",
|
|
"_serveReady": False, "_endpointAdded": False, "_adoptedExternally": True,
|
|
})
|
|
try:
|
|
atomic_write_json(cookbook_state_path, state)
|
|
except Exception as exc:
|
|
raise HTTPException(500, f"state write failed: {exc}")
|
|
return {"ok": True, "session_id": sess, "host": host or "local"}
|
|
|
|
return router
|
|
|
|
|
|
def setup_claude_routes() -> APIRouter:
|
|
"""Serve the Claude Code skill bundle.
|
|
|
|
Claude Code uses the same scope-gated `/api/codex/*` endpoints at runtime;
|
|
this router only exists to deliver the skill zip via `/api/claude/plugin.zip`
|
|
so the user-facing setup commands stay in the Claude namespace.
|
|
"""
|
|
router = APIRouter(prefix="/api/claude", tags=["claude"])
|
|
|
|
@router.get("/plugin.zip")
|
|
def plugin_zip(request: Request):
|
|
require_user(request)
|
|
# Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump
|
|
# README.md or other bundle metadata into the user's claude config dir.
|
|
skills_root = Path(__file__).resolve().parent.parent / "integrations" / "claude" / "skills"
|
|
if not skills_root.exists():
|
|
raise HTTPException(404, "Claude skill bundle not found")
|
|
bundle_root = skills_root.parent
|
|
buf = BytesIO()
|
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
for path in sorted(skills_root.rglob("*")):
|
|
if path.is_dir() or "__pycache__" in path.parts or path.suffix == ".pyc":
|
|
continue
|
|
zf.write(path, path.relative_to(bundle_root))
|
|
buf.seek(0)
|
|
headers = {"Content-Disposition": 'attachment; filename="odysseus-claude-skill.zip"'}
|
|
return StreamingResponse(buf, media_type="application/zip", headers=headers)
|
|
|
|
return router
|