diff --git a/app.py b/app.py index 60441baf1..b0e6f9d55 100644 --- a/app.py +++ b/app.py @@ -755,7 +755,8 @@ app.include_router(setup_research_routes(research_handler, session_manager=sessi # History from routes.history.history_routes import setup_history_routes -app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler)) +history_router = setup_history_routes(session_manager, upload_handler=upload_handler) +app.include_router(history_router) # Search from routes.search.search_routes import setup_search_routes @@ -915,6 +916,7 @@ app.include_router(setup_codex_routes( memory_router=memory_router, calendar_router=calendar_router, document_router=document_router, + history_router=history_router, )) app.include_router(setup_claude_routes()) diff --git a/integrations/claude/skills/odysseus/SKILL.md b/integrations/claude/skills/odysseus/SKILL.md index 31b40ee01..c8bd94db3 100644 --- a/integrations/claude/skills/odysseus/SKILL.md +++ b/integrations/claude/skills/odysseus/SKILL.md @@ -100,6 +100,14 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory - `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`. - `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`. +## Chat history + +- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope. + +```bash +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID +``` + ## Email draft + send - Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send. diff --git a/integrations/claude/skills/odysseus/scripts/odysseus_api.py b/integrations/claude/skills/odysseus/scripts/odysseus_api.py index 8a22eb494..54df59fa1 100755 --- a/integrations/claude/skills/odysseus/scripts/odysseus_api.py +++ b/integrations/claude/skills/odysseus/scripts/odysseus_api.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Small Odysseus scoped API helper for Codex terminal sessions.""" +"""Small Odysseus scoped API helper for Claude Code terminal sessions.""" from __future__ import annotations @@ -44,7 +44,7 @@ def _config() -> tuple[str, str] | None: 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) + print(f"missing {', '.join(missing)}; create a Claude Agent token in Odysseus Settings", file=sys.stderr) return None return base_url, token diff --git a/integrations/codex/skills/odysseus/SKILL.md b/integrations/codex/skills/odysseus/SKILL.md index d4cbdf726..a939cac33 100644 --- a/integrations/codex/skills/odysseus/SKILL.md +++ b/integrations/codex/skills/odysseus/SKILL.md @@ -100,6 +100,14 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex - `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`. - `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`. +## Chat history + +- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope. + +```bash +python3 integrations/codex/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID +``` + ## Email draft + send - Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send. diff --git a/routes/codex_routes.py b/routes/codex_routes.py index f42c6b632..542e44d49 100644 --- a/routes/codex_routes.py +++ b/routes/codex_routes.py @@ -35,6 +35,7 @@ CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"} CALENDAR_WRITE_SCOPES = {"calendar:write"} DOCS_READ_SCOPES = {"documents:read", "documents:write"} DOCS_WRITE_SCOPES = {"documents:write"} +CHAT_READ_SCOPES = {"chat"} WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"} @@ -161,6 +162,7 @@ def setup_codex_routes( memory_router: APIRouter | None = None, calendar_router: APIRouter | None = None, document_router: APIRouter | None = None, + history_router: APIRouter | None = None, ) -> APIRouter: router = APIRouter(prefix="/api/codex", tags=["codex"]) email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list") @@ -174,6 +176,7 @@ def setup_codex_routes( 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") + chat_history_endpoint = _find_endpoint(history_router, "GET", "/api/history/{session_id}") @router.get("/capabilities") def capabilities(request: Request): @@ -219,6 +222,11 @@ def setup_codex_routes( "launch": scoped(COOKBOOK_LAUNCH_SCOPES), "actions": ["tasks", "servers", "output", "serve", "stop"], }, + "chat": { + "read": scoped(CHAT_READ_SCOPES), + "actions": ["read"], + "available": chat_history_endpoint is not None, + }, }, "safety": { "email_send_requires_confirmation": True, @@ -226,6 +234,33 @@ def setup_codex_routes( }, } + @router.get("/chat/{session_id}") + async def read_chat( + request: Request, + session_id: str, + offset: int = 0, + limit: int = 100, + ): + _scope_owner(request, CHAT_READ_SCOPES) + if chat_history_endpoint is None: + raise HTTPException(503, "chat history unavailable") + offset, limit = _clamp_pagination( + offset, + limit, + default_limit=100, + max_limit=100, + ) + result = await chat_history_endpoint(request, session_id, limit, offset) + return { + "session_id": session_id, + "name": result["name"], + "model": result["model"], + "messages": result["history"], + "total": result["total"], + "offset": result["offset"], + "limit": result["limit"], + } + @router.get("/plugin.zip") def plugin_zip(request: Request): require_authenticated_request(request) diff --git a/tests/test_codex_chat_history.py b/tests/test_codex_chat_history.py new file mode 100644 index 000000000..a169588b2 --- /dev/null +++ b/tests/test_codex_chat_history.py @@ -0,0 +1,111 @@ +"""Agent chat history uses the same scope as existing chat and history routes.""" + +import pytest +from fastapi import APIRouter, HTTPException +from starlette.requests import Request + +from routes import codex_routes + + +def _endpoint(router, method: str, path: str): + for route in router.routes: + if route.path == path and method in route.methods: + return route.endpoint + raise AssertionError(f"{method} {path} route not found") + + +def _request(scopes: list[str]) -> Request: + request = Request({ + "type": "http", + "method": "GET", + "path": "/api/codex/chat/session-test", + "headers": [], + "state": {}, + }) + request.state.current_user = "api" + request.state.api_token = True + request.state.api_token_owner = "alice" + request.state.api_token_scopes = scopes + return request + + +def _history_router(calls: list[tuple]): + router = APIRouter() + + @router.get("/api/history/{session_id}") + async def history( + request: Request, + session_id: str, + limit: int | None = None, + offset: int | None = None, + ): + calls.append((request, session_id, limit, offset)) + return { + "name": "Model debugging", + "model": "org/model", + "history": [{"role": "assistant", "content": "second"}], + "total": 3, + "offset": offset, + "limit": limit, + "endpoint_url": "http://private-endpoint", + } + + return router + + +@pytest.mark.asyncio +async def test_history_read_uses_existing_paged_handler_without_exposing_endpoint_url(): + calls = [] + router = codex_routes.setup_codex_routes(history_router=_history_router(calls)) + read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}") + request = _request(["chat"]) + + result = await read_chat(request, "session-test", offset=1, limit=1) + + assert calls == [(request, "session-test", 1, 1)] + assert result == { + "session_id": "session-test", + "name": "Model debugging", + "model": "org/model", + "messages": [{"role": "assistant", "content": "second"}], + "total": 3, + "offset": 1, + "limit": 1, + } + + +@pytest.mark.asyncio +async def test_token_without_chat_scope_cannot_read_history_or_reach_handler(): + calls = [] + router = codex_routes.setup_codex_routes(history_router=_history_router(calls)) + read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}") + + with pytest.raises(HTTPException) as exc_info: + await read_chat(_request(["todos:read"]), "session-test") + + assert exc_info.value.status_code == 403 + assert calls == [] + + +@pytest.mark.asyncio +async def test_history_read_uses_existing_page_limit(): + calls = [] + router = codex_routes.setup_codex_routes(history_router=_history_router(calls)) + read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}") + + await read_chat(_request(["chat"]), "session-test", limit=1000) + + assert calls[0][2:] == (100, 0) + + +def test_capabilities_report_history_permission_from_chat_scope(): + router = codex_routes.setup_codex_routes(history_router=_history_router([])) + capabilities = _endpoint(router, "GET", "/api/codex/capabilities") + + without_chat = capabilities(_request(["todos:read"]))["tools"]["chat"] + reader = capabilities(_request(["chat"]))["tools"]["chat"] + + assert without_chat["read"] is False + assert reader["read"] is True + assert reader["available"] is True + assert reader["actions"] == ["read"]