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.
Files changed (60) hide show
  1. openhands/automation/__init__.py +3 -0
  2. openhands/automation/app.py +306 -0
  3. openhands/automation/auth.py +372 -0
  4. openhands/automation/backends/__init__.py +67 -0
  5. openhands/automation/backends/base.py +145 -0
  6. openhands/automation/backends/cloud.py +302 -0
  7. openhands/automation/backends/local.py +180 -0
  8. openhands/automation/config.py +573 -0
  9. openhands/automation/constants.py +83 -0
  10. openhands/automation/db.py +210 -0
  11. openhands/automation/dispatcher.py +429 -0
  12. openhands/automation/event_router.py +192 -0
  13. openhands/automation/event_schemas/__init__.py +126 -0
  14. openhands/automation/event_schemas/custom.py +108 -0
  15. openhands/automation/event_schemas/detection.py +136 -0
  16. openhands/automation/event_schemas/github.py +483 -0
  17. openhands/automation/exceptions.py +29 -0
  18. openhands/automation/execution.py +568 -0
  19. openhands/automation/filter_eval.py +192 -0
  20. openhands/automation/logger.py +115 -0
  21. openhands/automation/migrations/env.py +152 -0
  22. openhands/automation/migrations/script.py.mako +25 -0
  23. openhands/automation/migrations/versions/001_initial_schema.py +129 -0
  24. openhands/automation/migrations/versions/002_tarball_uploads.py +64 -0
  25. openhands/automation/migrations/versions/003_event_triggers.py +92 -0
  26. openhands/automation/migrations/versions/004_add_prompt_column.py +29 -0
  27. openhands/automation/models.py +312 -0
  28. openhands/automation/preset_router.py +531 -0
  29. openhands/automation/presets/__init__.py +9 -0
  30. openhands/automation/presets/plugin/sdk_main.py +328 -0
  31. openhands/automation/presets/plugin/setup.sh +23 -0
  32. openhands/automation/presets/prompt/sdk_main.py +315 -0
  33. openhands/automation/presets/prompt/setup.sh +23 -0
  34. openhands/automation/router.py +347 -0
  35. openhands/automation/scheduler.py +202 -0
  36. openhands/automation/schemas.py +613 -0
  37. openhands/automation/storage/__init__.py +18 -0
  38. openhands/automation/storage/factory.py +36 -0
  39. openhands/automation/storage/file_store.py +64 -0
  40. openhands/automation/storage/google_cloud.py +195 -0
  41. openhands/automation/storage/local.py +125 -0
  42. openhands/automation/storage/s3.py +340 -0
  43. openhands/automation/trigger_matcher.py +89 -0
  44. openhands/automation/uploads.py +323 -0
  45. openhands/automation/utils/__init__.py +24 -0
  46. openhands/automation/utils/agent_server.py +161 -0
  47. openhands/automation/utils/api_key.py +98 -0
  48. openhands/automation/utils/cron.py +143 -0
  49. openhands/automation/utils/log_context.py +28 -0
  50. openhands/automation/utils/run.py +238 -0
  51. openhands/automation/utils/sandbox.py +212 -0
  52. openhands/automation/utils/tarball_validation.py +170 -0
  53. openhands/automation/utils/time.py +15 -0
  54. openhands/automation/utils/webhook.py +235 -0
  55. openhands/automation/watchdog.py +284 -0
  56. openhands/automation/webhook_router.py +275 -0
  57. openhands_automation-1.0.0a1.dist-info/METADATA +128 -0
  58. openhands_automation-1.0.0a1.dist-info/RECORD +60 -0
  59. openhands_automation-1.0.0a1.dist-info/WHEEL +4 -0
  60. openhands_automation-1.0.0a1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """OpenHands automation service."""
2
+
3
+ __version__ = "0.1.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