openhands-automation 1.0.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/automation/__init__.py +3 -0
- openhands/automation/app.py +306 -0
- openhands/automation/auth.py +372 -0
- openhands/automation/backends/__init__.py +67 -0
- openhands/automation/backends/base.py +145 -0
- openhands/automation/backends/cloud.py +302 -0
- openhands/automation/backends/local.py +180 -0
- openhands/automation/config.py +573 -0
- openhands/automation/constants.py +83 -0
- openhands/automation/db.py +210 -0
- openhands/automation/dispatcher.py +429 -0
- openhands/automation/event_router.py +192 -0
- openhands/automation/event_schemas/__init__.py +126 -0
- openhands/automation/event_schemas/custom.py +108 -0
- openhands/automation/event_schemas/detection.py +136 -0
- openhands/automation/event_schemas/github.py +483 -0
- openhands/automation/exceptions.py +29 -0
- openhands/automation/execution.py +568 -0
- openhands/automation/filter_eval.py +192 -0
- openhands/automation/logger.py +115 -0
- openhands/automation/migrations/env.py +152 -0
- openhands/automation/migrations/script.py.mako +25 -0
- openhands/automation/migrations/versions/001_initial_schema.py +129 -0
- openhands/automation/migrations/versions/002_tarball_uploads.py +64 -0
- openhands/automation/migrations/versions/003_event_triggers.py +92 -0
- openhands/automation/migrations/versions/004_add_prompt_column.py +29 -0
- openhands/automation/models.py +312 -0
- openhands/automation/preset_router.py +531 -0
- openhands/automation/presets/__init__.py +9 -0
- openhands/automation/presets/plugin/sdk_main.py +328 -0
- openhands/automation/presets/plugin/setup.sh +23 -0
- openhands/automation/presets/prompt/sdk_main.py +315 -0
- openhands/automation/presets/prompt/setup.sh +23 -0
- openhands/automation/router.py +347 -0
- openhands/automation/scheduler.py +202 -0
- openhands/automation/schemas.py +613 -0
- openhands/automation/storage/__init__.py +18 -0
- openhands/automation/storage/factory.py +36 -0
- openhands/automation/storage/file_store.py +64 -0
- openhands/automation/storage/google_cloud.py +195 -0
- openhands/automation/storage/local.py +125 -0
- openhands/automation/storage/s3.py +340 -0
- openhands/automation/trigger_matcher.py +89 -0
- openhands/automation/uploads.py +323 -0
- openhands/automation/utils/__init__.py +24 -0
- openhands/automation/utils/agent_server.py +161 -0
- openhands/automation/utils/api_key.py +98 -0
- openhands/automation/utils/cron.py +143 -0
- openhands/automation/utils/log_context.py +28 -0
- openhands/automation/utils/run.py +238 -0
- openhands/automation/utils/sandbox.py +212 -0
- openhands/automation/utils/tarball_validation.py +170 -0
- openhands/automation/utils/time.py +15 -0
- openhands/automation/utils/webhook.py +235 -0
- openhands/automation/watchdog.py +284 -0
- openhands/automation/webhook_router.py +275 -0
- openhands_automation-1.0.0a1.dist-info/METADATA +128 -0
- openhands_automation-1.0.0a1.dist-info/RECORD +60 -0
- openhands_automation-1.0.0a1.dist-info/WHEEL +4 -0
- openhands_automation-1.0.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""FastAPI application entrypoint."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
from fastapi.responses import JSONResponse
|
|
12
|
+
from fastapi.staticfiles import StaticFiles
|
|
13
|
+
from sqlalchemy import text
|
|
14
|
+
|
|
15
|
+
from openhands.automation.auth import create_http_client
|
|
16
|
+
from openhands.automation.config import get_settings
|
|
17
|
+
from openhands.automation.db import (
|
|
18
|
+
create_engine,
|
|
19
|
+
create_session_factory,
|
|
20
|
+
set_sqlite_mode,
|
|
21
|
+
)
|
|
22
|
+
from openhands.automation.dispatcher import dispatcher_loop
|
|
23
|
+
from openhands.automation.event_router import router as event_router
|
|
24
|
+
from openhands.automation.logger import setup_all_loggers
|
|
25
|
+
from openhands.automation.preset_router import router as preset_router
|
|
26
|
+
from openhands.automation.router import router
|
|
27
|
+
from openhands.automation.scheduler import scheduler_loop
|
|
28
|
+
from openhands.automation.uploads import router as uploads_router
|
|
29
|
+
from openhands.automation.watchdog import watchdog_loop
|
|
30
|
+
from openhands.automation.webhook_router import router as webhook_router
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("automation.app")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def lifespan(app: FastAPI):
|
|
38
|
+
"""Application startup/shutdown lifecycle."""
|
|
39
|
+
# Startup
|
|
40
|
+
settings = get_settings()
|
|
41
|
+
|
|
42
|
+
# Apply the repo-wide JSON structured-logging convention
|
|
43
|
+
setup_all_loggers()
|
|
44
|
+
|
|
45
|
+
# Silence noisy third-party loggers
|
|
46
|
+
for noisy_logger in (
|
|
47
|
+
"ddtrace",
|
|
48
|
+
"httpx",
|
|
49
|
+
"httpcore",
|
|
50
|
+
"sqlalchemy.engine", # Suppress SQL statement logging
|
|
51
|
+
):
|
|
52
|
+
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
|
|
53
|
+
|
|
54
|
+
logger.info("Starting OpenHands Automations Service")
|
|
55
|
+
|
|
56
|
+
# Create shared httpx client for auth (stored in app.state for DI)
|
|
57
|
+
app.state.http_client = create_http_client()
|
|
58
|
+
|
|
59
|
+
# Create engine and session factory, store in app.state
|
|
60
|
+
engine_result = await create_engine(settings)
|
|
61
|
+
app.state.engine_result = engine_result
|
|
62
|
+
app.state.engine = engine_result.engine
|
|
63
|
+
app.state.session_factory = create_session_factory(engine_result.engine)
|
|
64
|
+
|
|
65
|
+
# Set SQLite mode flag for scheduler/dispatcher to use
|
|
66
|
+
set_sqlite_mode(engine_result.is_sqlite)
|
|
67
|
+
|
|
68
|
+
# Auto-run migrations for SQLite on startup
|
|
69
|
+
# This ensures the schema is always up-to-date for local deployments
|
|
70
|
+
# For PostgreSQL, migrations are typically run separately via `alembic upgrade head`
|
|
71
|
+
if engine_result.is_sqlite:
|
|
72
|
+
from alembic import command
|
|
73
|
+
from alembic.config import Config
|
|
74
|
+
|
|
75
|
+
from openhands.automation.db import normalize_sqlite_url_for_alembic
|
|
76
|
+
|
|
77
|
+
# Find migrations folder relative to this package.
|
|
78
|
+
# When installed via pip/uvx, migrations are bundled inside
|
|
79
|
+
# automation/migrations.
|
|
80
|
+
package_dir = Path(__file__).parent
|
|
81
|
+
migrations_path = package_dir / "migrations"
|
|
82
|
+
|
|
83
|
+
if not migrations_path.is_dir():
|
|
84
|
+
# Fallback: check if running from source (migrations at repo root)
|
|
85
|
+
repo_root_migrations = package_dir.parent / "migrations"
|
|
86
|
+
if repo_root_migrations.is_dir():
|
|
87
|
+
migrations_path = repo_root_migrations
|
|
88
|
+
else:
|
|
89
|
+
msg = (
|
|
90
|
+
f"Migrations directory not found. "
|
|
91
|
+
f"Checked: {migrations_path}, {repo_root_migrations}"
|
|
92
|
+
)
|
|
93
|
+
raise RuntimeError(msg)
|
|
94
|
+
|
|
95
|
+
alembic_cfg = Config()
|
|
96
|
+
alembic_cfg.set_main_option("script_location", str(migrations_path))
|
|
97
|
+
# Set the database URL for Alembic to use (sync version)
|
|
98
|
+
db_url = normalize_sqlite_url_for_alembic(settings.db_url)
|
|
99
|
+
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
|
|
100
|
+
|
|
101
|
+
# Run migrations synchronously (Alembic doesn't support async)
|
|
102
|
+
try:
|
|
103
|
+
command.upgrade(alembic_cfg, "head")
|
|
104
|
+
logger.info("SQLite database migrations applied successfully")
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"Failed to apply SQLite migrations: {e}")
|
|
107
|
+
msg = f"SQLite migration failed. Database may be inconsistent: {e}"
|
|
108
|
+
raise RuntimeError(msg) from e
|
|
109
|
+
|
|
110
|
+
# Start the background scheduler and dispatcher
|
|
111
|
+
shutdown_event = asyncio.Event()
|
|
112
|
+
app.state.shutdown_event = shutdown_event
|
|
113
|
+
|
|
114
|
+
# Scheduler: polls automations and creates PENDING runs
|
|
115
|
+
scheduler_task = asyncio.create_task(
|
|
116
|
+
scheduler_loop(
|
|
117
|
+
app.state.session_factory,
|
|
118
|
+
interval_seconds=settings.scheduler_interval_seconds,
|
|
119
|
+
shutdown_event=shutdown_event,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
app.state.scheduler_task = scheduler_task
|
|
123
|
+
logger.info("Background scheduler started")
|
|
124
|
+
|
|
125
|
+
# Dispatcher: picks up PENDING runs and dispatches them
|
|
126
|
+
if not settings.base_url:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"AUTOMATION_BASE_URL not set — using localhost. "
|
|
129
|
+
"Sandboxes in the cloud won't be able to reach this URL."
|
|
130
|
+
)
|
|
131
|
+
dispatcher_task = asyncio.create_task(
|
|
132
|
+
dispatcher_loop(
|
|
133
|
+
app.state.session_factory,
|
|
134
|
+
settings=settings,
|
|
135
|
+
interval_seconds=settings.dispatcher_interval_seconds,
|
|
136
|
+
shutdown_event=shutdown_event,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
app.state.dispatcher_task = dispatcher_task
|
|
140
|
+
logger.info("Background dispatcher started")
|
|
141
|
+
|
|
142
|
+
# Watchdog: marks stale RUNNING runs as FAILED
|
|
143
|
+
watchdog_task = asyncio.create_task(
|
|
144
|
+
watchdog_loop(
|
|
145
|
+
app.state.session_factory,
|
|
146
|
+
settings=settings,
|
|
147
|
+
shutdown_event=shutdown_event,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
app.state.watchdog_task = watchdog_task
|
|
151
|
+
logger.info("Background watchdog started")
|
|
152
|
+
|
|
153
|
+
yield
|
|
154
|
+
|
|
155
|
+
# Shutdown
|
|
156
|
+
logger.info("Shutting down background tasks...")
|
|
157
|
+
shutdown_event.set()
|
|
158
|
+
|
|
159
|
+
# Wait for all tasks to exit gracefully
|
|
160
|
+
for task_name, task in [
|
|
161
|
+
("scheduler", scheduler_task),
|
|
162
|
+
("dispatcher", dispatcher_task),
|
|
163
|
+
("watchdog", watchdog_task),
|
|
164
|
+
]:
|
|
165
|
+
try:
|
|
166
|
+
await asyncio.wait_for(task, timeout=5.0)
|
|
167
|
+
except TimeoutError:
|
|
168
|
+
logger.warning("%s did not exit in time, cancelling", task_name)
|
|
169
|
+
task.cancel()
|
|
170
|
+
try:
|
|
171
|
+
await task
|
|
172
|
+
except asyncio.CancelledError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
await app.state.http_client.aclose()
|
|
176
|
+
await app.state.engine_result.dispose()
|
|
177
|
+
logger.info("Automations service shut down")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_cors_origins() -> list[str]:
|
|
181
|
+
"""Build the list of allowed CORS origins from settings."""
|
|
182
|
+
settings = get_settings()
|
|
183
|
+
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
|
184
|
+
if not origins:
|
|
185
|
+
origins = [settings.openhands_api_base_url]
|
|
186
|
+
return origins
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _create_app() -> FastAPI:
|
|
190
|
+
"""Create and configure the FastAPI application."""
|
|
191
|
+
# Serve OpenAPI docs under the base path so they're accessible when the app
|
|
192
|
+
# is mounted at /api/automation (e.g., /api/automation/docs).
|
|
193
|
+
base_path = get_settings().base_path
|
|
194
|
+
return FastAPI(
|
|
195
|
+
title="OpenHands Automations Service",
|
|
196
|
+
description=(
|
|
197
|
+
"Scheduled and event-driven automation execution for OpenHands Cloud"
|
|
198
|
+
),
|
|
199
|
+
version="0.1.0",
|
|
200
|
+
lifespan=lifespan,
|
|
201
|
+
docs_url=f"{base_path}/docs",
|
|
202
|
+
openapi_url=f"{base_path}/openapi.json",
|
|
203
|
+
redoc_url=f"{base_path}/redoc",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
app = _create_app()
|
|
208
|
+
|
|
209
|
+
app.add_middleware(
|
|
210
|
+
CORSMiddleware,
|
|
211
|
+
allow_origins=_build_cors_origins(),
|
|
212
|
+
allow_credentials=True,
|
|
213
|
+
allow_methods=["*"],
|
|
214
|
+
allow_headers=["*"],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
_base_path = get_settings().base_path
|
|
218
|
+
|
|
219
|
+
# Include specific routers BEFORE main router to avoid route conflict.
|
|
220
|
+
# The main router has /v1/{automation_id} which would match any /v1/<path>
|
|
221
|
+
# and fail UUID validation.
|
|
222
|
+
app.include_router(uploads_router, prefix=_base_path)
|
|
223
|
+
app.include_router(preset_router, prefix=_base_path)
|
|
224
|
+
app.include_router(event_router, prefix=_base_path)
|
|
225
|
+
app.include_router(webhook_router, prefix=_base_path)
|
|
226
|
+
app.include_router(router, prefix=_base_path)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Static /health and /ready paths are a convenience for k8s probes — the fixed
|
|
230
|
+
# path requires less templating. Base-path endpoints are still available for
|
|
231
|
+
# publicly-routed traffic like integration tests.
|
|
232
|
+
@app.get("/health")
|
|
233
|
+
@app.get(f"{_base_path}/health")
|
|
234
|
+
async def health():
|
|
235
|
+
return {"status": "ok"}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.get("/ready")
|
|
239
|
+
@app.get(f"{_base_path}/ready")
|
|
240
|
+
async def readiness():
|
|
241
|
+
"""Readiness probe — checks DB connectivity.
|
|
242
|
+
|
|
243
|
+
Returns 503 when the DB is unreachable so Kubernetes stops routing traffic.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
async with app.state.engine.connect() as conn:
|
|
247
|
+
await conn.execute(text("SELECT 1"))
|
|
248
|
+
return {"status": "ready"}
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.error("Readiness check failed: %s", e, exc_info=True)
|
|
251
|
+
return JSONResponse(
|
|
252
|
+
status_code=503,
|
|
253
|
+
content={"status": "not_ready", "error": "database unavailable"},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Frontend static file hosting (opt-in via AUTOMATION_FRONTEND_DIR)
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
_settings = get_settings()
|
|
261
|
+
_frontend_dir = _settings.frontend_dir
|
|
262
|
+
if _frontend_dir:
|
|
263
|
+
_frontend_path = Path(_frontend_dir)
|
|
264
|
+
if not _frontend_path.is_dir():
|
|
265
|
+
logger.warning(
|
|
266
|
+
"AUTOMATION_FRONTEND_DIR=%s is not a directory — frontend hosting disabled",
|
|
267
|
+
_frontend_dir,
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
_frontend_mount = _settings.frontend_path
|
|
271
|
+
logger.info("Serving frontend from %s at %s", _frontend_dir, _frontend_mount)
|
|
272
|
+
|
|
273
|
+
_index_full_path = str(_frontend_path / "index.html")
|
|
274
|
+
_index_stat = os.stat(_index_full_path)
|
|
275
|
+
|
|
276
|
+
class _SPAStaticFiles(StaticFiles):
|
|
277
|
+
"""StaticFiles that falls back to index.html for SPA client routes."""
|
|
278
|
+
|
|
279
|
+
def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
|
|
280
|
+
full_path, stat_result = super().lookup_path(path)
|
|
281
|
+
if stat_result is None:
|
|
282
|
+
# Unknown path → serve index.html for client-side routing
|
|
283
|
+
return _index_full_path, _index_stat
|
|
284
|
+
return full_path, stat_result
|
|
285
|
+
|
|
286
|
+
def file_response(self, full_path, stat_result, scope, status_code=200):
|
|
287
|
+
response = super().file_response(
|
|
288
|
+
full_path, stat_result, scope, status_code
|
|
289
|
+
)
|
|
290
|
+
# Hashed assets are immutable; everything else (especially
|
|
291
|
+
# index.html) must be revalidated on every request.
|
|
292
|
+
if "/assets/" in str(full_path):
|
|
293
|
+
response.headers["Cache-Control"] = (
|
|
294
|
+
"public, max-age=31536000, immutable"
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
response.headers.setdefault(
|
|
298
|
+
"Cache-Control", "no-cache, must-revalidate"
|
|
299
|
+
)
|
|
300
|
+
return response
|
|
301
|
+
|
|
302
|
+
app.mount(
|
|
303
|
+
_frontend_mount,
|
|
304
|
+
_SPAStaticFiles(directory=_frontend_path, html=True),
|
|
305
|
+
name="frontend",
|
|
306
|
+
)
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Authentication for the automations service API.
|
|
2
|
+
|
|
3
|
+
Supports two authentication methods:
|
|
4
|
+
1. API key: Bearer token in the Authorization header
|
|
5
|
+
2. Cookie: keycloak_auth cookie from the OpenHands web UI
|
|
6
|
+
|
|
7
|
+
Both methods validate against the OpenHands API GET /api/v1/users/me endpoint
|
|
8
|
+
to get the user and organization identity.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import secrets
|
|
14
|
+
import uuid
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from cachetools import TTLCache
|
|
19
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
20
|
+
from pydantic.dataclasses import dataclass
|
|
21
|
+
from tenacity import (
|
|
22
|
+
RetryCallState,
|
|
23
|
+
before_sleep_log,
|
|
24
|
+
retry,
|
|
25
|
+
retry_if_result,
|
|
26
|
+
stop_after_attempt,
|
|
27
|
+
wait_exponential,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from openhands.automation.config import get_config
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("automation.auth")
|
|
34
|
+
|
|
35
|
+
# Auth cache - initialized lazily to use config values
|
|
36
|
+
_auth_cache: TTLCache[str, "AuthenticatedUser"] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_auth_cache() -> TTLCache[str, "AuthenticatedUser"]:
|
|
40
|
+
"""Get or create the auth cache with config-based settings."""
|
|
41
|
+
global _auth_cache
|
|
42
|
+
if _auth_cache is None:
|
|
43
|
+
http_config = get_config().http
|
|
44
|
+
_auth_cache = TTLCache(
|
|
45
|
+
maxsize=http_config.auth_cache_size,
|
|
46
|
+
ttl=http_config.auth_cache_ttl,
|
|
47
|
+
)
|
|
48
|
+
return _auth_cache
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _reset_auth_cache() -> None:
|
|
52
|
+
"""Reset the auth cache so it will be recreated with new config values.
|
|
53
|
+
|
|
54
|
+
Called by clear_config_cache() to ensure tests that change config see
|
|
55
|
+
the new cache settings take effect.
|
|
56
|
+
"""
|
|
57
|
+
global _auth_cache
|
|
58
|
+
_auth_cache = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthMethod(StrEnum):
|
|
62
|
+
"""Authentication method used for the request."""
|
|
63
|
+
|
|
64
|
+
API_KEY = "api_key"
|
|
65
|
+
COOKIE = "cookie"
|
|
66
|
+
LOCAL_API_KEY = "local_api_key"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def create_http_client() -> httpx.AsyncClient:
|
|
70
|
+
"""Create a new httpx client for auth requests."""
|
|
71
|
+
return httpx.AsyncClient(timeout=get_config().http.http_timeout)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_http_client(request: Request) -> httpx.AsyncClient:
|
|
75
|
+
"""FastAPI dependency to get the shared httpx client from app.state.
|
|
76
|
+
|
|
77
|
+
The client is created during app startup and stored in app.state.http_client.
|
|
78
|
+
This enables proper dependency injection and makes testing easier.
|
|
79
|
+
"""
|
|
80
|
+
client: httpx.AsyncClient | None = getattr(request.app.state, "http_client", None)
|
|
81
|
+
if client is None or client.is_closed:
|
|
82
|
+
raise HTTPException(
|
|
83
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
84
|
+
detail="HTTP client not initialized",
|
|
85
|
+
)
|
|
86
|
+
return client
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class AuthenticatedUser:
|
|
91
|
+
user_id: uuid.UUID
|
|
92
|
+
org_id: uuid.UUID
|
|
93
|
+
email: str
|
|
94
|
+
role: str
|
|
95
|
+
permissions: list[str]
|
|
96
|
+
auth_method: AuthMethod
|
|
97
|
+
api_key: str | None = None # Set when auth_method == API_KEY
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def clear_auth_cache() -> None:
|
|
101
|
+
"""Clear all cached authentication data. Useful for testing."""
|
|
102
|
+
_get_auth_cache().clear()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _credential_cache_key(credential: str) -> str:
|
|
106
|
+
"""Hash a credential for use as a cache key (never store raw credential)."""
|
|
107
|
+
return hashlib.sha256(credential.encode()).hexdigest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_rate_limited(response: httpx.Response) -> bool:
|
|
111
|
+
"""Check if response is a 429 rate limit response."""
|
|
112
|
+
return response.status_code == 429
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _return_last_response(retry_state: RetryCallState) -> httpx.Response:
|
|
116
|
+
"""Return the last response when retries are exhausted."""
|
|
117
|
+
logger.warning(
|
|
118
|
+
"Rate limit retries exhausted after %d attempts",
|
|
119
|
+
retry_state.attempt_number,
|
|
120
|
+
)
|
|
121
|
+
# Defensive check: outcome should be set by tenacity, but guard against
|
|
122
|
+
# potential library changes or edge cases for type safety
|
|
123
|
+
if retry_state.outcome is None:
|
|
124
|
+
raise RuntimeError("retry_error_callback invoked without outcome")
|
|
125
|
+
return retry_state.outcome.result()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Module-level retry decorator for auth requests.
|
|
129
|
+
# Config is read at import time and frozen for the process lifetime.
|
|
130
|
+
_http_config = get_config().http
|
|
131
|
+
_auth_retry = retry(
|
|
132
|
+
retry=retry_if_result(_is_rate_limited),
|
|
133
|
+
stop=stop_after_attempt(_http_config.auth_max_retries + 1),
|
|
134
|
+
wait=wait_exponential(
|
|
135
|
+
multiplier=_http_config.auth_initial_backoff,
|
|
136
|
+
max=_http_config.auth_max_backoff,
|
|
137
|
+
),
|
|
138
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
139
|
+
retry_error_callback=_return_last_response,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@_auth_retry
|
|
144
|
+
async def _make_auth_request_with_retry(
|
|
145
|
+
client: httpx.AsyncClient,
|
|
146
|
+
url: str,
|
|
147
|
+
headers: dict[str, str],
|
|
148
|
+
) -> httpx.Response:
|
|
149
|
+
"""Make an auth request with exponential backoff retry on 429 responses.
|
|
150
|
+
|
|
151
|
+
Uses tenacity for retry logic with exponential backoff.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
client: The httpx client to use for requests
|
|
155
|
+
url: The URL to request
|
|
156
|
+
headers: Request headers
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
The HTTP response (may still be a 429 if all retries exhausted)
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
httpx.RequestError: If there's a network/connection error
|
|
163
|
+
"""
|
|
164
|
+
return await client.get(url, headers=headers)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_local_user() -> AuthenticatedUser:
|
|
168
|
+
"""Return a default user for local mode authentication.
|
|
169
|
+
|
|
170
|
+
Used when authenticating with local_api_key in self-hosted deployments.
|
|
171
|
+
Provides deterministic user/org IDs for consistent data ownership.
|
|
172
|
+
|
|
173
|
+
Security notes:
|
|
174
|
+
- Uses deterministic UUID5 based on DNS namespace, meaning every self-hosted
|
|
175
|
+
installation gets identical user/org IDs. This ensures consistent data
|
|
176
|
+
ownership tracking across service restarts.
|
|
177
|
+
- If logs or database exports containing these IDs are shared between
|
|
178
|
+
separate installations, data attribution could be ambiguous. For isolated
|
|
179
|
+
deployments (the typical self-hosted case), this is acceptable.
|
|
180
|
+
|
|
181
|
+
Access model:
|
|
182
|
+
- Grants admin role with manage_automations permission, giving full access.
|
|
183
|
+
- Self-hosted deployments typically have full trust in their environment,
|
|
184
|
+
so permissive defaults are appropriate. Read-only or restricted access
|
|
185
|
+
modes could be added later if needed via additional config options.
|
|
186
|
+
"""
|
|
187
|
+
# Use deterministic UUIDs based on namespace (consistent across restarts)
|
|
188
|
+
local_user_id = uuid.uuid5(uuid.NAMESPACE_DNS, "openhands-local-user")
|
|
189
|
+
local_org_id = uuid.uuid5(uuid.NAMESPACE_DNS, "openhands-local-org")
|
|
190
|
+
|
|
191
|
+
return AuthenticatedUser(
|
|
192
|
+
user_id=local_user_id,
|
|
193
|
+
org_id=local_org_id,
|
|
194
|
+
email="local@localhost",
|
|
195
|
+
role="admin",
|
|
196
|
+
permissions=["manage_automations"],
|
|
197
|
+
auth_method=AuthMethod.LOCAL_API_KEY,
|
|
198
|
+
api_key=None,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def require_permission(permission: str):
|
|
203
|
+
"""Factory that returns a FastAPI dependency enforcing a permission.
|
|
204
|
+
|
|
205
|
+
Checks whether the authenticated user has the given permission string
|
|
206
|
+
in their permissions list. Raises HTTP 403 if missing, otherwise
|
|
207
|
+
returns the ``AuthenticatedUser``.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
async def _check(
|
|
211
|
+
user: "AuthenticatedUser" = Depends(authenticate_request),
|
|
212
|
+
) -> "AuthenticatedUser":
|
|
213
|
+
if permission not in user.permissions:
|
|
214
|
+
logger.warning(
|
|
215
|
+
"Permission denied: user %s missing permission %s",
|
|
216
|
+
user.user_id,
|
|
217
|
+
permission,
|
|
218
|
+
)
|
|
219
|
+
raise HTTPException(
|
|
220
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
221
|
+
detail=f"Requires {permission} permission",
|
|
222
|
+
)
|
|
223
|
+
return user
|
|
224
|
+
|
|
225
|
+
return _check
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def authenticate_request(
|
|
229
|
+
request: Request,
|
|
230
|
+
client: httpx.AsyncClient = Depends(get_http_client),
|
|
231
|
+
) -> AuthenticatedUser:
|
|
232
|
+
"""Authenticate the request using API key or keycloak_auth cookie.
|
|
233
|
+
|
|
234
|
+
Authentication modes:
|
|
235
|
+
|
|
236
|
+
**Local mode with local_api_key configured:**
|
|
237
|
+
Only the configured local API key is accepted. SaaS authentication is
|
|
238
|
+
disabled. Matching Bearer tokens are authenticated as a default local
|
|
239
|
+
user without calling the OpenHands API. Non-matching keys are rejected
|
|
240
|
+
immediately.
|
|
241
|
+
|
|
242
|
+
**SaaS mode (local_api_key not configured):**
|
|
243
|
+
Supports API key via Authorization: Bearer header or keycloak_auth cookie.
|
|
244
|
+
Calls the OpenHands API GET /api/v1/users/me to verify credentials and
|
|
245
|
+
get user/org identity. Implements retry with exponential backoff for
|
|
246
|
+
rate limiting. Results are cached in-memory.
|
|
247
|
+
"""
|
|
248
|
+
settings = get_config().service
|
|
249
|
+
|
|
250
|
+
# Determine authentication method (API key takes priority)
|
|
251
|
+
auth_header = request.headers.get("Authorization", "")
|
|
252
|
+
if auth_header.startswith("Bearer "):
|
|
253
|
+
api_key = auth_header.removeprefix("Bearer ").strip()
|
|
254
|
+
if not api_key:
|
|
255
|
+
raise HTTPException(
|
|
256
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
257
|
+
detail="Empty API key",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# In local mode with local_api_key configured, only accept that key
|
|
261
|
+
# (SaaS authentication is disabled when local_api_key is set)
|
|
262
|
+
if settings.is_local_mode and settings.local_api_key:
|
|
263
|
+
# Use constant-time comparison to prevent timing attacks
|
|
264
|
+
if secrets.compare_digest(api_key, settings.local_api_key):
|
|
265
|
+
logger.debug("Authenticated via local API key")
|
|
266
|
+
return _get_local_user()
|
|
267
|
+
# Key doesn't match - reject immediately in local mode
|
|
268
|
+
raise HTTPException(
|
|
269
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
270
|
+
detail="Invalid API key",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
auth_method = AuthMethod.API_KEY
|
|
274
|
+
credential = api_key
|
|
275
|
+
else:
|
|
276
|
+
cookie_value = request.cookies.get("keycloak_auth")
|
|
277
|
+
if cookie_value:
|
|
278
|
+
auth_method = AuthMethod.COOKIE
|
|
279
|
+
credential = cookie_value
|
|
280
|
+
else:
|
|
281
|
+
raise HTTPException(
|
|
282
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
283
|
+
detail="Authentication required: provide Bearer token "
|
|
284
|
+
"or keycloak_auth cookie",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Check cache first
|
|
288
|
+
cache_key = _credential_cache_key(credential)
|
|
289
|
+
auth_cache = _get_auth_cache()
|
|
290
|
+
cached_user = auth_cache.get(cache_key)
|
|
291
|
+
if cached_user is not None:
|
|
292
|
+
logger.debug("Auth cache hit for user %s", cached_user.user_id)
|
|
293
|
+
return cached_user
|
|
294
|
+
|
|
295
|
+
logger.debug("Auth cache miss, validating with OpenHands API")
|
|
296
|
+
|
|
297
|
+
# Build outbound headers based on auth method
|
|
298
|
+
if auth_method == AuthMethod.API_KEY:
|
|
299
|
+
outbound_headers = {"Authorization": f"Bearer {credential}"}
|
|
300
|
+
else:
|
|
301
|
+
outbound_headers = {"Cookie": f"keycloak_auth={credential}"}
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
resp = await _make_auth_request_with_retry(
|
|
305
|
+
client,
|
|
306
|
+
f"{settings.openhands_api_base_url}/api/v1/users/me",
|
|
307
|
+
headers=outbound_headers,
|
|
308
|
+
)
|
|
309
|
+
except httpx.RequestError as e:
|
|
310
|
+
logger.error("Failed to reach OpenHands API for auth: %s", e)
|
|
311
|
+
raise HTTPException(
|
|
312
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
313
|
+
detail="Failed to reach OpenHands API for authentication",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if resp.status_code == 401:
|
|
317
|
+
if auth_method == AuthMethod.API_KEY:
|
|
318
|
+
detail = "Invalid or expired API key"
|
|
319
|
+
else:
|
|
320
|
+
detail = "Invalid or expired session cookie"
|
|
321
|
+
raise HTTPException(
|
|
322
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
323
|
+
detail=detail,
|
|
324
|
+
)
|
|
325
|
+
if resp.status_code == 429:
|
|
326
|
+
raise HTTPException(
|
|
327
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
328
|
+
detail="Rate limited by authentication service",
|
|
329
|
+
)
|
|
330
|
+
if resp.status_code != 200:
|
|
331
|
+
logger.error(
|
|
332
|
+
"Unexpected status from OpenHands /api/v1/users/me: %s",
|
|
333
|
+
resp.status_code,
|
|
334
|
+
)
|
|
335
|
+
raise HTTPException(
|
|
336
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
337
|
+
detail="Unexpected response from OpenHands API",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
data = resp.json()
|
|
341
|
+
user_id_raw = data.get("id")
|
|
342
|
+
org_id_raw = data.get("org_id")
|
|
343
|
+
if not user_id_raw or not org_id_raw:
|
|
344
|
+
raise HTTPException(
|
|
345
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
346
|
+
detail="Could not determine user/org identity from OpenHands API",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
user_uuid = uuid.UUID(str(user_id_raw))
|
|
351
|
+
org_uuid = uuid.UUID(str(org_id_raw))
|
|
352
|
+
except ValueError:
|
|
353
|
+
raise HTTPException(
|
|
354
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
355
|
+
detail="Invalid user_id or org_id format from OpenHands API",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
email = data.get("email", "")
|
|
359
|
+
role = data.get("role", "")
|
|
360
|
+
permissions = data.get("permissions", [])
|
|
361
|
+
|
|
362
|
+
user = AuthenticatedUser(
|
|
363
|
+
user_id=user_uuid,
|
|
364
|
+
org_id=org_uuid,
|
|
365
|
+
email=email,
|
|
366
|
+
role=role,
|
|
367
|
+
permissions=permissions,
|
|
368
|
+
auth_method=auth_method,
|
|
369
|
+
api_key=credential if auth_method == AuthMethod.API_KEY else None,
|
|
370
|
+
)
|
|
371
|
+
auth_cache[cache_key] = user
|
|
372
|
+
return user
|