Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ def register_static_mime_types() -> None:
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
)
from core.database import SessionLocal, ApiToken
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
from core.middleware import (
CodexCookbookBoundaryMiddleware,
SecurityHeadersMiddleware,
is_cors_preflight,
)
from core.auth import AuthManager, normalize_known_username
from core.exceptions import (
SessionNotFoundError, InvalidFileUploadError,
Expand Down Expand Up @@ -358,7 +362,7 @@ async def dispatch(self, request: Request, call_next):
path = request.url.path
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
# carries no credentials by design and must reach CORSMiddleware to be
# answered. AuthMiddleware is the outermost middleware, so gating the
# answered. AuthMiddleware runs outside CORSMiddleware, so gating the
# preflight on auth 401s it before CORS can respond -- which blocks
# every cross-origin browser/WebView client before the real request
# is sent. Let real preflights through (only OPTIONS w/ the ACRM
Expand Down Expand Up @@ -473,6 +477,12 @@ def _do():
else:
logger.info("Auth middleware disabled (set AUTH_ENABLED=true to enable)")

# Added after AuthMiddleware so Starlette places this boundary outermost. Raw
# Odysseus bearer/internal credentials are rejected before authentication can
# read or update token state and before FastAPI parses a Cookbook route body.
# The same boundary remains installed when authentication is disabled.
app.add_middleware(CodexCookbookBoundaryMiddleware)

# ========= STATIC FILES =========
os.makedirs(STATIC_DIR, exist_ok=True)

Expand Down
98 changes: 97 additions & 1 deletion core/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

from fastapi import HTTPException, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from starlette.responses import JSONResponse, Response
from starlette.routing import get_route_path


# Per-process token that lets the in-app tool layer hit admin-gated
Expand All @@ -17,6 +18,7 @@
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
# Pseudo-username on in-process tool-loopback requests; require_admin trusts it and it is reserved.
INTERNAL_TOOL_USER = "internal-tool"
CODEX_COOKBOOK_PREFIX = "/api/codex/cookbook"


def is_cors_preflight(method: str, headers) -> bool:
Expand All @@ -28,6 +30,100 @@ def is_cors_preflight(method: str, headers) -> bool:
return method == "OPTIONS" and "access-control-request-method" in headers


def is_codex_cookbook_path(path: str) -> bool:
"""Match only the duplicate Codex Cookbook route family."""
return path == CODEX_COOKBOOK_PREFIX or path.startswith(
f"{CODEX_COOKBOOK_PREFIX}/"
)


def is_odysseus_bearer_authorization(value: str | None) -> bool:
"""Recognize an Odysseus Bearer value, including proxy-combined fields."""
if not isinstance(value, str):
return False
for candidate in value.split(","):
parts = candidate.strip().split(None, 1)
if (
len(parts) == 2
and parts[0].casefold() == "bearer"
and parts[1].startswith("ody_")
):
return True
return False


def _header_values(headers, name: str) -> list[str]:
"""Return every field value, with a mapping fallback for direct callers."""
getlist = getattr(headers, "getlist", None)
if callable(getlist):
values = getlist(name)
else:
value = headers.get(name)
values = value if isinstance(value, (list, tuple)) else [value]
return [value for value in values if isinstance(value, str)]


def _internal_header_matches(value: str) -> bool:
"""Compare raw or proxy-combined values without obs-text type failures."""
candidates = [value]
trimmed_value = value.strip(" \t")
if trimmed_value != value:
candidates.append(trimmed_value)
if "," in value:
for part in value.split(","):
candidates.append(part)
trimmed_part = part.strip(" \t")
if trimmed_part != part:
candidates.append(trimmed_part)
try:
expected = INTERNAL_TOOL_TOKEN.encode("utf-8")
except (AttributeError, UnicodeError):
return False
for candidate in candidates:
try:
if secrets.compare_digest(candidate.encode("utf-8"), expected):
return True
except (AttributeError, TypeError, UnicodeError):
continue
return False


def require_codex_cookbook_browser(request: Request) -> None:
"""Reject bearer and internal-tool principals at the shared boundary."""
current_user = getattr(request.state, "current_user", None)
if (
getattr(request.state, "api_token", False)
or current_user == "api"
or current_user == INTERNAL_TOOL_USER
):
raise HTTPException(403, "Forbidden")
if any(
is_odysseus_bearer_authorization(value)
for value in _header_values(request.headers, "authorization")
):
raise HTTPException(403, "Forbidden")
if any(
_internal_header_matches(value)
for value in _header_values(request.headers, INTERNAL_TOOL_HEADER)
):
raise HTTPException(403, "Forbidden")


class CodexCookbookBoundaryMiddleware(BaseHTTPMiddleware):
"""Apply the Codex Cookbook principal gate before request-body parsing."""

async def dispatch(self, request: Request, call_next) -> Response:
if is_codex_cookbook_path(get_route_path(request.scope)):
try:
require_codex_cookbook_browser(request)
except HTTPException as exc:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
return await call_next(request)


def require_admin(request: Request):
"""Raise 403 if the current user isn't an admin.
Allows access when auth is explicitly disabled, or when the request carries
Expand Down
46 changes: 2 additions & 44 deletions integrations/claude/skills/odysseus/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: odysseus
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) or to launch/monitor/stop a Cookbook model-serve task through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
---

# Odysseus
Expand Down Expand Up @@ -34,6 +34,7 @@ If the user says "reminder" + a time, default to TODO with due_date. Only switch
- 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.
- Cookbook/model deployment is intentionally operator-controlled in the Odysseus UI and unavailable to bearer tokens; agents should consume the user-configured inference endpoint rather than attempt `/api/codex/cookbook/*`.

## Todos

Expand Down Expand Up @@ -106,49 +107,6 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.

## Cookbook serve (debug a failing model launch)

The Cookbook surface lets you reproduce what a human would do in Odysseus → Cookbook: read which serves are running, tail their tmux output to see why they crashed, edit the launch command, relaunch, kill a stuck one. Use this when the user is debugging a model server that won't come up (compute-capability errors, OOM, missing kernels, wrong attention backend, etc.).

- `GET /api/codex/cookbook/tasks` — list active serve/download/install tasks (sessionId, type, status, repo_id, remoteHost, payload._cmd). Requires `cookbook:read`.
- `GET /api/codex/cookbook/servers` — list configured servers (name, host, port, env type + path, model dirs). Requires `cookbook:read`.
- `GET /api/codex/cookbook/cached?host=<NAME>` — list models already cached on the named server (HF cache + Ollama + extra modelDirs). Call BEFORE `serve` to see what's already on disk. Requires `cookbook:read`.
- `GET /api/codex/cookbook/presets` — list saved serve presets (model + host + port + cmd). The user's saved preset usually has a working cmd — try `preset NAME` before composing your own. Requires `cookbook:read`.
- `GET /api/codex/cookbook/output/{session_id}?tail=400` — read the last N lines of the task's persistent log file (preferred) or tmux pane (fallback). The log file persists across vllm crashes, so this returns the actual Python traceback even after the bash prompt + neofetch banner overwrites the pane. Default tail=400. Requires `cookbook:read`.
- `POST /api/codex/cookbook/serve` — launch a serve task. Body matches `ServeRequest`: `{ repo_id, cmd, remote_host?, ssh_port?, env_prefix?, gpus?, platform? }`. The `cmd` is validated: leading binary must be `vllm`/`python3`/`sglang`/`llama-server`/`ollama`/`node`/`npx`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||`/`;`/`$(...)` — the validator rejects shell metacharacters. The venv activation (`env_prefix`) is added automatically from the host's saved settings, so pass the bare binary + args. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/preset/{name}` — launch a saved preset by name. Reuses the working cmd + host the user already saved. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/adopt` — register an externally-launched tmux session into cookbook tracking. Body: `{ tmux_session, model, host?, port? }`. Use this when serve_model rejected a cmd and you fell back to direct ssh+tmux — without adoption, the session is invisible to the UI. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/stop/{session_id}` — kill the tmux session for that task. Requires `cookbook:launch`.

```bash
# Survey what's running
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook tasks

# Tail the failing one (sessionId from `cookbook tasks`)
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook output serve-abc12345 400

# Stop the previous attempt before you try a new flag set
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook stop serve-abc12345

# Relaunch with new flags. cmd MUST begin with one of the allowlisted binaries.
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook serve \
/mnt/HADES/models/Qwen3.5-397B-A17B-AWQ \
"vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --host 0.0.0.0 --port 8001 --tensor-parallel-size 8 --max-model-len 262144 --gpu-memory-utilization 0.90 --dtype auto --max-num-seqs 8 --trust-remote-code --enable-expert-parallel --enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser qwen3" \
pewds@192.168.1.12
```

**Debug loop pattern:** when a serve is failing, the productive sequence is

1. `cookbook tasks` → find the failing sessionId.
2. `cookbook output SID 600` → read the last 600 lines, find the actual root-cause line (often above the visible tail because tmux scrollback rolled — request a larger `tail` if the error references "above").
3. `cookbook stop SID` — kill the previous attempt before relaunching; two serves on the same `--port` collide.
4. `cookbook serve repo "new cmd"` — try the next variation. Wait ~20s, then `cookbook output` on the new sessionId.

**Hard limits this surface enforces:**
- `cookbook serve` cmd allowlist + shell-metacharacter rejection — you cannot run arbitrary shell, only model-server binaries.
- `cookbook stop` only targets task sessionIds matching `[a-zA-Z0-9_-]+`.
- The agent CAN spawn GPU-pinning long-lived processes — always `cookbook stop` your previous attempt before relaunching, and check `cookbook tasks` for collisions on the same `--port` before launching.

## 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 Claude Agent tool toggle instead.
64 changes: 0 additions & 64 deletions integrations/claude/skills/odysseus/scripts/odysseus_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,6 @@ def _usage() -> int:
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)
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)
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
return 2

Expand Down Expand Up @@ -113,61 +104,6 @@ def main() -> int:
body = None
else:
return _usage()
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()
else:
if len(sys.argv) < 3:
return _usage()
Expand Down
64 changes: 0 additions & 64 deletions integrations/codex/scripts/odysseus_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,6 @@ def _usage() -> int:
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)
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)
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
return 2

Expand Down Expand Up @@ -113,61 +104,6 @@ def main() -> int:
body = None
else:
return _usage()
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()
else:
if len(sys.argv) < 3:
return _usage()
Expand Down
Loading
Loading