Compare commits

...

239 Commits

Author SHA1 Message Date
openhands
9c061d178b refactor(auth): simplify OSS authorization to bare minimum
OSS module (openhands/server/auth/):
- Permission enum only
- Single no-op require_permission() that returns user_id
- No roles, no ROLE_PERMISSIONS, no helper methods

Enterprise module (enterprise/server/auth/authorization.py):
- Imports Permission from OSS module
- Defines RoleName enum and ROLE_PERMISSIONS locally
- Implements all authorization logic: require_permission(),
  require_org_role(), has_permission(), get_role_permissions(), etc.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-17 01:15:08 +00:00
openhands
cc7e002ffe feat(auth): add OSS no-op authorization with SAAS override
- Create openhands/server/auth/ module with no-op authorization
  - Permission and RoleName enums defined in OSS module
  - ROLE_PERMISSIONS mapping defined in OSS module
  - No-op require_permission() and require_org_role() dependencies
  - All authorization checks pass in OSS mode

- Update enterprise authorization to import from OSS module
  - Import Permission, RoleName, ROLE_PERMISSIONS from OSS
  - Import get_role_permissions() helper from OSS
  - Override require_permission() and require_org_role() with real checks
  - Enterprise performs actual permission validation in SAAS mode

This allows the same authorization decorators to be used in both modes:
- OSS mode: All checks pass (no-op)
- SAAS mode: Real permission checks are enforced

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-16 23:44:39 +00:00
openhands
d058b64597 feat(auth): implement permission-based authorization
- Add Permission enum with 15 granular permissions
- Add RoleName enum for owner, admin, member roles
- Add ROLE_PERMISSIONS mapping with correct permission sets per role
- Add require_permission() FastAPI dependency for permission-based access control
- Add helper functions: get_role_permissions(), has_permission()

Permission mapping:
- Member: manage_secrets, manage_mcp, manage_integrations,
  manage_application_settings, manage_api_keys, view_llm_settings
- Admin: All member permissions + edit_llm_settings, view_billing,
  add_credits, invite_user_to_organization, change_user_role:member,
  change_user_role:admin
- Owner: All admin permissions + change_user_role:owner,
  change_organization_name, delete_organization

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-16 23:09:16 +00:00
Chuck Butkus
954c922a98 Merge branch 'main' into feature/role-based-authorization 2026-02-16 14:23:31 -05:00
mamoodi
663ace4b39 Add saas-rel* branch pattern to ghcr-build workflow (#12888)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-16 12:27:37 -05:00
Hiep Le
2d085a6e0a fix(frontend): add auto-scroll when new messages arrive in chat (#12885) 2026-02-16 23:46:14 +07:00
Hiep Le
8b7112abe8 refactor(frontend): hide planning preview component when plan content is empty (#12879) 2026-02-16 18:35:20 +07:00
Hiep Le
34547ba947 fix(backend): enable byor key export after purchasing credits (#12862) 2026-02-16 17:02:06 +07:00
Graham Neubig
5f958ab60d fix: suppress alembic INFO logs before import to prevent Datadog misclassification (#12691)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 14:32:39 -05:00
Hiep Le
d7656bf1c9 refactor(backend): rename user role to member across the system (#12853) 2026-02-13 00:45:47 +07:00
Tim O'Farrell
2bc107564c Support list_files and get_trajectory for nested conversation managers (#12850)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-12 10:39:00 -07:00
Tim O'Farrell
85eb1e1504 Check event types before making expensive API calls in GitHub webhook handler (#12819)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 09:33:59 -07:00
OpenHands Bot
cd235cc8c7 Bump SDK packages to v1.11.4 (#12839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-02-11 10:55:46 -07:00
Graham Neubig
40f52dfabc Use lowercase minimax-m2.5 for consistency (#12840)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 01:29:17 +08:00
Hiep Le
bab7bf85e8 fix(backend): prevent org deletion from setting current_org_id to NULL (#12817) 2026-02-12 00:15:21 +07:00
Hiep Le
c856537f65 refactor(backend): update the patch organization api to support organization name updates (#12834) 2026-02-12 00:08:43 +07:00
Graham Neubig
736f5b2255 Add MiniMax-M2.5 model support (#12835)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-11 16:57:22 +00:00
chuckbutkus
c1d9d11772 Log all exceptions in get_user() when authentication fails (#12836)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-11 11:49:13 -05:00
sp.wack
85244499fe fix(frontend): performance and loading state bugs (#12821) 2026-02-11 15:34:52 +00:00
Hiep Le
c55084e223 fix(backend): read RECAPTCHA_SITE_KEY from environment in V1 web client config (#12830) 2026-02-11 18:59:52 +07:00
Tim O'Farrell
e3bb75deb4 fix(enterprise): use poetry.lock for reproducible dependency builds (#12820)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-11 04:51:12 -07:00
Hiep Le
1948200762 chore: update sdk to the latest version (#12811) 2026-02-11 12:57:08 +07:00
Tim O'Farrell
affe0af361 Add debug logging for sandbox startup health checks (#12814)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-10 07:23:43 -07:00
Hiep Le
f20c956196 feat(backend): implement org member patch api (#12800) 2026-02-10 20:01:24 +07:00
Alexander Grattan
4a089a3a0d fix(docs): update Gray Swan API links and onboarding instructions in security README (#12809) 2026-02-10 10:14:49 +00:00
Hiep Le
aa0b2d0b74 feat(backend): add api for switching between orgs (#12799) 2026-02-10 14:22:52 +07:00
Hiep Le
bef9b80b9d fix(frontend): add missing border radius to conversation loading on first load (#12796) 2026-02-09 21:36:07 +07:00
Graham Neubig
c4a90b1f89 Fix Resend ValidationError by adding email validation (#12511)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-08 09:47:39 -05:00
sp.wack
0d13c57d9f feat(backend): org get me route (#12760)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-07 16:11:25 +07:00
Graham Neubig
b3422f1275 Add PR Review by OpenHands workflow (#12784)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 17:26:16 -05:00
Xingyao Wang
f139a9970b feat: add SANDBOX_STARTUP_GRACE_SECONDS env var for configurable startup timeout (#12741)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-07 06:12:29 +08:00
Jamie Chicago
54d156122c Add automated PR review workflow using OpenHands (#12698)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2026-02-06 19:02:55 +00:00
Tim O'Farrell
ac072bf686 feat(frontend): change alert banner from solid background to border style (#12783)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 18:05:29 +00:00
Hiep Le
a53812c029 feat(backend): develop delete /api/organizations/{orgid}/members/{userid} api (#12734) 2026-02-07 00:50:47 +07:00
Tim O'Farrell
1d1c0925b5 refactor: Move check_byor_export_enabled to OrgService and add tests (PR #12753 followup) (#12782)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 17:03:03 +00:00
Hiep Le
872f41e3c0 feat(backend): implement get /api/organizations/{orgId}/members api (#12735) 2026-02-06 23:47:30 +07:00
Tim O'Farrell
d43ff82534 feat: Add BYOR export flag to org for LLM key access control (#12753)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-06 09:30:12 -07:00
huangkevin-apr
8cd8c011b2 fix(a11y): Add aria-label to Sidebar component (#12728) 2026-02-06 22:32:52 +07:00
Tim O'Farrell
5c68b10983 (Frontend) Migrate to new /api/v1/web-client/config endpoint (#12479)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-06 08:31:40 -07:00
Graham Neubig
a97fad1976 fix: Add PostHog error tracking for V1 AgentErrorEvent and ConversationErrorEvent (#12543)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 09:51:01 -05:00
Graham Neubig
4c3542a91c fix: use appropriate log level for webhook installation results (#12493)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 09:01:37 -05:00
Tim O'Farrell
f460057f58 chore: add deprecation notices to all runtime directory files (#12772)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 05:15:02 -07:00
MkDev11
4fa2ad0f47 fix: add exponential backoff retry for env var export when bash session is busy (#12748)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
2026-02-06 05:07:17 -07:00
Hiep Le
dd8be12809 feat(backend): return is_personal field in OrgResponse (#12777) 2026-02-06 19:01:06 +07:00
Tim O'Farrell
89475095d9 Preload callback processor class to prevent Pydantic Deserialization Error (#12776) 2026-02-06 04:29:28 -07:00
Tim O'Farrell
05d5f8848a Fix V1 GitHub conversations failing to clone repository (#12775)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 03:08:55 -07:00
Hiep Le
ee2885eb0b feat: store plan.md file in appropriate configuration folders (#12713) 2026-02-06 16:09:39 +07:00
Tim O'Farrell
545257f870 Refactor: Add LLM provider utilities and improve API base URL detection (#12766)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-05 14:22:32 -07:00
Hiep Le
b23ab33a01 chore: update sdk to the latest version (#12762) 2026-02-06 00:25:11 +07:00
sp.wack
a9ede73391 fix(backend): resolve missing email and display name for user identity tracking (#12719) 2026-02-05 16:50:33 +00:00
chuckbutkus
634c2439b4 Fix key gen again (#12752)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-05 11:45:10 -05:00
Hiep Le
a1989a40b3 refactor(frontend): remove border and border radius from ConversationLoading (#12756) 2026-02-05 21:50:07 +07:00
Saurya Velagapudi
e38f1283ea feat(recaptcha): add user_id and email to assessment log (#12749)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-04 17:58:44 -08:00
Tim O'Farrell
07eb791735 Remove flaky test_bash_session.py (#12739)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-04 11:55:22 -07:00
Saurya Velagapudi
c355c4819f feat(recaptcha): add assessment name to logging and AssessmentResult (#12744)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-04 09:30:02 -08:00
sp.wack
9d8e4c44cc fix(frontend): fix cross-domain PostHog tracking param names and persist bootstrap IDs across OAuth redirects (#12729) 2026-02-04 16:15:53 +04:00
openhands
c9cf142949 refactor: use Role class for role hierarchy in authorization
- Remove hardcoded OrgRole enum and ROLE_HIERARCHY dictionary
- Import Role class from storage.role module
- Update get_user_org_role() to return Role object instead of string
- Update has_required_role() to compare roles using rank field
- Lower rank = higher position in hierarchy (e.g., rank 1 > rank 2 > rank 3)
- Update require_org_role() to accept role name string and fetch Role from database
- Add error handling for missing role in database

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-04 04:18:15 +00:00
Chuck Butkus
7e0b6a39c1 Merge branch 'main' into feature/role-based-authorization 2026-02-03 22:43:19 -05:00
Hiep Le
25cc55e558 chore: update sdk to the latest version (#12737) 2026-02-04 01:20:13 +07:00
chuckbutkus
0e825c38d7 APP-443: Fix key generation (#12726)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-02-03 17:50:40 +00:00
Graham Neubig
ce04e70b5b fix: BYOR to OpenHands provider switch auth error (#12725)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-03 09:26:56 -07:00
Chuck Butkus
268fa43ba9 Lint fix 2026-02-03 02:05:40 -05:00
openhands
ff14104db4 feat: Add role-based authorization for org-scoped endpoints
Implement role-based authorization using FastAPI dependencies that check
user roles (owner, admin, user) within organizations.

Changes:
- Add enterprise/server/auth/authorization.py with:
  - OrgRole enum for role types
  - Role hierarchy (owner > admin > user)
  - get_user_org_role() to retrieve user's role in an org
  - has_required_role() to check role hierarchy
  - require_org_role() dependency factory
  - Convenience dependencies: require_org_user, require_org_admin, require_org_owner

- Update enterprise/server/routes/orgs.py:
  - GET /{org_id}: require_org_user (any member can view)
  - PATCH /{org_id}: require_org_admin (admin or owner can update)
  - DELETE /{org_id}: require_org_owner (only owner can delete)

- Add comprehensive unit tests for authorization module

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-03 06:59:35 +00:00
Tim O'Farrell
7b0589ad40 Add GitHub and Replicated app slugs to web client config (#12724) 2026-02-02 15:53:52 -07:00
mamoodi
682465a862 Release 1.3 (#12715) 2026-02-02 16:17:01 -05:00
Tim O'Farrell
1bb4c844d4 Fix runtime status error on conversation resume (#12718)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-02 21:04:45 +00:00
Neha Prasad
d6c11fe517 fix selected repo disappearing in pen repository dropdown (#12702)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-02-02 19:07:48 +00:00
Tim O'Farrell
b088d4857e Improve batch_get_app_conversations UUID handling (#12711)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-02 07:25:07 -07:00
Tim O'Farrell
0f05898d55 Deprecate V0 endpoints now handled by agent server (#12710)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-02 07:25:00 -07:00
Hiep Le
d1f0a01a57 feat(frontend): add silent WebSocket recovery for V1 conversations (#12677) 2026-02-02 19:53:33 +07:00
Hiep Le
f5a9d28999 feat(frontend): add shine text effect to Plan Preview during streaming (planning agent) (#12676) 2026-02-02 14:31:31 +07:00
Graham Neubig
afa0417608 Remove evaluation directory and benchmarking dependencies (#12666)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-01 11:39:29 -08:00
chuckbutkus
e688fba761 Fix check for already migrated (#12700) 2026-01-30 19:31:46 -05:00
Tim O'Farrell
d1ec5cbdf6 Fix litellm migration max_budget issue and add logging (#12697)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-30 16:35:31 -07:00
Jamie Chicago
f42625f789 Improve issue templates with best practices and clear expectations (#12632)
Co-authored-by: jamiechicago312 <jamiechicago312@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-01-30 21:27:44 +01:00
Graham Neubig
fe28519677 feat(frontend): add gpt-5.2 and gpt-5.2-codex models, remove gpt-5 models (#12639)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-30 12:15:47 -05:00
Tim O'Farrell
e62ceafa4a Cleaner Logs (#12579)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-30 15:25:36 +00:00
Tim O'Farrell
0b8c69fad2 Fix for issue where stats are not updated for conversations (#12688)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-30 08:12:38 -07:00
Hiep Le
37d9b672a4 fix(frontend): prevent duplicate API calls when sub-conversation becomes ready (planning agent) (#12673) 2026-01-30 15:54:51 +07:00
Hiep Le
c8b867a634 fix(frontend): persist selected agent mode across page refresh (planning agent) (#12672) 2026-01-30 15:39:36 +07:00
Hiep Le
59834beba7 fix(frontend): prevent duplicate sub-conversation creation on page refresh (planning agent) (#12645) 2026-01-30 15:25:41 +07:00
Hiep Le
d2eced9cff feat(frontend): handle the build button for the planning agent (planning agent) (#12644) 2026-01-30 15:25:16 +07:00
Hiep Le
7836136ff8 feat(frontend): disable the build button while the agent is running (planning agent) (#12643) 2026-01-30 15:13:05 +07:00
chuckbutkus
fdb04dfe5d Add GitLab provider check to schedule_gitlab_repo_sync (#12680)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-29 23:56:36 -05:00
Tim O'Farrell
3d4cb89441 fix(frontend): Support V1 conversations in MetricsModal (#12678)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-29 15:03:06 -07:00
Tim O'Farrell
9fb9efd3d2 Refactor LiteLLM key updates to use generic user key API (#12664)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-29 09:53:01 -07:00
Engel Nyst
5511c01c2e chore: clarify draft guidance in PR template (#12670)
Co-authored-by: smolpaws <engel@enyst.org>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2026-01-29 14:17:35 +00:00
Engel Nyst
02825fb5bb Cleanup CLI directory (#12669) 2026-01-29 15:14:50 +01:00
HeyItsChloe
876e773589 chore(frontend): convo tab only renders active/selected tab (#12570)
Co-authored-by: Chloe <chloe@openhands.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-29 16:47:41 +07:00
MkDev11
9e1ae86191 fix: hide Notes from task list when notes are empty (#12668)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
2026-01-29 16:13:23 +07:00
Hiep Le
df47b7b79d feat(frontend): ensure the planner tab opens when the view button is selected (#12621) 2026-01-29 13:12:09 +07:00
chuckbutkus
7d1c105b55 Move settings to dict to after openhands (#12663) 2026-01-28 23:45:01 +00:00
Tim O'Farrell
db6a9e8895 Fix broken key migration by decrypting legacy encrypted keys before LiteLLM update (#12657) 2026-01-28 15:09:50 -07:00
Hiep Le
d76ac44dc3 refactor(frontend): reduce heading text size for plan preview content (#12620) 2026-01-29 00:30:40 +07:00
MkDev11
c483c80a3c feat: add host network support for V1 DockerSandboxService (#12445)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-28 09:55:38 -07:00
chuckbutkus
570ab904f6 Fix UserSettings creation from Org tables (#12635)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-01-28 09:35:05 -07:00
sp.wack
00a74731ae chore: refresh frontend lockfile (#12619) 2026-01-28 13:03:42 +04:00
Tim O'Farrell
102095affb Add downgrade script and methods for reverting user migration (#12629)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: chuckbutkus <chuck@all-hands.dev>
2026-01-27 14:41:34 -07:00
John-Mason P. Shackelford
b6ce45b474 feat(app_server): start conversations with remote plugins via REST API (#12338)
- Add `PluginSpec` model with plugin configuration parameters extending SDK's `PluginSource`
- Extend app-conversations API to accept plugins specification in `AppConversationStartRequest`
- Propagate plugin source, ref, and repo_path to agent server's `StartConversationRequest`
- Include plugin parameters in initial conversation message for agent context

Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-27 16:26:38 -05:00
Tim O'Farrell
11c87caba4 fix(backend): fix callback state not persisting due to dual-session conflict (#12627)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-27 10:02:48 -07:00
Abhay Mishra
b8a608c45e Fix: branch/repo dropdown reset on click (#12501)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-27 16:08:08 +07:00
Tim O'Farrell
8a446787be Migrate SDK to 1.10.0 (#12614) 2026-01-27 04:26:06 +00:00
Tim O'Farrell
353124e171 Bump SDK to 1.10.0 (#12613) 2026-01-27 03:50:30 +00:00
chuckbutkus
e9298c89bd Fix org migration (#12612)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-26 20:55:55 -05:00
Hiep Le
29b77be807 fix: revert get_user_by_id_async to use sync session_maker (#12610) 2026-01-27 04:39:07 +07:00
mamoodi
7094835ef0 Fix Pydantic UnsupportedFieldAttributeWarning in Settings model (#12600)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-26 12:30:43 -05:00
mamoodi
7ad0eec325 Change error to warning if TOS not accepted (#12605) 2026-01-26 12:30:00 -05:00
Hiep Le
31d5081163 feat(frontend): display plan preview content (#12504) 2026-01-26 23:19:57 +07:00
Abhay Mishra
250736cb7a fix(frontend): display ThinkAction thought content in V1 UI (#12597)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-26 14:03:16 +07:00
Tim O'Farrell
a9bd3a70c9 Fix V0 Integrations (#12584) 2026-01-24 16:53:16 +00:00
Hiep Le
d7436a4af4 fix(backend): asyncsession query error in userstore.get_user_by_id_async (#12586)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-24 23:47:04 +07:00
Tim O'Farrell
f327e76be7 Added explicit expired error (#12580) 2026-01-23 12:49:10 -07:00
Hiep Le
52e39e5d12 fix(backend): unable to export conversation (#12577)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-24 01:06:02 +07:00
Graham Neubig
6c5ef256fd fix: pass userId to EmailVerificationModal in login page (#12573)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-23 23:24:25 +07:00
Tim O'Farrell
373ade8554 Fix org billing (#12562) 2026-01-23 08:42:50 -07:00
Hiep Le
9d0a19cf8f fix(backend): ensure conversation events are written back to google cloud (#12571) 2026-01-23 22:13:08 +07:00
Rohit Malhotra
d60dd38d78 fix: preserve query params in returnTo during login redirect (#12567)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-22 21:31:42 -08:00
Hiep Le
d5ee799670 feat(backend): develop patch /api/organizations/{orgid} api (#12470)
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
2026-01-23 01:29:35 +07:00
Hiep Le
b685fd43dd fix(backend): github proxy state compression (#12387)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-01-23 00:39:03 +07:00
Hiep Le
0e04f6fdbe feat(backend): develop delete /api/organizations/{orgid} api (#12471)
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
2026-01-23 00:28:55 +07:00
Hiep Le
9c40929197 feat(backend): develop get /api/organizations/{orgid} api (#12274)
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-01-22 23:55:29 +07:00
Hiep Le
af309e8586 feat(backend): develop get /api/organizations api (#12373)
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
2026-01-22 23:32:42 +07:00
sp.wack
cc5d5c2335 test(frontend): add missing HTTP mocks for conversation history preloading (#12549) 2026-01-22 20:30:27 +04:00
Hiep Le
60e668f4a7 fix(backend): application settings are not updating as expected (#12547) 2026-01-22 21:54:52 +07:00
Hiep Le
743f6256a6 feat(backend): load skills from agent server (#12434) 2026-01-22 20:20:50 +07:00
Mohammed Abdulai
a87b4efd41 feat: preload conversation history before websocket connection (#12488)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2026-01-22 12:41:27 +00:00
Tim O'Farrell
730d9970f5 Migrate SDK to 1.9.1 (#12540) 2026-01-21 16:14:27 -07:00
Tim O'Farrell
2440593431 Simplify app conversation (#12517) 2026-01-21 13:03:28 -07:00
Tim O'Farrell
8c94ddbf1a Bump the SDK to 1.9.0 (#12535) 2026-01-21 13:02:45 -07:00
Tim O'Farrell
af60183f22 Optimize runtime build (#12531) 2026-01-21 19:35:11 +00:00
Timon
40fb69325f fix: use correct agent-server image in docker-compose.yml (#12527) 2026-01-21 12:03:01 -07:00
Rohit Malhotra
f9891a2c0d [Jira]: improve traceability and reliability fixes (#12515)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-21 13:50:41 -05:00
Hiep Le
44f1bb022b fix(frontend): sub-conversation ids are missing when navigating from the conversation panel (#12506) 2026-01-22 00:27:48 +07:00
dependabot[bot]
95c2a0285c chore(deps): bump the version-all group in /frontend with 7 updates (#12524)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 20:47:09 +04:00
Tim O'Farrell
8ea4813936 fix: remove keycloak_auth cookie requirement for webhook secrets endpoint (#12525)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-21 09:17:19 -07:00
Graham Neubig
650bf8c0c0 docs: Update docker-compose files to use V1 environment variables (#12522)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-21 10:22:50 -05:00
Mohammed Abdulai
dd4bb5d362 Improve Browser and App tab empty state guidance (#12418) 2026-01-21 21:09:38 +07:00
Graham Neubig
b3137e7ae8 fix: remove frozen=True from Field() to fix Pydantic warning (#12518)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-21 08:50:49 -05:00
dependabot[bot]
0d740925c5 chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.91.2 to 5.91.3 in /frontend in the eslint group (#12497)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 16:34:44 +04:00
Hiep Le
030ff59c40 feat(backend): develop post /api/organizations api (org project) (#12263)
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
2026-01-21 15:14:27 +07:00
Rohit Malhotra
3bc56740b9 [Jira]: separate signature verification from conversation orchestration (#12478)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-20 16:21:23 -05:00
Hiep Le
87a5762bf2 fix: handle invalid LiteLLM API keys during user migration (#12510) 2026-01-20 13:11:49 -07:00
Rohit Malhotra
5b149927fb [Jira]: fix match conditions for kicking off openhands (#12477)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-20 15:10:03 -05:00
Hiep Le
275bb689e4 fix(backend): sub-conversations can no longer be linked to parent conversations due to recent v1 changes (#12505) 2026-01-20 23:59:30 +07:00
Hiep Le
d595945309 fix(backend): handle existing LiteLLM users and team members during sign-in (#12508) 2026-01-20 23:35:37 +07:00
Hiep Le
e8ba741f28 fix(backend): byor key alias cleanup on 404 (#12491) 2026-01-19 13:12:04 +07:00
Graham Neubig
57929b73ee fix(frontend): show friendly message for budget/credit errors in V1 conversations (#12401)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-19 00:52:44 +00:00
Tim O'Farrell
9fd4e42438 Add CORS origins support to Docker sandbox service for remote browser access (#12489)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-18 08:02:29 -07:00
Tim O'Farrell
0176cd9669 Add LLM model to shared conversation view and remove feature flag (#12476)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-16 21:27:20 -07:00
Rohit Malhotra
2e9b29970e Extend expiration time for CLI device links (#12480) 2026-01-16 19:43:05 -05:00
chuckbutkus
a07fc1510b Add variable to disable duplicate email check (#12474) 2026-01-16 18:04:15 -05:00
dependabot[bot]
78f067acef chore(deps): bump the version-all group in /frontend with 5 updates (#12437)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-17 01:38:24 +04:00
Tim O'Farrell
0d5f97c8c7 Refactor enterprise database.py to use DbSessionInjector (#12446)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-16 14:38:05 -07:00
chuckbutkus
a987387353 Fix asyncio event loop conflict in get_user_by_id (#12475)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-01-16 20:37:04 +00:00
Rohit Malhotra
941b6dd340 Jira: don't lock conversation to ticket (#12472)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-16 14:12:27 -05:00
Hiep Le
23251a2487 fix: cannot run code locally in SaaS mode (#12465) 2026-01-16 08:42:19 -07:00
Tim O'Farrell
6af8301996 Default value for V1 enabled should be true (#12469) 2026-01-16 15:39:22 +00:00
mamoodi
b57ec9eda1 Release 1.2.0 and 1.2.1 (#12440) 2026-01-16 09:33:41 -05:00
Tim O'Farrell
c8594a2eaa (Backend) New Config Endpoint (#12460) 2026-01-15 22:06:39 -07:00
Rohit Malhotra
102715a3c9 (Hotfix): missing provider tokens in v1 resolvers (#12453)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-15 22:22:10 -05:00
chuckbutkus
d5e66b4f3a SAAS: Introducing orgs (phase 1) (#11265)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-01-15 22:03:31 -05:00
Graham Neubig
f5315887ec Fix: Reduce log noise for optional org-level .openhands repositories (#12456)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-15 23:27:34 +00:00
Tim O'Farrell
6e4ac8e2ce [APP-373] Update legacy v0 deprecation notice with version and removal date (#12455)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-15 15:22:38 -07:00
Vedant Madane
efb54fd791 fix(frontend): dismissible & expandable error banner (#12354)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-15 21:18:37 +00:00
sp.wack
53f86955e0 fix(frontend): clear error banner on successful V1 WebSocket events (#12398) 2026-01-16 01:12:55 +04:00
MkDev11
b5a615aa67 fix: preserve HTTP protocol for self-hosted Gitea/Forgejo instances (#12439) 2026-01-15 12:20:07 -05:00
Mohammed Abdulai
3cb76f5cbb fix: clean up conversation localStorage on delete and migration (#12300)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-15 15:28:24 +00:00
Tim O'Farrell
91fb97a13f Migrate the SDK to 1.8.2 (#12438) 2026-01-15 15:18:26 +00:00
Graham Neubig
9af3ee8298 fix: Add WORKER_1 and WORKER_2 env vars to remote sandbox service (#12424)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-15 08:53:04 -05:00
Saurya Velagapudi
169ca5aae9 UV Migration Steps 1.3-1.6: Add project dependencies and generate uv.lock (#12416)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Saurya <saurya@openhands.dev>
2026-01-14 19:32:31 -07:00
Pegasus
3c6edfe14b fix(frontend): Respect HIDE_LLM_SETTINGS flag in settings modal (#12400) 2026-01-14 17:14:32 -06:00
dependabot[bot]
633552a731 chore(deps-dev): bump eslint-plugin-prettier from 5.5.4 to 5.5.5 in /frontend in the eslint group (#12408)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 01:09:36 +04:00
Saurya Velagapudi
3da24da4a0 UV Migration Step 1.1: Add PEP 621 [project] section for UV compatibility (#12414)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-14 20:59:15 +00:00
Tim O'Farrell
f28ab56cc3 fix: require reCAPTCHA token when reCAPTCHA is enabled (#12409)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-14 12:34:09 -07:00
Tim O'Farrell
6ccd42bb29 [APP-369] Fix: Allow public access to shared conversations and events (#12411)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-14 16:47:47 +00:00
Hiep Le
1146ea2274 refactor(frontend): create a feature flag for google recaptcha (#12402) 2026-01-14 19:22:38 +07:00
sp.wack
ff28e13698 hotfix(frontend): Fix auth refetch loading spinner flash (#12396) 2026-01-13 19:08:38 +00:00
Xingyao Wang
9171986dde Consolidate repo guidance into AGENTS.md (#12375)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-13 15:53:14 +00:00
Jatoth Adithya Naik
9d405243b8 Fix tab pin/unpin by aligning localStorage key per conversation- #12287 (#12292)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-13 15:25:08 +00:00
dependabot[bot]
d7218925c4 chore(deps): bump the version-all group in /frontend with 3 updates (#12390)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 19:10:17 +04:00
sp.wack
27c16d6691 chore(frontend): Cross-domain PostHog tracking (#12166) 2026-01-13 18:07:56 +04:00
dependabot[bot]
eabba5c160 chore(deps): bump the version-all group in /frontend with 10 updates (#12379)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 17:54:20 +04:00
Hiep Le
ece7e2dd39 refactor(frontend): update tooltip styling (#12371) 2026-01-13 11:59:40 +07:00
Tim O'Farrell
13762eba7c Add optional sandbox_id parameter to start_sandbox method (#12382)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-12 15:43:24 -07:00
Tim O'Farrell
9cf7d64bfe Guard User Creation with Redis based Lock (#12381)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-12 22:03:42 +00:00
Xingyao Wang
92baebc4bd Remove prod/ prefix from litellm proxy model path (#12200)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-13 04:46:09 +08:00
Hiep Le
3d0aa50450 refactor(frontend): make memory condenser max history size input styling consistent (#12365) 2026-01-12 21:43:06 +07:00
Hiep Le
0e3332d974 fix(frontend): make secrets table responsive when descriptions are long (#12363) 2026-01-12 21:40:09 +07:00
Saurya Velagapudi
3c6d2ff1d6 Add conversation API deprecation notices (#12303)
Co-authored-by: Saurya <saurya@openhands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-01-12 08:26:33 -05:00
Hiep Le
b7b76c7a30 refactor: update the pull request template to add optional demo screenshots and videos (#12367) 2026-01-12 20:20:24 +07:00
dependabot[bot]
17b1c04aa0 chore(deps): bump the version-all group in /frontend with 2 updates (#12340)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-12 15:53:33 +04:00
Hiep Le
624a241bbf refactor: Dockerfile (enterprise) (#12368) 2026-01-12 02:27:44 +07:00
Hiep Le
7862e10f03 chore: update enterprise/Dockerfile (#12355) 2026-01-10 11:00:44 -07:00
Tim O'Farrell
7380039bf6 feat(frontend): improve public share menu behavior (#12345)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-10 08:53:22 -07:00
Hiep Le
d773dd6514 feat: implement reCAPTCHA enterprise risk-based non-interactive (#12288) 2026-01-10 22:04:35 +07:00
Xingyao Wang
175117e8b5 Fix: Prevent Enter key from submitting during IME composition (#12252)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-10 11:34:25 +08:00
Tim O'Farrell
778a1cf609 Fix for critical regression where conversations will not start in OSS (#12347) 2026-01-09 19:29:56 +00:00
OpenHands Bot
c08adc87b4 Bump SDK packages to v1.8.1 (#12343)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-10 02:41:01 +08:00
Graham Neubig
434647e4e4 fix: replace deprecated Pydantic .dict() with .model_dump() (#12339)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-09 12:37:58 -05:00
Tim O'Farrell
849ae13118 Fix regression in EventService search (#12342)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-09 10:00:35 -07:00
Tim O'Farrell
180df8ea20 Remove runtime unit tests (#12331) 2026-01-09 09:06:02 -07:00
sp.wack
17791e5e62 fix: restore agenthub import for agent registration (#12341) 2026-01-09 16:00:06 +00:00
Mengxin Zhu
f3aaebdc33 fix(frontend): preserve path prefix in WebSocket URL for proxy deployments (#12284)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2026-01-09 17:39:02 +04:00
Tim O'Farrell
0e4f0c25d7 Fix merge conflict in DB migrations (#12336) 2026-01-09 05:46:43 +00:00
Hiep Le
d4cf1d4590 fix(frontend): tool titles are not displayed for v1 conversations (#12317) 2026-01-09 10:45:42 +07:00
Graham Neubig
9b50d0cb7d chore(deps): update jinja2, tornado, urllib3 (#12330)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-08 22:05:26 -05:00
Tim O'Farrell
5c411e7fc1 Fix circular import in gitlab_sync.py (#12334)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-08 22:44:18 +00:00
Saurya Velagapudi
6442f772a0 Fix: Parse SANDBOX_VOLUMES env var for agent-server volume mounts (#12327)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-08 20:31:27 +00:00
Rohit Malhotra
5fb431bcc5 feat: Implement Slack V1 integration following GitHub V1 pattern (#11825)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-01-08 13:08:11 -07:00
mamoodi
adfabe7659 docs/styles: Minor updates to some docs and some formatting (#12320) 2026-01-08 12:20:57 -05:00
mamoodi
0ddac3879e Update PR template with note on draft PRs (#12315) 2026-01-08 16:55:16 +00:00
Hiep Le
7398737b06 refactor(frontend): align conversation metrics title to the left in the modal (#12310) 2026-01-08 23:09:26 +07:00
Hiep Le
50d9cbac04 refactor(frontend): reduce gap between icon and text in chat status indicator (#12313) 2026-01-08 23:09:16 +07:00
Sarvatarshan Sankar
a40f7bda21 Fix: Prevent Search API Key from resetting when saving other settings (#12243)
Co-authored-by: Sarvatarshan Sankar <sarvatarshansankar20@Sarvatarshans-MacBook-Air.local>
2026-01-08 19:30:06 +04:00
Pranjal Gupta
39f0e6ed94 perf: eliminate slow chown operations in Docker builds (~41min → seconds) (#12256)
Co-authored-by: Pranjal Gupta <19pran@gmail.com>
2026-01-08 09:29:43 -06:00
Hiep Le
6475aa3487 refactor(frontend): remove auth modal and related tests (#12307) 2026-01-08 21:02:02 +07:00
dependabot[bot]
5dea0d22b4 chore(deps): bump the version-all group across 1 directory with 8 updates (#12298)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 16:34:30 +04:00
sp.wack
a6e8b819ad refactor(frontend): Remove dead 402 error handling code (#12305) 2026-01-08 11:56:27 +00:00
Tim O'Farrell
c97e7082f7 Making sure verify_repo_provider is_optional so log is debug not error (#12302)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-08 05:15:00 +00:00
Tim O'Farrell
cb9e6fde24 Fix Python deprecation warning: use auth=Auth.Token() instead of login_or_token (#12299)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 21:39:36 -07:00
Tim O'Farrell
828837a969 APP-319: No longer logging error when idle sandboxes are stopped (#12296) 2026-01-07 22:55:57 +00:00
Tim O'Farrell
bbdedf8641 Fix unbound variable and read_text() bugs in event services (#12297)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 22:07:23 +00:00
Graham Neubig
11d1e79506 refactor(enterprise): Remove custom Prometheus metrics app (#12253)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 14:49:50 -05:00
Tim O'Farrell
e485c93119 APP-318 Increase LiteLLM HTTP timeout from 5s to 15s (#12290)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 17:40:52 +00:00
Tim O'Farrell
cddf01b4e9 Fix AttributeError in GoogleCloudSharedEventService: use self.bucket instead of erroneous import (#12289)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 17:03:28 +00:00
Abhay Mishra
6086c0b09d feat(frontend): convert AuthModal to dedicated /login page (#12143) (#12181)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-07 16:46:24 +00:00
Jatoth Adithya Naik
15836c4d4b Replace conversation loading spinner with skeleton cards (#12230)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-07 15:57:10 +00:00
Tim O'Farrell
20eb9bd3c5 Use the version from the release tag rather than from main (#12286) 2026-01-07 15:50:57 +00:00
Neha Prasad
ba1770ad89 fix: remove query invalidation to prevent stale (#12238) 2026-01-07 19:20:40 +04:00
dependabot[bot]
4b7ce82f71 chore(deps): bump the version-all group in /frontend with 2 updates (#12277)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-07 13:21:46 +00:00
Hiep Le
5c20724845 fix: db migration (#12282) 2026-01-07 17:29:31 +07:00
Hiep Le
8ddb815a89 refactor(backend): enhance storage and retrieval of blocked domains (#12273) 2026-01-07 13:41:43 +07:00
Engel Nyst
08df955ba7 Clarify OpenHands naming (replace “OSS” wording in docs and backend) (#12235)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 07:24:27 +01:00
Tim O'Farrell
b816d0448b Fix GoogleCloudSharedEventServiceInjector missing bucket_name field and add tests (#12280)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-06 22:27:00 -07:00
Tim O'Farrell
fa974f8106 APP-307 Add Google Cloud Storage-based EventService implementation (#12264)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-06 15:52:07 -07:00
Xingyao Wang
af5c22700c Bump condenser defaults: max_size 120->240 (#12267)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 05:43:23 +08:00
Ryanakml
1907ebeaa8 feat: add chat message skeletons and improve routing stability (#12223)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-07 00:29:08 +04:00
Rohit Malhotra
9686ee02f3 V1 GitHub resolver fixes (#12199)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-06 19:33:54 +00:00
HeyItsChloe
d053a3d363 feat(frontend): adding status indicator and unit test (#12111)
Co-authored-by: Chloe <chloe@openhands.com>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2026-01-06 13:01:27 +00:00
1015 changed files with 71847 additions and 89543 deletions

View File

@@ -5,52 +5,113 @@ labels: ['bug']
body:
- type: markdown
attributes:
value: Thank you for taking the time to fill out this bug report. Please provide as much information as possible
to help us understand and address the issue effectively.
value: |
## Thank you for reporting a bug! 🐛
**Please fill out all required fields.** Issues missing critical information (version, installation method, reproduction steps, etc.) will be delayed or closed until complete details are provided.
Clear, detailed reports help us resolve issues faster.
- type: checkboxes
attributes:
label: Is there an existing issue for the same bug? (If one exists, thumbs up or comment on the issue instead).
description: Please check if an issue already exists for the bug you encountered.
label: Is there an existing issue for the same bug?
description: Please search existing issues before creating a new one. If found, react or comment to the duplicate issue instead of making a new one.
options:
- label: I have checked the existing issues.
- label: I have searched existing issues and this is not a duplicate.
required: true
- type: textarea
id: bug-description
attributes:
label: Describe the bug and reproduction steps
description: Provide a description of the issue along with any reproduction steps.
label: Bug Description
description: Clearly describe what went wrong. Be specific and concise.
placeholder: Example - "When I run a Python task, OpenHands crashes after 30 seconds with a connection timeout error."
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Example - "OpenHands should execute the Python script and return results."
validations:
required: false
- type: textarea
id: actual-behavior
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Example - "Connection timed out after 30 seconds, task failed with error code 500."
validations:
required: false
- type: textarea
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Provide clear, step-by-step instructions to reproduce the bug.
placeholder: |
1. Install OpenHands using Docker
2. Configure with Claude 3.5 Sonnet
3. Run command: `openhands run "write a python script"`
4. Wait 30 seconds
5. Error appears
validations:
required: false
- type: dropdown
id: installation
attributes:
label: OpenHands Installation
label: OpenHands Installation Method
description: How are you running OpenHands?
options:
- Docker command in README
- GitHub resolver
- CLI (uv tool install)
- CLI (executable binary)
- CLI (Docker)
- Local GUI (Docker web interface)
- OpenHands Cloud (app.all-hands.dev)
- SDK (Python library)
- Development workflow
- CLI
- app.all-hands.dev
- Other
default: 0
validations:
required: false
- type: input
id: installation-other
attributes:
label: If you selected "Other", please specify
description: Describe your installation method
placeholder: ex. Custom Kubernetes deployment, pip install from source, etc.
- type: input
id: openhands-version
attributes:
label: OpenHands Version
description: What version of OpenHands are you using?
placeholder: ex. 0.9.8, main, etc.
description: What version are you using? Find this in settings or by running `openhands --version`
placeholder: ex. 0.9.8, main, commit hash, etc.
validations:
required: false
- type: checkboxes
id: version-confirmation
attributes:
label: Version Confirmation
description: Bugs on older versions may already be fixed. Please upgrade before submitting.
options:
- label: "I have confirmed this bug exists on the LATEST version of OpenHands"
required: false
- type: input
id: model-name
attributes:
label: Model Name
description: What model are you using?
placeholder: ex. gpt-4o, claude-3-5-sonnet, openrouter/deepseek-r1, etc.
description: Which LLM model are you using?
placeholder: ex. gpt-4o, claude-3-5-sonnet-20241022, openrouter/deepseek-r1, etc.
validations:
required: false
- type: dropdown
id: os
@@ -60,12 +121,46 @@ body:
- MacOS
- Linux
- WSL on Windows
- Windows (Docker Desktop)
- Other
validations:
required: false
- type: input
id: browser
attributes:
label: Browser (if using web UI)
description: |
If applicable, which browser and version?
placeholder: ex. Chrome 131, Firefox 133, Safari 17.2
- type: textarea
id: logs
attributes:
label: Logs and Error Messages
description: |
**Paste relevant logs, error messages, or stack traces.** Use code blocks (```) for formatting.
LLM logs are in `logs/llm/default/`. Include timestamps if errors occurred at a specific time.
placeholder: |
```
Paste error logs here
```
- type: textarea
id: additional-context
attributes:
label: Logs, Errors, Screenshots, and Additional Context
description: Please provide any additional information you think might help. If you want to share the chat history
you can click the thumbs-down (👎) button above the input field and you will get a shareable link
(you can also click thumbs up when things are going well of course!). LLM logs will be stored in the
`logs/llm/default` folder. Please add any additional context about the problem here.
label: Screenshots and Additional Context
description: |
Add screenshots, videos, runtime environment, or other context that helps explain the issue.
💡 **Share conversation history:** In the OpenHands chat UI, click the 👎 or 👍 button (above the message input) to generate a shareable link to your conversation.
placeholder: Drag and drop screenshots here, paste links, or add additional context.
- type: markdown
attributes:
value: |
---
**Note:** Issues with incomplete information may be closed or deprioritized. Maintainers and community members have limited bandwidth and prioritize well-documented bugs that are easier to reproduce and fix. Thank you for your understanding!

View File

@@ -1,17 +0,0 @@
---
name: Feature Request or Enhancement
about: Suggest an idea for an OpenHands feature or enhancement
title: ''
labels: 'enhancement'
assignees: ''
---
**What problem or use case are you trying to solve?**
**Describe the UX or technical implementation you have in mind**
**Additional context**
### If you find this feature request or enhancement useful, make sure to add a 👍 to the issue

View File

@@ -0,0 +1,105 @@
name: Feature Request or Enhancement
description: Suggest a new feature or improvement for OpenHands
title: '[Feature]: '
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
## Thank you for suggesting a feature! 💡
**Please provide detailed information.** Vague or low-effort requests may be closed. Well-documented feature requests with strong community support are more likely to be added to the roadmap.
- type: checkboxes
attributes:
label: Is there an existing feature request for this?
description: Please search existing issues and feature requests before creating a new one. If found, react or comment to the duplicate issue instead of making a new one.
options:
- label: I have searched existing issues and feature requests, and this is not a duplicate.
required: true
- type: textarea
id: problem-statement
attributes:
label: Problem or Use Case
description: What problem are you trying to solve? What use case would this feature enable?
placeholder: |
Example - "As a developer working on large codebases, I need to search across multiple files simultaneously. Currently, I have to search file-by-file which is time-consuming and inefficient."
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: Describe your ideal solution. What should this feature do? How should it work?
placeholder: |
Example - "Add a global search feature that allows searching across all files in the workspace. Results should show file name, line number, and context around matches. Include regex support and filtering options."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds? What are their limitations?
placeholder: Example - "I tried using grep in the terminal, but it's not integrated with the UI and doesn't provide click-to-navigate functionality."
- type: dropdown
id: priority
attributes:
label: Priority / Severity
description: How important is this feature to your workflow?
options:
- "Critical - Blocking my work, no workaround available"
- "High - Significant impact on productivity"
- "Medium - Would improve experience"
- "Low - Nice to have"
default: 2
validations:
required: true
- type: dropdown
id: scope
attributes:
label: Estimated Scope
description: To the best of your knowledge, how complex do you think this feature would be to implement?
options:
- "Small - UI tweak, config option, or minor change"
- "Medium - New feature with moderate complexity"
- "Large - Significant feature requiring architecture changes"
- "Unknown - Not sure about the technical complexity"
default: 3
- type: dropdown
id: feature-area
attributes:
label: Feature Area
description: Which part of OpenHands does this feature relate to? If you select "Other", please specify the area in the Additional Context section below.
options:
- "Agent / AI behavior"
- "User Interface / UX"
- "CLI / Command-line interface"
- "File system / Workspace management"
- "Configuration / Settings"
- "Integrations (GitHub, GitLab, etc.)"
- "Performance / Optimization"
- "Documentation"
- "Other"
validations:
required: true
- type: textarea
id: technical-details
attributes:
label: Technical Implementation Ideas (Optional)
description: If you have technical expertise, share implementation ideas, API suggestions, or relevant technical details.
placeholder: |
Example - "Could use ripgrep library for fast search. Expose results via /api/search endpoint. Frontend can use virtualized list for rendering large result sets."
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context, screenshots, mockups, or examples that help illustrate this feature request.
placeholder: Drag and drop screenshots, mockups, or links here.

View File

@@ -1,10 +1,16 @@
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
## Summary of PR
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
<!-- Summarize what the PR does -->
## Demo Screenshots/Videos
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
## Change Type
<!-- Choose the types that apply to your PR and remove the rest. -->
<!-- Choose the types that apply to your PR -->
- [ ] Bug fix
- [ ] New feature

View File

@@ -9,6 +9,7 @@ on:
push:
branches:
- main
- "saas-rel-*"
tags:
- "*"
pull_request:
@@ -39,8 +40,7 @@ jobs:
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
@@ -149,6 +149,9 @@ jobs:
push: true
tags: ${{ env.DOCKER_TAGS }}
platforms: ${{ env.DOCKER_PLATFORM }}
# Caching directives to boost performance
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }},mode=max
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
provenance: false
@@ -252,151 +255,15 @@ jobs:
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
needs: [ghcr_build_runtime, define-matrix]
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Download runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v6
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
# Forked repos can't push to GHCR, so we need to rebuild using cache
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
with:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Run docker runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flakey tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
# Setting RUN_AS_OPENHANDS to false means use root.
# That should mean SANDBOX_USER_ID is ignored but some tests do not check for RUN_AS_OPENHANDS.
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
name: RT Unit Tests (openhands)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [ghcr_build_runtime, define-matrix]
strategy:
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Download runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v6
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
# Forked repos can't push to GHCR, so we need to rebuild using cache
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
with:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
- name: Run runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge
# Due to this bug: https://github.com/actions/runner/issues/2566, we want to create a job that runs when the
# prerequisites have been cancelled or failed so merging is disallowed, otherwise Github considers "skipped" as "success"
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
runtime_tests_check_fail:
name: All Runtime Tests Passed
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: Some tests failed
run: |
echo "Some runtime tests failed or were cancelled"
exit 1
update_pr_description:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'

View File

@@ -0,0 +1,127 @@
---
name: PR Review by OpenHands
on:
# Use pull_request_target to allow fork PRs to access secrets when triggered by maintainers
# Security: This workflow runs when:
# 1. A new PR is opened (non-draft), OR
# 2. A draft PR is marked as ready for review, OR
# 3. A maintainer adds the 'review-this' label, OR
# 4. A maintainer requests openhands-agent or all-hands-bot as a reviewer
# Only users with write access can add labels or request reviews, ensuring security.
# The PR code is explicitly checked out for review, but secrets are only accessible
# because the workflow runs in the base repository context
pull_request_target:
types: [opened, ready_for_review, labeled, review_requested]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-review:
# Run when one of the following conditions is met:
# 1. A new non-draft PR is opened by a trusted contributor, OR
# 2. A draft PR is converted to ready for review by a trusted contributor, OR
# 3. 'review-this' label is added, OR
# 4. openhands-agent or all-hands-bot is requested as a reviewer
# Note: FIRST_TIME_CONTRIBUTOR PRs require manual trigger via label/reviewer request
if: |
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
github.event.label.name == 'review-this' ||
github.event.requested_reviewer.login == 'openhands-agent' ||
github.event.requested_reviewer.login == 'all-hands-bot'
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
LLM_MODEL: litellm_proxy/claude-sonnet-4-5-20250929
LLM_BASE_URL: https://llm-proxy.app.all-hands.dev
# PR context will be automatically provided by the agent script
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout software-agent-sdk repository
uses: actions/checkout@v5
with:
repository: OpenHands/software-agent-sdk
path: software-agent-sdk
- name: Checkout PR repository
uses: actions/checkout@v5
with:
# When using pull_request_target, explicitly checkout the PR branch
# This ensures we review the actual PR code (including fork PRs)
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
# Security: Don't persist credentials to prevent untrusted PR code from using them
persist-credentials: false
path: pr-repo
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install GitHub CLI
run: |
# Install GitHub CLI for posting review comments
sudo apt-get update
sudo apt-get install -y gh
- name: Install OpenHands dependencies
run: |
# Install OpenHands SDK and tools from local checkout
uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools
- name: Check required configuration
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
if [ -z "$LLM_API_KEY" ]; then
echo "Error: LLM_API_KEY secret is not set."
exit 1
fi
echo "PR Number: $PR_NUMBER"
echo "PR Title: $PR_TITLE"
echo "Repository: $REPO_NAME"
echo "LLM model: $LLM_MODEL"
if [ -n "$LLM_BASE_URL" ]; then
echo "LLM base URL: $LLM_BASE_URL"
fi
- name: Run PR review
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
GITHUB_TOKEN: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
run: |
# Change to the PR repository directory so agent can analyze the code
cd pr-repo
# Run the PR review script from the software-agent-sdk checkout
uv run python ../software-agent-sdk/examples/03_github_workflows/02_pr_review/agent_script.py
- name: Upload logs as artifact
uses: actions/upload-artifact@v5
if: always()
with:
name: openhands-pr-review-logs
path: |
*.log
output/
retention-days: 7

View File

@@ -45,7 +45,7 @@ jobs:
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
"Feel free to join our developer community on [Slack](https://all-hands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
"Feel free to join our developer community on [Slack](https://openhands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
"🙌 Happy hacking! 🙌\n\n" +
"<!-- auto-comment:good-first-issue -->"
});

View File

@@ -150,9 +150,9 @@ Each integration follows a consistent pattern with service classes, storage mode
**Important Notes:**
- Enterprise code is licensed under Polyform Free Trial License (30-day limit)
- The enterprise server extends the OSS server through dynamic imports
- The enterprise server extends the OpenHands server through dynamic imports
- Database changes require careful migration planning in `enterprise/migrations/`
- Always test changes in both OSS and enterprise contexts
- Always test changes in both OpenHands and enterprise contexts
- Use the enterprise-specific Makefile commands for development
**Enterprise Testing Best Practices:**
@@ -166,7 +166,7 @@ Each integration follows a consistent pattern with service classes, storage mode
**Import Patterns:**
- Use relative imports without `enterprise.` prefix in enterprise code
- Example: `from storage.database import session_maker` not `from enterprise.storage.database import session_maker`
- This ensures code works in both OSS and enterprise contexts
- This ensures code works in both OpenHands and enterprise contexts
**Test Structure:**
- Place tests in `enterprise/tests/unit/` following the same structure as the source code

View File

@@ -61,7 +61,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
contact@all-hands.dev.
contact@openhands.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
@@ -115,7 +115,9 @@ community.
### Slack Etiquettes
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Lets work together to build a supportive and welcoming community!
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all
community members. By following these best practices, we ensure effective communication and collaboration while
minimizing disruptions. Lets work together to build a supportive and welcoming community!
- Communicate respectfully and professionally, avoiding sarcasm or harsh language, and remember that tone can be difficult to interpret in text.
- Use threads for specific discussions to keep channels organized and easier to follow.
@@ -125,7 +127,10 @@ These Slack etiquette guidelines are designed to foster an inclusive, respectful
- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
- Always adhere to [our standards](https://github.com/OpenHands/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to alert you only when “LLMs” appears in messages.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged.
For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned.
For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to
alert you only when “LLMs” appears in messages.
## Attribution

View File

@@ -1,36 +1,44 @@
# The OpenHands Community
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered world.
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered
world.
## Mission
Its very clear that AI is changing software development. We want the developer community to drive that change organically, through open source.
Its very clear that AI is changing software development. We want the developer community to drive that change
organically, through open source.
So were not just building friendly interfaces for AI-driven development. Were publishing _building blocks_ that empower developers to create new experiences, tailored to your own habits, needs, and imagination.
So were not just building friendly interfaces for AI-driven development. Were publishing _building blocks_ that
empower developers to create new experiences, tailored to your own habits, needs, and imagination.
## Ethos
We have two core values: **high openness** and **high agency**. While we dont expect everyone in the community to embody these values, we want to establish them as norms.
We have two core values: **high openness** and **high agency**. While we dont expect everyone in the community to
embody these values, we want to establish them as norms.
### High Openness
We welcome anyone and everyone into our community by default. You dont have to be a software developer to help us build. You dont have to be pro-AI to help us learn.
We welcome anyone and everyone into our community by default. You dont have to be a software developer to help us
build. You dont have to be pro-AI to help us learn.
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the fruits of our work, but the whole process of growing it.
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the
fruits of our work, but the whole process of growing it.
We welcome thoughtful criticism, whether its a comment on a PR or feedback on the community as a whole.
### High Agency
Everyone should feel empowered to contribute to OpenHands. Whether its by making a PR, hosting an event, sharing feedback, or just asking a question, dont hold back!
Everyone should feel empowered to contribute to OpenHands. Whether its by making a PR, hosting an event, sharing
feedback, or just asking a question, dont hold back!
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly and love building new things.
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly
and love building new things.
Coding, development practices, and communities are changing rapidly. We wont hesitate to change direction and make big bets.
## Relationship to All Hands
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.all-hands.dev/).
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.openhands.dev/).
All Hands was founded by three of the first major contributors to OpenHands:
@@ -38,8 +46,13 @@ All Hands was founded by three of the first major contributors to OpenHands:
- Graham Neubig, a CMU Professor who rallied the academic community around OpenHands
- Robert Brennan, a software engineer who architected the user-facing features of OpenHands
All Hands is an important part of the OpenHands ecosystem. Weve raised over $20M--mainly to hire developers and researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
All Hands is an important part of the OpenHands ecosystem. Weve raised over $20M--mainly to hire developers and
researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we promise to navigate that conflict thoughtfully and transparently.
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility
to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we
promise to navigate that conflict thoughtfully and transparently.
At some point, we may transfer custody of OpenHands to an open source foundation. But for now, the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the “benevolent” part, please: fork us.
At some point, we may transfer custody of OpenHands to an open source foundation. But for now,
the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the
“benevolent” part, please: fork us.

View File

@@ -6,27 +6,31 @@ Thanks for your interest in contributing to OpenHands! We welcome and appreciate
To understand the codebase, please refer to the README in each module:
- [frontend](./frontend/README.md)
- [evaluation](./evaluation/README.md)
- [openhands](./openhands/README.md)
- [agenthub](./openhands/agenthub/README.md)
- [server](./openhands/server/README.md)
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
## Setting up Your Development Environment
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells you how to set up a development workflow.
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
you how to set up a development workflow.
## How Can I Contribute?
There are many ways that you can contribute:
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.all-hands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
## What Can I Build?
Here are a few ways you can help improve the codebase.
#### UI/UX
We're always looking to improve the look and feel of the application. If you've got a small fix
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
@@ -35,6 +39,7 @@ of the application, please open an issue first, or better, join the #dev-ui-ux c
to gather consensus from our design team first.
#### Improving the agent
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
@@ -46,10 +51,12 @@ We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. Y
channel in Slack to learn more.
#### Adding a new agent
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
to help expand the capabilities of OpenHands.
#### Adding a new runtime
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
to do this by default. But there are other ways of creating a sandbox for the agent.
@@ -57,8 +64,11 @@ If you work for a company that provides a cloud-based runtime, you could help us
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
#### Testing
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing test suites.
At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
quality of the project.
## Sending Pull Requests to OpenHands
@@ -66,7 +76,8 @@ You'll need to fork our repository to send us a Pull Request. You can learn more
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
### Pull Request title
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
- `feat`: A new feature
- `fix`: A bug fix
@@ -87,6 +98,7 @@ For example, a PR title could be:
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
### Pull Request description
- If your PR is small (such as a typo fix), you can go brief.
- If it contains a lot of changes, it's better to write more details.
@@ -97,7 +109,9 @@ please include a short message that we can add to our changelog.
### Opening Issues
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that the community has interest/effort for.
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
the community has interest/effort for.
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
@@ -108,11 +122,13 @@ We're generally happy to consider all pull requests with the evaluation process
#### For Small Improvements
Small improvements with few downsides are typically reviewed and approved quickly.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check before getting a review.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
before getting a review.
#### For Core Agent Changes
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are evaluated based on three key metrics:
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
evaluated based on three key metrics:
1. **Accuracy**
2. **Efficiency**

View File

@@ -2,11 +2,13 @@
## Contributors
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have helped make OpenHands possible. We greatly appreciate your dedication and hard work.
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have
helped make OpenHands possible. We greatly appreciate your dedication and hard work.
## Open Source Projects
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the open source community:
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the
open source community:
#### [SWE Agent](https://github.com/princeton-nlp/swe-agent)
- License: MIT License
@@ -20,8 +22,8 @@ OpenHands includes and adapts the following open source projects. We are gratefu
- License: Apache License 2.0
- Description: Adapted in implementing the browsing agent
### Reference Implementations for Evaluation Benchmarks
OpenHands integrates code of the reference implementations for the following agent evaluation benchmarks:
#### [HumanEval](https://github.com/openai/human-eval)
@@ -52,28 +54,44 @@ OpenHands integrates code of the reference implementations for the following age
#### [ProntoQA](https://github.com/asaparov/prontoqa)
- License: Apache License 2.0
## Open Source licenses
### MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
### BSD 3-Clause License
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
### Apache License 2.0
@@ -268,8 +286,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
Copyright [yyyy] [name of copyright owner]
### Non-Open Source Reference Implementations:
#### [MultiPL-E](https://github.com/nuprl/MultiPL-E)

View File

@@ -76,7 +76,7 @@ variables in your terminal. The final configurations are set from highest to low
Environment variables > config.toml variables > default variables
**Note on Alternative Models:**
See [our documentation](https://docs.all-hands.dev/usage/llms) for recommended models.
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
### 4. Running the application
@@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.1-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
## Develop inside Docker container
@@ -195,12 +195,12 @@ Here's a guide to the important documentation files in the repository:
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
- [DOC_STYLE_GUIDE.md](https://github.com/All-Hands-AI/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [/evaluation/README.md](./evaluation/README.md): Documentation for the evaluation framework and benchmarks
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

View File

@@ -3,14 +3,14 @@ These are the procedures and guidelines on how issues are triaged in this repo b
## General
* All issues must be tagged with **enhancement**, **bug** or **troubleshooting/help**.
* Issues may be tagged with what it relates to (**agent quality**, **resolver**, **CLI**, etc.).
* Issues may be tagged with what it relates to (**llm**, **app tab**, **UI/UX**, etc.).
## Severity
* **High**: High visibility issues or affecting many users.
* **Critical**: Affecting all users or potential security issues.
## Difficulty
* Issues with low implementation difficulty may be tagged with **good first issue**.
* Issues good for newcomers may be tagged with **good first issue**.
## Not Enough Information
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
@@ -22,6 +22,6 @@ the issue may be closed as **not planned** (Usually after a week).
* Issues may be broken down into multiple issues if required.
## Stale and Auto Closures
* In order to keep a maintainable backlog, issues that have no activity within 30 days are automatically marked as **Stale**.
* If issues marked as **Stale** continue to have no activity for 7 more days, they will automatically be closed as not planned.
* In order to keep a maintainable backlog, issues that have no activity within 40 days are automatically marked as **Stale**.
* If issues marked as **Stale** continue to have no activity for 10 more days, they will automatically be closed as not planned.
* Issues may be reopened by maintainers if deemed important.

View File

@@ -54,7 +54,7 @@ The experience will be familiar to anyone who has used Devin or Jules.
### OpenHands Cloud
This is a deployment of OpenHands GUI, running on hosted infrastructure.
You can try it with a free $10 credit by [signing in with your GitHub account](https://app.all-hands.dev).
You can try it with a free $10 credit by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
OpenHands Cloud comes with source-available features and integrations:
- Integrations with Slack, Jira, and Linear

View File

@@ -440,12 +440,6 @@ type = "noop"
#temperature = 0.1
#max_input_tokens = 1024
#################################### Eval ####################################
# Configuration for the evaluation, please refer to the specific evaluation
# plugin for the available options
##############################################################################
########################### Kubernetes #######################################
# Kubernetes configuration when using the Kubernetes runtime
##############################################################################

View File

@@ -12,7 +12,8 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.1-nikolaik}
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/runtime}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.2-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -10,6 +10,15 @@ repos:
args: ["--allow-multiple-documents"]
- id: debug-statements
- repo: local
hooks:
- id: warn-appmode-oss
name: "Warn on AppMode.OSS in backend (use AppMode.OPENHANDS)"
language: system
entry: bash -lc 'if rg -n "\\bAppMode\\.OSS\\b" openhands tests/unit; then echo "Found AppMode.OSS usage. Prefer AppMode.OPENHANDS."; exit 1; fi'
pass_filenames: false
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.5.1
hooks:

View File

@@ -7,7 +7,8 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.1-nikolaik}
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-31536c8-python}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -23,12 +23,23 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Python packages with security fixes
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
# Update packages with known CVE fixes
pip install --upgrade \
"mcp>=1.10.0" \
"pillow>=11.3.0"
# Install poetry and export before importing current code.
RUN /app/.venv/bin/pip install poetry poetry-plugin-export
# Install Python dependencies from poetry.lock for reproducible builds
# Copy lock files first for better Docker layer caching
COPY --chown=openhands:openhands enterprise/pyproject.toml enterprise/poetry.lock /tmp/enterprise/
RUN cd /tmp/enterprise && \
# Export only main dependencies with hashes for supply chain security
/app/.venv/bin/poetry export --only main -o requirements.txt && \
# Remove the local path dependency (openhands-ai is already in base image)
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
# Install pinned dependencies from lock file
/app/.venv/bin/pip install -r requirements.txt && \
# Cleanup - return to /app before removing /tmp/enterprise
cd /app && \
rm -rf /tmp/enterprise && \
/app/.venv/bin/pip uninstall -y poetry poetry-plugin-export
WORKDIR /app
COPY --chown=openhands:openhands --chmod=770 enterprise .

View File

@@ -2,7 +2,7 @@ BACKEND_HOST ?= "127.0.0.1"
BACKEND_PORT = 3000
BACKEND_HOST_PORT = "$(BACKEND_HOST):$(BACKEND_PORT)"
FRONTEND_PORT = 3001
OPENHANDS_PATH ?= "../../OpenHands"
OPENHANDS_PATH ?= ".."
OPENHANDS := $(OPENHANDS_PATH)
OPENHANDS_FRONTEND_PATH = $(OPENHANDS)/frontend/build

View File

@@ -1,6 +1,6 @@
# OpenHands Enterprise Server
> [!WARNING]
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.all-hands.dev/contact).
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.openhands.dev/contact).
> [!WARNING]
> This is a work in progress and may contain bugs, incomplete features, or breaking changes.
@@ -10,13 +10,13 @@ This directory contains the enterprise server used by [OpenHands Cloud](https://
You may also want to check out the MIT-licensed [OpenHands](https://github.com/OpenHands/OpenHands)
## Extension of OpenHands (OSS)
## Extension of OpenHands
The code in `/enterprise` directory builds on top of open source (OSS) code, extending its functionality. The enterprise code is entangled with the OSS code in two ways
The code in `/enterprise` builds on top of OpenHands (MIT-licensed), extending its functionality. The enterprise code is entangled with OpenHands in two ways:
- Enterprise stacks on top of OSS. For example, the middleware in enterprise is stacked right on top of the middlewares in OSS. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
- Enterprise stacks on top of OpenHands. For example, the middleware in enterprise is stacked right on top of the middlewares in OpenHands. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
- Enterprise overrides the implementation in OSS (only one is present at a time). For example, the server config SaasServerConfig which overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) on OSS. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
- Enterprise overrides the implementation in OpenHands (only one is present at a time). For example, the server config SaasServerConfig overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) in OpenHands. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
Key areas that change on `SAAS` are
@@ -26,11 +26,11 @@ Key areas that change on `SAAS` are
### Authentication
| Aspect | OSS | Enterprise |
| Aspect | OpenHands | Enterprise |
| ------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The Github app provides a short-lived access token and refresh token |
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The GitHub app provides a short-lived access token and refresh token |
| **Token Storage** | PAT is stored in **Settings** | Token is stored in **GithubTokenManager** (a file store in our backend) |
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during oauth, so subsequent requests with the cookie can be considered authenticated |
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during OAuth, so subsequent requests with the cookie can be considered authenticated |
Note that in the future, authentication will happen via keycloak. All modifications for authentication will happen in enterprise.
@@ -38,7 +38,7 @@ Note that in the future, authentication will happen via keycloak. All modificati
The github service is responsible for interacting with Github APIs. As a consequence, it uses the user's token and refreshes it if need be
| Aspect | OSS | Enterprise |
| Aspect | OpenHands | Enterprise |
| ------------------------- | -------------------------------------- | ---------------------------------------------- |
| **Class used** | `GitHubService` | `SaaSGitHubService` |
| **Token used** | User's PAT fetched from `Settings` | User's token fetched from `GitHubTokenManager` |
@@ -50,7 +50,7 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
## User ID vs User Token
- On OSS, the entire APP revolves around the Github token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
Note that introducing Github User ID on OSS, for instance, will cause large breakages.
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python
"""
This script can be removed once orgs is established - probably after Feb 15 2026
Downgrade script for migrated users.
This script identifies users who have been migrated (already_migrated=True)
and reverts them back to the pre-migration state.
Usage:
# Dry run - just list the users that would be downgraded
python downgrade_migrated_users.py --dry-run
# Downgrade a specific user by their keycloak_user_id
python downgrade_migrated_users.py --user-id <user_id>
# Downgrade all migrated users (with confirmation)
python downgrade_migrated_users.py --all
# Downgrade all migrated users without confirmation (dangerous!)
python downgrade_migrated_users.py --all --no-confirm
"""
import argparse
import asyncio
import sys
# Add the enterprise directory to the path
sys.path.insert(0, '/workspace/project/OpenHands/enterprise')
from server.logger import logger
from sqlalchemy import select, text
from storage.database import session_maker
from storage.user_settings import UserSettings
from storage.user_store import UserStore
def get_migrated_users() -> list[str]:
"""Get list of keycloak_user_ids for users who have been migrated.
This includes:
1. Users with already_migrated=True in user_settings (migrated users)
2. Users in the 'user' table who don't have a user_settings entry (new sign-ups)
"""
with session_maker() as session:
# Get users from user_settings with already_migrated=True
migrated_result = session.execute(
select(UserSettings.keycloak_user_id).where(
UserSettings.already_migrated.is_(True)
)
)
migrated_users = {row[0] for row in migrated_result.fetchall() if row[0]}
# Get users from the 'user' table (new sign-ups won't have user_settings)
# These are users who signed up after the migration was deployed
new_signup_result = session.execute(
text("""
SELECT CAST(u.id AS VARCHAR)
FROM "user" u
WHERE NOT EXISTS (
SELECT 1 FROM user_settings us
WHERE us.keycloak_user_id = CAST(u.id AS VARCHAR)
)
""")
)
new_signups = {row[0] for row in new_signup_result.fetchall() if row[0]}
# Combine both sets
all_users = migrated_users | new_signups
return list(all_users)
async def downgrade_user(user_id: str) -> bool:
"""Downgrade a single user.
Args:
user_id: The keycloak_user_id to downgrade
Returns:
True if successful, False otherwise
"""
try:
result = await UserStore.downgrade_user(user_id)
if result:
print(f'✓ Successfully downgraded user: {user_id}')
return True
else:
print(f'✗ Failed to downgrade user: {user_id}')
return False
except Exception as e:
print(f'✗ Error downgrading user {user_id}: {e}')
logger.exception(
'downgrade_script:error',
extra={'user_id': user_id, 'error': str(e)},
)
return False
async def main():
parser = argparse.ArgumentParser(
description='Downgrade migrated users back to pre-migration state'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Just list users that would be downgraded, without making changes',
)
parser.add_argument(
'--user-id',
type=str,
help='Downgrade a specific user by keycloak_user_id',
)
parser.add_argument(
'--all',
action='store_true',
help='Downgrade all migrated users',
)
parser.add_argument(
'--no-confirm',
action='store_true',
help='Skip confirmation prompt (use with caution!)',
)
args = parser.parse_args()
# Get list of migrated users
migrated_users = get_migrated_users()
print(f'\nFound {len(migrated_users)} migrated user(s).')
if args.dry_run:
print('\n--- DRY RUN MODE ---')
print('The following users would be downgraded:')
for user_id in migrated_users:
print(f' - {user_id}')
print('\nNo changes were made.')
return
if args.user_id:
# Downgrade a specific user
if args.user_id not in migrated_users:
print(f'\nUser {args.user_id} is not in the migrated users list.')
print('Either the user was not migrated, or the user_id is incorrect.')
return
print(f'\nDowngrading user: {args.user_id}')
if not args.no_confirm:
confirm = input('Are you sure? (yes/no): ')
if confirm.lower() != 'yes':
print('Cancelled.')
return
success = await downgrade_user(args.user_id)
if success:
print('\nDowngrade completed successfully.')
else:
print('\nDowngrade failed. Check logs for details.')
sys.exit(1)
elif args.all:
# Downgrade all migrated users
if not migrated_users:
print('\nNo migrated users to downgrade.')
return
print(f'\n⚠️ About to downgrade {len(migrated_users)} user(s).')
if not args.no_confirm:
print('\nThis will:')
print(' - Revert LiteLLM team/user budget settings')
print(' - Delete organization entries')
print(' - Delete user entries in the new schema')
print(' - Reset the already_migrated flag')
print('\nUsers to downgrade:')
for user_id in migrated_users[:10]: # Show first 10
print(f' - {user_id}')
if len(migrated_users) > 10:
print(f' ... and {len(migrated_users) - 10} more')
confirm = input('\nType "yes" to proceed: ')
if confirm.lower() != 'yes':
print('Cancelled.')
return
print('\nStarting downgrade...\n')
success_count = 0
fail_count = 0
for user_id in migrated_users:
success = await downgrade_user(user_id)
if success:
success_count += 1
else:
fail_count += 1
print('\n--- Summary ---')
print(f'Successful: {success_count}')
print(f'Failed: {fail_count}')
if fail_count > 0:
sys.exit(1)
else:
parser.print_help()
print('\nPlease specify --dry-run, --user-id, or --all')
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -2,7 +2,7 @@
You have a few options here, which are expanded on below:
- A simple local development setup, with live reloading for both OSS and this repo
- A simple local development setup, with live reloading for both OpenHands and this repo
- A more complex setup that includes Redis
- An even more complex setup that includes GitHub events
@@ -26,7 +26,7 @@ Before starting, make sure you have the following tools installed:
## Option 1: Simple local development
This option will allow you to modify the both the OSS code and the code in this repo,
This option will allow you to modify both the OpenHands code and the code in this repo,
and see the changes in real-time.
This option works best for most scenarios. The only thing it's missing is
@@ -105,9 +105,9 @@ export REDIS_PORT=6379
(see above)
### 2. Build OSS Openhands
### 2. Build OpenHands
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
Develop on [Openhands](https://github.com/OpenHands/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
```
docker build -f containers/app/Dockerfile -t openhands .
@@ -155,7 +155,7 @@ Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-
### Local Debugging with VSCode
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of the OSS OpenHands project running.
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of OpenHands running.
#### Redis
@@ -201,8 +201,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
@@ -235,8 +235,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",

View File

@@ -26,12 +26,14 @@ from integrations.utils import (
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.auth_error import ExpiredError
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.integrations.service_types import AuthenticationError
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
@@ -143,11 +145,7 @@ class GithubManager(Manager):
).get('body', ''):
return False
if GithubFactory.is_eligible_for_conversation_starter(
message
) and self._user_has_write_access_to_repo(installation_id, repo_name, username):
await GithubFactory.trigger_conversation_starter(message)
# Check event types before making expensive API calls (e.g., _user_has_write_access_to_repo)
if not (
GithubFactory.is_labeled_issue(message)
or GithubFactory.is_issue_comment(message)
@@ -157,8 +155,17 @@ class GithubManager(Manager):
return False
logger.info(f'[GitHub] Checking permissions for {username} in {repo_name}')
user_has_write_access = self._user_has_write_access_to_repo(
installation_id, repo_name, username
)
return self._user_has_write_access_to_repo(installation_id, repo_name, username)
if (
GithubFactory.is_eligible_for_conversation_starter(message)
and user_has_write_access
):
await GithubFactory.trigger_conversation_starter(message)
return user_has_write_access
async def receive_message(self, message: Message):
self._confirm_incoming_source_type(message)
@@ -310,7 +317,7 @@ class GithubManager(Manager):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
if not github_view.v1:
if not github_view.v1_enabled:
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
@@ -347,7 +354,7 @@ class GithubManager(Manager):
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
except (AuthenticationError, ExpiredError, SessionExpiredError) as e:
logger.warning(
f'[GitHub] Session expired for user {user_info.username}: {str(e)}'
)

View File

@@ -1,6 +1,6 @@
import asyncio
from integrations.utils import store_repositories_in_db
from integrations.store_repo_utils import store_repositories_in_db
from pydantic import SecretStr
from server.auth.token_manager import TokenManager

View File

@@ -1,11 +1,12 @@
import logging
import os
from typing import Any
from uuid import UUID
import httpx
from github import Auth, Github, GithubIntegration
from integrations.utils import CONVERSATION_URL, get_summary_instruction
from pydantic import Field
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
@@ -20,8 +21,6 @@ from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
get_conversation_url,
get_prompt_template,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
@@ -34,7 +33,6 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
github_view_data: dict[str, Any] = Field(default_factory=dict)
should_request_summary: bool = Field(default=True)
should_extract: bool = Field(default=True)
inline_pr_comment: bool = Field(default=False)
async def __call__(
@@ -64,7 +62,12 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
self.should_request_summary = False
try:
_logger.info(f'[GitHub V1] Requesting summary {conversation_id}')
summary = await self._request_summary(conversation_id)
_logger.info(
f'[GitHub V1] Posting summary {conversation_id}',
extra={'summary': summary},
)
await self._post_summary_to_github(summary)
return EventCallbackResult(
@@ -82,12 +85,12 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
# Check if we have installation ID and credentials before posting
if (
self.github_view_data.get('installation_id')
and os.getenv('GITHUB_APP_CLIENT_ID')
and os.getenv('GITHUB_APP_PRIVATE_KEY')
and GITHUB_APP_CLIENT_ID
and GITHUB_APP_PRIVATE_KEY
):
await self._post_summary_to_github(
f'OpenHands encountered an error: **{str(e)}**.\n\n'
f'[See the conversation]({get_conversation_url().format(conversation_id)})'
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
'for more information.'
)
except Exception as post_error:
@@ -115,16 +118,11 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
f'Missing installation ID for GitHub payload: {self.github_view_data}'
)
github_app_client_id = os.getenv('GITHUB_APP_CLIENT_ID', '').strip()
github_app_private_key = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace(
'\\n', '\n'
)
if not github_app_client_id or not github_app_private_key:
if not GITHUB_APP_CLIENT_ID or not GITHUB_APP_PRIVATE_KEY:
raise ValueError('GitHub App credentials are not configured')
github_integration = GithubIntegration(
auth=Auth.AppAuth(github_app_client_id, github_app_private_key),
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY),
)
token_data = github_integration.get_access_token(installation_id)
return token_data.token
@@ -140,7 +138,7 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
issue_number = self.github_view_data['issue_number']
if self.inline_pr_comment:
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(full_repo_name)
pr = repo.get_pull(issue_number)
pr.create_review_comment_reply(
@@ -148,7 +146,7 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
)
return
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(full_repo_name)
issue = repo.get_issue(number=issue_number)
issue.create_comment(summary)
@@ -274,16 +272,16 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
app_conversation_info.sandbox_id,
)
assert sandbox.session_api_key is not None, (
f'No session API key for sandbox: {sandbox.id}'
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_prompt_template('summary_prompt.j2')
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(

View File

@@ -17,6 +17,7 @@ from integrations.utils import (
HOST,
HOST_URL,
get_oh_labels,
get_user_v1_enabled_setting,
has_exact_mention,
)
from jinja2 import Environment
@@ -24,9 +25,9 @@ from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.database import session_maker
from storage.org_store import OrgStore
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
@@ -55,6 +56,10 @@ from openhands.utils.async_utils import call_sync_from_async
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
async def is_v1_enabled_for_github_resolver(user_id: str) -> bool:
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITHUB_RESOLVER
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
"""Get the user's proactive conversation setting.
@@ -73,51 +78,17 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
if not user_id:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.enable_proactive_conversation_starters is None:
# Check global setting first - if disabled globally, return False
if not ENABLE_PROACTIVE_CONVERSATION_STARTERS:
return False
return settings.enable_proactive_conversation_starters
def _get_setting():
org = OrgStore.get_current_org_from_keycloak_user_id(user_id)
if not org:
return False
return bool(org.enable_proactive_conversation_starters)
async def get_user_v1_enabled_setting(user_id: str) -> bool:
"""Get the user's V1 conversation API setting.
Args:
user_id: The keycloak user ID
Returns:
True if V1 conversations are enabled for this user, False otherwise
Note:
This function checks both the global environment variable kill switch AND
the user's individual setting. Both must be true for the function to return true.
"""
# Check the global environment variable first
if not ENABLE_V1_GITHUB_RESOLVER:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.v1_enabled is None:
return False
return settings.v1_enabled
return await call_sync_from_async(_get_setting)
# =================================================
@@ -140,7 +111,10 @@ class GithubIssue(ResolverViewInterface):
title: str
description: str
previous_comments: list[Comment]
v1: bool
v1_enabled: bool
def _get_branch_name(self) -> str | None:
return getattr(self, 'branch_name', None)
async def _load_resolver_context(self):
github_service = GithubServiceImpl(
@@ -175,6 +149,7 @@ class GithubIssue(ResolverViewInterface):
issue_body=self.description,
previous_comments=self.previous_comments,
)
return user_instructions, conversation_instructions
async def _get_user_secrets(self):
@@ -188,26 +163,32 @@ class GithubIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
self.v1_enabled = await is_v1_enabled_for_github_resolver(
self.user_info.keycloak_user_id
)
if v1_enabled:
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
if self.v1_enabled:
# Create dummy conversationm metadata
# Don't save to conversation store
# V1 conversations are stored in a separate table
self.conversation_id = uuid4().hex
return ConversationMetadata(
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
conversation_id=self.conversation_id,
selected_repository=self.full_repo_name,
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=None,
selected_branch=self._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
@@ -218,25 +199,18 @@ class GithubIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
try:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
return
except Exception as e:
logger.warning(f'Error checking V1 settings, falling back to V0: {e}')
# Use existing V0 conversation service
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
else:
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
@@ -294,6 +268,7 @@ class GithubIssue(ResolverViewInterface):
system_message_suffix=conversation_instructions,
initial_message=initial_message,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITHUB,
title=f'GitHub Issue #{self.issue_number}: {self.title}',
trigger=ConversationTrigger.RESOLVER,
@@ -318,11 +293,9 @@ class GithubIssue(ResolverViewInterface):
f'Failed to start V1 conversation: {task.detail}'
)
self.v1 = True
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
from integrations.github.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
@@ -354,7 +327,6 @@ class GithubIssueComment(GithubIssue):
conversation_instructions_template = jinja_env.get_template(
'issue_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
issue_number=self.issue_number,
issue_title=self.title,
@@ -390,31 +362,6 @@ class GithubPRComment(GithubIssueComment):
return user_instructions, conversation_instructions
async def initialize_new_conversation(self) -> ConversationMetadata:
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
# Create dummy conversationm metadata
# Don't save to conversation store
# V1 conversations are stored in a separate table
return ConversationMetadata(
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self.branch_name,
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
@dataclass
class GithubInlinePRComment(GithubPRComment):
@@ -449,7 +396,6 @@ class GithubInlinePRComment(GithubPRComment):
conversation_instructions_template = jinja_env.get_template(
'pr_update_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
pr_number=self.issue_number,
pr_title=self.title,
@@ -464,7 +410,7 @@ class GithubInlinePRComment(GithubPRComment):
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
from integrations.github.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
@@ -830,7 +776,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
v1_enabled=False,
)
elif GithubFactory.is_issue_comment(message):
@@ -856,7 +802,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
v1_enabled=False,
)
elif GithubFactory.is_pr_comment(message):
@@ -898,7 +844,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
v1_enabled=False,
)
elif GithubFactory.is_inline_pr_comment(message):
@@ -932,7 +878,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
v1_enabled=False,
)
else:

View File

@@ -1,7 +1,7 @@
import asyncio
from integrations.store_repo_utils import store_repositories_in_db
from integrations.types import GitLabResourceType
from integrations.utils import store_repositories_in_db
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus

View File

@@ -167,17 +167,15 @@ async def install_webhook_on_resource(
scopes=SCOPES,
)
logger.info(
'Creating new webhook',
extra={
'webhook_id': webhook_id,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
log_extra = {
'webhook_id': webhook_id,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
}
if status == WebhookStatus.RATE_LIMITED:
logger.warning('Rate limited while creating webhook', extra=log_extra)
raise BreakLoopException()
if webhook_id:
@@ -191,9 +189,8 @@ async def install_webhook_on_resource(
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
},
)
logger.info(
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
)
logger.info('Created new webhook', extra=log_extra)
else:
logger.error('Failed to create webhook', extra=log_extra)
return webhook_id, status

View File

@@ -1,22 +1,37 @@
import hashlib
import hmac
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
"""Jira integration manager.
This module orchestrates the processing of Jira webhook events:
1. Parse webhook payload (via JiraPayloadParser)
2. Validate workspace
3. Authenticate user
4. Create view with repository selection (via JiraFactory)
5. Start conversation job
The manager delegates payload parsing to JiraPayloadParser and view creation
to JiraFactory, keeping the orchestration logic clean and traceable.
"""
import httpx
from fastapi import Request
from integrations.jira.jira_types import JiraViewInterface
from integrations.jira.jira_view import (
JiraExistingConversationView,
JiraFactory,
JiraNewConversationView,
from integrations.jira.jira_payload import (
JiraPayloadError,
JiraPayloadParser,
JiraPayloadSkipped,
JiraPayloadSuccess,
JiraWebhookPayload,
)
from integrations.jira.jira_types import (
JiraViewInterface,
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
from integrations.manager import Manager
from integrations.models import JobContext, Message
from integrations.models import Message
from integrations.utils import (
HOST,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_oh_labels,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
@@ -28,9 +43,6 @@ from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
@@ -41,303 +53,211 @@ from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
# Get OH labels for this environment
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
class JiraManager(Manager):
"""Manager for processing Jira webhook events.
This class orchestrates the flow from webhook receipt to conversation creation,
delegating parsing to JiraPayloadParser and view creation to JiraFactory.
"""
def __init__(self, token_manager: TokenManager):
self.token_manager = token_manager
self.integration_store = JiraIntegrationStore.get_instance()
self.jinja_env = Environment(
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'jira')
)
self.payload_parser = JiraPayloadParser(
oh_label=OH_LABEL,
inline_oh_label=INLINE_OH_LABEL,
)
async def authenticate_user(
self, jira_user_id: str, workspace_id: int
async def receive_message(self, message: Message):
"""Process incoming Jira webhook message.
Flow:
1. Parse webhook payload
2. Validate workspace exists and is active
3. Authenticate user
4. Create view (includes fetching issue details and selecting repository)
5. Start job
Each step has clear logging for traceability.
"""
raw_payload = message.message.get('payload', {})
# Step 1: Parse webhook payload
logger.info(
'[Jira] Received webhook',
extra={'raw_payload': raw_payload},
)
parse_result = self.payload_parser.parse(raw_payload)
if isinstance(parse_result, JiraPayloadSkipped):
logger.info(
'[Jira] Webhook skipped', extra={'reason': parse_result.skip_reason}
)
return
if isinstance(parse_result, JiraPayloadError):
logger.warning(
'[Jira] Webhook parse failed', extra={'error': parse_result.error}
)
return
payload = parse_result.payload
logger.info(
'[Jira] Processing webhook',
extra={
'event_type': payload.event_type.value,
'issue_key': payload.issue_key,
'user_email': payload.user_email,
},
)
# Step 2: Validate workspace
workspace = await self._get_active_workspace(payload)
if not workspace:
return
# Step 3: Authenticate user
jira_user, saas_user_auth = await self._authenticate_user(payload, workspace)
if not jira_user or not saas_user_auth:
return
# Step 4: Create view (includes issue details fetch and repo selection)
decrypted_api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
try:
view = await JiraFactory.create_view(
payload=payload,
workspace=workspace,
user=jira_user,
user_auth=saas_user_auth,
decrypted_api_key=decrypted_api_key,
)
except RepositoryNotFoundError as e:
logger.warning(
'[Jira] Repository not found',
extra={'issue_key': payload.issue_key, 'error': str(e)},
)
await self._send_error_from_payload(payload, workspace, str(e))
return
except StartingConvoException as e:
logger.warning(
'[Jira] View creation failed',
extra={'issue_key': payload.issue_key, 'error': str(e)},
)
await self._send_error_from_payload(payload, workspace, str(e))
return
except Exception as e:
logger.error(
'[Jira] Unexpected error creating view',
extra={'issue_key': payload.issue_key, 'error': str(e)},
exc_info=True,
)
await self._send_error_from_payload(
payload,
workspace,
'Failed to initialize conversation. Please try again.',
)
return
# Step 5: Start job
await self.start_job(view)
async def _get_active_workspace(
self, payload: JiraWebhookPayload
) -> JiraWorkspace | None:
"""Validate and return the workspace for the webhook.
Returns None if:
- Workspace not found
- Workspace is inactive
- Request is from service account (to prevent recursion)
"""
workspace = await self.integration_store.get_workspace_by_name(
payload.workspace_name
)
if not workspace:
logger.warning(
'[Jira] Workspace not found',
extra={'workspace_name': payload.workspace_name},
)
# Can't send error without workspace credentials
return None
# Prevent recursive triggers from service account
if payload.user_email == workspace.svc_acc_email:
logger.debug(
'[Jira] Ignoring service account trigger',
extra={'workspace_name': payload.workspace_name},
)
return None
if workspace.status != 'active':
logger.warning(
'[Jira] Workspace inactive',
extra={'workspace_id': workspace.id, 'status': workspace.status},
)
await self._send_error_from_payload(
payload, workspace, 'Jira integration is not active for your workspace.'
)
return None
return workspace
async def _authenticate_user(
self, payload: JiraWebhookPayload, workspace: JiraWorkspace
) -> tuple[JiraUser | None, UserAuth | None]:
"""Authenticate Jira user and get their OpenHands user auth."""
# Find active Jira user by Keycloak user ID and workspace ID
"""Authenticate the Jira user and get OpenHands auth."""
jira_user = await self.integration_store.get_active_user(
jira_user_id, workspace_id
payload.account_id, workspace.id
)
if not jira_user:
logger.warning(
f'[Jira] No active Jira user found for {jira_user_id} in workspace {workspace_id}'
'[Jira] User not found or inactive',
extra={
'account_id': payload.account_id,
'user_email': payload.user_email,
'workspace_id': workspace.id,
},
)
await self._send_error_from_payload(
payload,
workspace,
f'User {payload.user_email} is not authenticated or active in the Jira integration.',
)
return None, None
saas_user_auth = await get_user_auth_from_keycloak_id(
jira_user.keycloak_user_id
)
if not saas_user_auth:
logger.warning(
'[Jira] Failed to get OpenHands auth',
extra={
'keycloak_user_id': jira_user.keycloak_user_id,
'user_email': payload.user_email,
},
)
await self._send_error_from_payload(
payload,
workspace,
f'User {payload.user_email} is not authenticated with OpenHands.',
)
return None, None
return jira_user, saas_user_auth
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
"""Get repositories that the user has access to."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return []
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
repos: list[Repository] = await client.get_repositories(
'pushed', server_config.app_mode, None, None, None, None
)
return repos
async def validate_request(
self, request: Request
) -> Tuple[bool, Optional[str], Optional[Dict]]:
"""Verify Jira webhook signature."""
signature_header = request.headers.get('x-hub-signature')
signature = signature_header.split('=')[1] if signature_header else None
body = await request.body()
payload = await request.json()
workspace_name = ''
if payload.get('webhookEvent') == 'comment_created':
selfUrl = payload.get('comment', {}).get('author', {}).get('self')
elif payload.get('webhookEvent') == 'jira:issue_updated':
selfUrl = payload.get('user', {}).get('self')
else:
workspace_name = ''
parsedUrl = urlparse(selfUrl)
if parsedUrl.hostname:
workspace_name = parsedUrl.hostname
if not workspace_name:
logger.warning('[Jira] No workspace name found in webhook payload')
return False, None, None
if not signature:
logger.warning('[Jira] No signature found in webhook headers')
return False, None, None
workspace = await self.integration_store.get_workspace_by_name(workspace_name)
if not workspace:
logger.warning('[Jira] Could not identify workspace for webhook')
return False, None, None
if workspace.status != 'active':
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
return False, None, None
webhook_secret = self.token_manager.decrypt_text(workspace.webhook_secret)
digest = hmac.new(webhook_secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, digest):
logger.info('[Jira] Webhook signature verified successfully')
return True, signature, payload
return False, None, None
def parse_webhook(self, payload: Dict) -> JobContext | None:
event_type = payload.get('webhookEvent')
if event_type == 'comment_created':
comment_data = payload.get('comment', {})
comment = comment_data.get('body', '')
if '@openhands' not in comment:
return None
issue_data = payload.get('issue', {})
issue_id = issue_data.get('id')
issue_key = issue_data.get('key')
base_api_url = issue_data.get('self', '').split('/rest/')[0]
user_data = comment_data.get('author', {})
user_email = user_data.get('emailAddress')
display_name = user_data.get('displayName')
account_id = user_data.get('accountId')
elif event_type == 'jira:issue_updated':
changelog = payload.get('changelog', {})
items = changelog.get('items', [])
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
if 'openhands' not in labels:
return None
issue_data = payload.get('issue', {})
issue_id = issue_data.get('id')
issue_key = issue_data.get('key')
base_api_url = issue_data.get('self', '').split('/rest/')[0]
user_data = payload.get('user', {})
user_email = user_data.get('emailAddress')
display_name = user_data.get('displayName')
account_id = user_data.get('accountId')
comment = ''
else:
return None
workspace_name = ''
parsedUrl = urlparse(base_api_url)
if parsedUrl.hostname:
workspace_name = parsedUrl.hostname
if not all(
[
issue_id,
issue_key,
user_email,
display_name,
account_id,
workspace_name,
base_api_url,
]
):
return None
return JobContext(
issue_id=issue_id,
issue_key=issue_key,
user_msg=comment,
user_email=user_email,
display_name=display_name,
platform_user_id=account_id,
workspace_name=workspace_name,
base_api_url=base_api_url,
)
async def receive_message(self, message: Message):
"""Process incoming Jira webhook message."""
payload = message.message.get('payload', {})
job_context = self.parse_webhook(payload)
if not job_context:
logger.info('[Jira] Webhook does not match trigger conditions')
return
# Get workspace by user email domain
workspace = await self.integration_store.get_workspace_by_name(
job_context.workspace_name
)
if not workspace:
logger.warning(
f'[Jira] No workspace found for email domain: {job_context.user_email}'
)
await self._send_error_comment(
job_context,
'Your workspace is not configured with Jira integration.',
None,
)
return
# Prevent any recursive triggers from the service account
if job_context.user_email == workspace.svc_acc_email:
return
if workspace.status != 'active':
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
await self._send_error_comment(
job_context,
'Jira integration is not active for your workspace.',
workspace,
)
return
# Authenticate user
jira_user, saas_user_auth = await self.authenticate_user(
job_context.platform_user_id, workspace.id
)
if not jira_user or not saas_user_auth:
logger.warning(
f'[Jira] User authentication failed for {job_context.user_email}'
)
await self._send_error_comment(
job_context,
f'User {job_context.user_email} is not authenticated or active in the Jira integration.',
workspace,
)
return
# Get issue details
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
issue_title, issue_description = await self.get_issue_details(
job_context, workspace.jira_cloud_id, workspace.svc_acc_email, api_key
)
job_context.issue_title = issue_title
job_context.issue_description = issue_description
except Exception as e:
logger.error(f'[Jira] Failed to get issue context: {str(e)}')
await self._send_error_comment(
job_context,
'Failed to retrieve issue details. Please check the issue key and try again.',
workspace,
)
return
try:
# Create Jira view
jira_view = await JiraFactory.create_jira_view_from_payload(
job_context,
saas_user_auth,
jira_user,
workspace,
)
except Exception as e:
logger.error(f'[Jira] Failed to create jira view: {str(e)}', exc_info=True)
await self._send_error_comment(
job_context,
'Failed to initialize conversation. Please try again.',
workspace,
)
return
if not await self.is_job_requested(message, jira_view):
return
await self.start_job(jira_view)
async def is_job_requested(
self, message: Message, jira_view: JiraViewInterface
) -> bool:
"""
Check if a job is requested and handle repository selection.
"""
if isinstance(jira_view, JiraExistingConversationView):
return True
try:
# Get user repositories
user_repos: list[Repository] = await self._get_repositories(
jira_view.saas_user_auth
)
target_str = f'{jira_view.job_context.issue_description}\n{jira_view.job_context.user_msg}'
# Try to infer repository from issue description
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
if match:
# Found exact repository match
jira_view.selected_repo = repos[0].full_name
logger.info(f'[Jira] Inferred repository: {repos[0].full_name}')
return True
else:
# No clear match - send repository selection comment
await self._send_repo_selection_comment(jira_view)
return False
except Exception as e:
logger.error(f'[Jira] Error in is_job_requested: {str(e)}')
return False
async def start_job(self, jira_view: JiraViewInterface):
async def start_job(self, view: JiraViewInterface):
"""Start a Jira job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_callback_processor import (
@@ -345,101 +265,79 @@ class JiraManager(Manager):
)
try:
user_info: JiraUser = jira_view.jira_user
logger.info(
f'[Jira] Starting job for user {user_info.keycloak_user_id} '
f'issue {jira_view.job_context.issue_key}',
'[Jira] Starting job',
extra={
'issue_key': view.payload.issue_key,
'user_id': view.jira_user.keycloak_user_id,
'selected_repo': view.selected_repo,
},
)
# Create conversation
conversation_id = await jira_view.create_or_update_conversation(
self.jinja_env
)
conversation_id = await view.create_or_update_conversation(self.jinja_env)
logger.info(
f'[Jira] Created/Updated conversation {conversation_id} for issue {jira_view.job_context.issue_key}'
'[Jira] Conversation created',
extra={
'conversation_id': conversation_id,
'issue_key': view.payload.issue_key,
},
)
# Register callback processor for updates
if isinstance(jira_view, JiraNewConversationView):
if isinstance(view, JiraNewConversationView):
processor = JiraCallbackProcessor(
issue_key=jira_view.job_context.issue_key,
workspace_name=jira_view.jira_workspace.name,
issue_key=view.payload.issue_key,
workspace_name=view.jira_workspace.name,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Jira] Created callback processor for conversation {conversation_id}'
'[Jira] Callback processor registered',
extra={'conversation_id': conversation_id},
)
# Send initial response
msg_info = jira_view.get_response_msg()
# Send success response
msg_info = view.get_response_msg()
except MissingSettingsError as e:
logger.warning(f'[Jira] Missing settings error: {str(e)}')
logger.warning(
'[Jira] Missing settings error',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
except LLMAuthenticationError as e:
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
logger.warning(
'[Jira] LLM authentication error',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(f'[Jira] Session expired: {str(e)}')
logger.warning(
'[Jira] Session expired',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = get_session_expired_message()
except StartingConvoException as e:
logger.warning(
'[Jira] Conversation start failed',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = str(e)
except Exception as e:
logger.error(
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
'[Jira] Unexpected error starting job',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
exc_info=True,
)
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
# Send response comment
try:
api_key = self.token_manager.decrypt_text(
jira_view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg_info),
issue_key=jira_view.job_context.issue_key,
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(f'[Jira] Failed to send response message: {str(e)}')
async def get_issue_details(
self,
job_context: JobContext,
jira_cloud_id: str,
svc_acc_email: str,
svc_acc_api_key: str,
) -> Tuple[str, str]:
url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key))
response.raise_for_status()
issue_payload = response.json()
if not issue_payload:
raise ValueError(f'Issue with key {job_context.issue_key} not found.')
title = issue_payload.get('fields', {}).get('summary', '')
description = issue_payload.get('fields', {}).get('description', '')
if not title:
raise ValueError(
f'Issue with key {job_context.issue_key} does not have a title.'
)
if not description:
raise ValueError(
f'Issue with key {job_context.issue_key} does not have a description.'
)
return title, description
await self._send_comment(view, msg_info)
async def send_message(
self,
@@ -449,6 +347,7 @@ class JiraManager(Manager):
svc_acc_email: str,
svc_acc_api_key: str,
):
"""Send a comment to a Jira issue."""
url = (
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
@@ -460,54 +359,53 @@ class JiraManager(Manager):
response.raise_for_status()
return response.json()
async def _send_error_comment(
self,
job_context: JobContext,
error_msg: str,
workspace: JiraWorkspace | None,
):
"""Send error comment to Jira issue."""
if not workspace:
logger.error('[Jira] Cannot send error comment - no workspace available')
return
async def _send_comment(self, view: JiraViewInterface, msg: str):
"""Send a comment using credentials from the view."""
try:
api_key = self.token_manager.decrypt_text(
view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg),
issue_key=view.payload.issue_key,
jira_cloud_id=view.jira_workspace.jira_cloud_id,
svc_acc_email=view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(
'[Jira] Failed to send comment',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
async def _send_error_from_payload(
self,
payload: JiraWebhookPayload,
workspace: JiraWorkspace,
error_msg: str,
):
"""Send error comment before view is created (using payload directly)."""
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(
self.create_outgoing_message(msg=error_msg),
issue_key=job_context.issue_key,
issue_key=payload.issue_key,
jira_cloud_id=workspace.jira_cloud_id,
svc_acc_email=workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(f'[Jira] Failed to send error comment: {str(e)}')
async def _send_repo_selection_comment(self, jira_view: JiraViewInterface):
"""Send a comment with repository options for the user to choose."""
try:
comment_msg = (
'I need to know which repository to work with. '
'Please add it to your issue description or send a followup comment.'
)
api_key = self.token_manager.decrypt_text(
jira_view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=comment_msg),
issue_key=jira_view.job_context.issue_key,
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
logger.info(
f'[Jira] Sent repository selection comment for issue {jira_view.job_context.issue_key}'
)
except Exception as e:
logger.error(
f'[Jira] Failed to send repository selection comment: {str(e)}'
'[Jira] Failed to send error comment',
extra={'issue_key': payload.issue_key, 'error': str(e)},
)
def get_workspace_name_from_payload(self, payload: dict) -> str | None:
"""Extract workspace name from Jira webhook payload.
This method is used by the route for signature verification.
"""
parse_result = self.payload_parser.parse(payload)
if isinstance(parse_result, JiraPayloadSuccess):
return parse_result.payload.workspace_name
return None

View File

@@ -0,0 +1,267 @@
"""Centralized payload parsing for Jira webhooks.
This module provides a single source of truth for parsing and validating
Jira webhook payloads, replacing scattered parsing logic throughout the codebase.
"""
from dataclasses import dataclass
from enum import Enum
from urllib.parse import urlparse
from openhands.core.logger import openhands_logger as logger
class JiraEventType(Enum):
"""Types of Jira events we handle."""
LABELED_TICKET = 'labeled_ticket'
COMMENT_MENTION = 'comment_mention'
@dataclass(frozen=True)
class JiraWebhookPayload:
"""Normalized, validated representation of a Jira webhook payload.
This immutable dataclass replaces JobContext and provides a single
source of truth for all webhook data. All parsing happens in
JiraPayloadParser, ensuring consistent validation.
"""
event_type: JiraEventType
raw_event: str # Original webhookEvent value
# Issue data
issue_id: str
issue_key: str
# User data
user_email: str
display_name: str
account_id: str
# Workspace data (derived from issue self URL)
workspace_name: str
base_api_url: str
# Event-specific data
comment_body: str = '' # For comment events
@property
def user_msg(self) -> str:
"""Alias for comment_body for backward compatibility."""
return self.comment_body
class JiraPayloadParseError(Exception):
"""Raised when payload parsing fails."""
def __init__(self, reason: str, event_type: str | None = None):
self.reason = reason
self.event_type = event_type
super().__init__(reason)
@dataclass(frozen=True)
class JiraPayloadSuccess:
"""Result when parsing succeeds."""
payload: JiraWebhookPayload
@dataclass(frozen=True)
class JiraPayloadSkipped:
"""Result when event is intentionally skipped."""
skip_reason: str
@dataclass(frozen=True)
class JiraPayloadError:
"""Result when parsing fails due to invalid data."""
error: str
JiraPayloadParseResult = JiraPayloadSuccess | JiraPayloadSkipped | JiraPayloadError
class JiraPayloadParser:
"""Centralized parser for Jira webhook payloads.
This class provides a single entry point for parsing webhooks,
determining event types, and extracting all necessary fields.
Replaces scattered parsing in JiraFactory and JiraManager.
"""
def __init__(self, oh_label: str, inline_oh_label: str):
"""Initialize parser with OpenHands label configuration.
Args:
oh_label: Label that triggers OpenHands (e.g., 'openhands')
inline_oh_label: Mention that triggers OpenHands (e.g., '@openhands')
"""
self.oh_label = oh_label
self.inline_oh_label = inline_oh_label
def parse(self, raw_payload: dict) -> JiraPayloadParseResult:
"""Parse a raw webhook payload into a normalized JiraWebhookPayload.
Args:
raw_payload: The raw webhook payload dict from Jira
Returns:
One of:
- JiraPayloadSuccess: Valid, actionable event with payload
- JiraPayloadSkipped: Event we intentionally don't process
- JiraPayloadError: Malformed payload we expected to process
"""
webhook_event = raw_payload.get('webhookEvent', '')
logger.debug(
'[Jira] Parsing webhook payload', extra={'webhook_event': webhook_event}
)
if webhook_event == 'jira:issue_updated':
return self._parse_label_event(raw_payload, webhook_event)
elif webhook_event == 'comment_created':
return self._parse_comment_event(raw_payload, webhook_event)
else:
return JiraPayloadSkipped(f'Unhandled webhook event type: {webhook_event}')
def _parse_label_event(
self, payload: dict, webhook_event: str
) -> JiraPayloadParseResult:
"""Parse an issue_updated event for label changes."""
changelog = payload.get('changelog', {})
items = changelog.get('items', [])
# Extract labels that were added
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
if self.oh_label not in labels:
return JiraPayloadSkipped(
f"Label event does not contain '{self.oh_label}' label"
)
# For label events, user data comes from 'user' field
user_data = payload.get('user', {})
return self._extract_and_validate(
payload=payload,
user_data=user_data,
event_type=JiraEventType.LABELED_TICKET,
webhook_event=webhook_event,
comment_body='',
)
def _parse_comment_event(
self, payload: dict, webhook_event: str
) -> JiraPayloadParseResult:
"""Parse a comment_created event."""
comment_data = payload.get('comment', {})
comment_body = comment_data.get('body', '')
if not self._has_mention(comment_body):
return JiraPayloadSkipped(
f"Comment does not mention '{self.inline_oh_label}'"
)
# For comment events, user data comes from 'comment.author'
user_data = comment_data.get('author', {})
return self._extract_and_validate(
payload=payload,
user_data=user_data,
event_type=JiraEventType.COMMENT_MENTION,
webhook_event=webhook_event,
comment_body=comment_body,
)
def _has_mention(self, text: str) -> bool:
"""Check if text contains an exact mention of OpenHands."""
from integrations.utils import has_exact_mention
return has_exact_mention(text, self.inline_oh_label)
def _extract_and_validate(
self,
payload: dict,
user_data: dict,
event_type: JiraEventType,
webhook_event: str,
comment_body: str,
) -> JiraPayloadParseResult:
"""Extract common fields and validate required data is present."""
issue_data = payload.get('issue', {})
# Extract all fields with empty string defaults (makes them str type)
issue_id = issue_data.get('id', '')
issue_key = issue_data.get('key', '')
user_email = user_data.get('emailAddress', '')
display_name = user_data.get('displayName', '')
account_id = user_data.get('accountId', '')
base_api_url, workspace_name = self._extract_workspace_from_url(
issue_data.get('self', '')
)
# Validate required fields
missing: list[str] = []
if not issue_id:
missing.append('issue.id')
if not issue_key:
missing.append('issue.key')
if not user_email:
missing.append('user.emailAddress')
if not display_name:
missing.append('user.displayName')
if not account_id:
missing.append('user.accountId')
if not workspace_name:
missing.append('workspace_name (derived from issue.self)')
if not base_api_url:
missing.append('base_api_url (derived from issue.self)')
if missing:
return JiraPayloadError(f"Missing required fields: {', '.join(missing)}")
return JiraPayloadSuccess(
JiraWebhookPayload(
event_type=event_type,
raw_event=webhook_event,
issue_id=issue_id,
issue_key=issue_key,
user_email=user_email,
display_name=display_name,
account_id=account_id,
workspace_name=workspace_name,
base_api_url=base_api_url,
comment_body=comment_body,
)
)
def _extract_workspace_from_url(self, self_url: str) -> tuple[str, str]:
"""Extract base API URL and workspace name from issue self URL.
Args:
self_url: The 'self' URL from the issue data
Returns:
Tuple of (base_api_url, workspace_name)
"""
if not self_url:
return '', ''
# Extract base URL (everything before /rest/)
if '/rest/' in self_url:
base_api_url = self_url.split('/rest/')[0]
else:
parsed = urlparse(self_url)
base_api_url = f'{parsed.scheme}://{parsed.netloc}'
# Extract workspace name (hostname)
parsed = urlparse(base_api_url)
workspace_name = parsed.hostname or ''
return base_api_url, workspace_name

View File

@@ -1,26 +1,42 @@
from abc import ABC, abstractmethod
"""Type definitions and interfaces for Jira integration."""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from integrations.models import JobContext
from jinja2 import Environment
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.server.user_auth.user_auth import UserAuth
if TYPE_CHECKING:
from integrations.jira.jira_payload import JiraWebhookPayload
class JiraViewInterface(ABC):
"""Interface for Jira views that handle different types of Jira interactions."""
"""Interface for Jira views that handle different types of Jira interactions.
job_context: JobContext
Views hold the webhook payload directly rather than duplicating fields,
and fetch issue details lazily when needed.
"""
# Core data - view holds these references
payload: 'JiraWebhookPayload'
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
# Mutable state set during processing
selected_repo: str | None
conversation_id: str
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get initial instructions for the conversation."""
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch and cache issue title and description from Jira API.
Returns:
Tuple of (issue_title, issue_description)
"""
pass
@abstractmethod
@@ -35,6 +51,21 @@ class JiraViewInterface(ABC):
class StartingConvoException(Exception):
"""Exception raised when starting a conversation fails."""
"""Exception raised when starting a conversation fails.
This provides user-friendly error messages that can be sent back to Jira.
"""
pass
class RepositoryNotFoundError(Exception):
"""Raised when a repository cannot be determined from the issue.
This is a separate error domain from StartingConvoException - it represents
a precondition failure (no repo configured/found) rather than a conversation
creation failure. The manager catches this and converts it to a user-friendly
message.
"""
pass

View File

@@ -1,8 +1,21 @@
from dataclasses import dataclass
"""Jira view implementations and factory.
from integrations.jira.jira_types import JiraViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
Views are responsible for:
- Holding the webhook payload and auth context
- Lazy-loading issue details from Jira API when needed
- Creating conversations with the selected repository
"""
from dataclasses import dataclass, field
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
from integrations.jira.jira_types import (
JiraViewInterface,
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from jinja2 import Environment
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
@@ -10,55 +23,147 @@ from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import create_new_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
integration_store = JiraIntegrationStore.get_instance()
@dataclass
class JiraNewConversationView(JiraViewInterface):
job_context: JobContext
"""View for creating a new Jira conversation.
This view holds the webhook payload directly and lazily fetches
issue details when needed for rendering templates.
"""
payload: JiraWebhookPayload
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None
conversation_id: str
selected_repo: str | None = None
conversation_id: str = ''
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
# Lazy-loaded issue details (cached after first fetch)
_issue_title: str | None = field(default=None, repr=False)
_issue_description: str | None = field(default=None, repr=False)
# Decrypted API key (set by factory)
_decrypted_api_key: str = field(default='', repr=False)
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch issue details from Jira API (cached after first call).
Returns:
Tuple of (issue_title, issue_description)
Raises:
StartingConvoException: If issue details cannot be fetched
"""
if self._issue_title is not None and self._issue_description is not None:
return self._issue_title, self._issue_description
try:
url = f'{JIRA_CLOUD_API_URL}/{self.jira_workspace.jira_cloud_id}/rest/api/2/issue/{self.payload.issue_key}'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.get(
url,
auth=(
self.jira_workspace.svc_acc_email,
self._decrypted_api_key,
),
)
response.raise_for_status()
issue_payload = response.json()
if not issue_payload:
raise StartingConvoException(
f'Issue {self.payload.issue_key} not found.'
)
self._issue_title = issue_payload.get('fields', {}).get('summary', '')
self._issue_description = (
issue_payload.get('fields', {}).get('description', '') or ''
)
if not self._issue_title:
raise StartingConvoException(
f'Issue {self.payload.issue_key} does not have a title.'
)
logger.info(
'[Jira] Fetched issue details',
extra={
'issue_key': self.payload.issue_key,
'has_description': bool(self._issue_description),
},
)
return self._issue_title, self._issue_description
except httpx.HTTPStatusError as e:
logger.error(
'[Jira] Failed to fetch issue details',
extra={
'issue_key': self.payload.issue_key,
'status': e.response.status_code,
},
)
raise StartingConvoException(
f'Failed to fetch issue details: HTTP {e.response.status_code}'
)
except Exception as e:
if isinstance(e, StartingConvoException):
raise
logger.error(
'[Jira] Failed to fetch issue details',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
)
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get instructions for the conversation.
This fetches issue details if not already cached.
Returns:
Tuple of (system_instructions, user_message)
"""
issue_title, issue_description = await self.get_issue_details()
instructions_template = jinja_env.get_template('jira_instructions.j2')
instructions = instructions_template.render()
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
user_message=self.job_context.user_msg or '',
issue_key=self.payload.issue_key,
issue_title=issue_title,
issue_description=issue_description,
user_message=self.payload.user_msg,
)
return instructions, user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create a new Jira conversation"""
"""Create a new Jira conversation.
Returns:
The conversation ID
Raises:
StartingConvoException: If conversation creation fails
"""
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = self._get_instructions(jinja_env)
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
@@ -76,149 +181,259 @@ class JiraNewConversationView(JiraViewInterface):
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Jira] Created conversation {self.conversation_id}')
logger.info(
'[Jira] Created conversation',
extra={
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
},
)
# Store Jira conversation mapping
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
issue_id=self.payload.issue_id,
issue_key=self.payload.issue_key,
jira_user_id=self.jira_user.id,
)
await integration_store.create_conversation(jira_conversation)
return self.conversation_id
except Exception as e:
if isinstance(e, StartingConvoException):
raise
logger.error(
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
'[Jira] Failed to create conversation',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
exc_info=True,
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira"""
"""Get the response message to send back to Jira."""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
@dataclass
class JiraExistingConversationView(JiraViewInterface):
job_context: JobContext
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None
conversation_id: str
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
user_msg_template = jinja_env.get_template('jira_existing_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
user_message=self.job_context.user_msg or '',
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
)
return '', user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Update an existing Jira conversation"""
user_id = self.jira_user.keycloak_user_id
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
# Should we raise here if there are no providers?
providers_set = list(provider_tokens.keys()) if provider_tokens else []
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
)
return self.conversation_id
except Exception as e:
logger.error(
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here|{conversation_link}]."
return f"I'm on it! {self.payload.display_name} can [track my progress here|{conversation_link}]."
class JiraFactory:
"""Factory for creating Jira views based on message content"""
"""Factory for creating Jira views.
The factory is responsible for:
- Creating the appropriate view type
- Inferring and selecting the repository
- Validating all required data is available
Repository selection happens here so that view creation either
succeeds with a valid repo or fails with a clear error.
"""
@staticmethod
async def create_jira_view_from_payload(
job_context: JobContext,
saas_user_auth: UserAuth,
jira_user: JiraUser,
jira_workspace: JiraWorkspace,
async def _create_provider_handler(user_auth: UserAuth) -> ProviderHandler | None:
"""Create a ProviderHandler for the user."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return None
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
return ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
@staticmethod
def _extract_potential_repos(
issue_key: str,
issue_title: str,
issue_description: str,
user_msg: str,
) -> list[str]:
"""Extract potential repository names from issue content.
Raises:
RepositoryNotFoundError: If no potential repos found in text.
"""
search_text = f'{issue_title}\n{issue_description}\n{user_msg}'
potential_repos = infer_repo_from_message(search_text)
if not potential_repos:
raise RepositoryNotFoundError(
'Could not determine which repository to use. '
'Please mention the repository (e.g., owner/repo) in the issue description or comment.'
)
logger.info(
'[Jira] Found potential repositories in issue content',
extra={'issue_key': issue_key, 'potential_repos': potential_repos},
)
return potential_repos
@staticmethod
async def _verify_repos(
issue_key: str,
potential_repos: list[str],
provider_handler: ProviderHandler,
) -> list[str]:
"""Verify which repos the user has access to."""
verified_repos: list[str] = []
for repo_name in potential_repos:
try:
repository = await provider_handler.verify_repo_provider(repo_name)
verified_repos.append(repository.full_name)
logger.debug(
'[Jira] Repository verification succeeded',
extra={'issue_key': issue_key, 'repository': repository.full_name},
)
except Exception as e:
logger.debug(
'[Jira] Repository verification failed',
extra={
'issue_key': issue_key,
'repo_name': repo_name,
'error': str(e),
},
)
return verified_repos
@staticmethod
def _select_single_repo(
issue_key: str,
potential_repos: list[str],
verified_repos: list[str],
) -> str:
"""Select exactly one repo from verified repos.
Raises:
RepositoryNotFoundError: If zero or multiple repos verified.
"""
if len(verified_repos) == 0:
raise RepositoryNotFoundError(
f'Could not access any of the mentioned repositories: {", ".join(potential_repos)}. '
'Please ensure you have access to the repository and it exists.'
)
if len(verified_repos) > 1:
raise RepositoryNotFoundError(
f'Multiple repositories found: {", ".join(verified_repos)}. '
'Please specify exactly one repository in the issue description or comment.'
)
logger.info(
'[Jira] Verified repository access',
extra={'issue_key': issue_key, 'repository': verified_repos[0]},
)
return verified_repos[0]
@staticmethod
async def _infer_repository(
payload: JiraWebhookPayload,
user_auth: UserAuth,
issue_title: str,
issue_description: str,
) -> str:
"""Infer and verify the repository from issue content.
Raises:
RepositoryNotFoundError: If no valid repository can be determined.
"""
provider_handler = await JiraFactory._create_provider_handler(user_auth)
if not provider_handler:
raise RepositoryNotFoundError(
'No Git provider connected. Please connect a Git provider in OpenHands settings.'
)
potential_repos = JiraFactory._extract_potential_repos(
payload.issue_key, issue_title, issue_description, payload.user_msg
)
verified_repos = await JiraFactory._verify_repos(
payload.issue_key, potential_repos, provider_handler
)
return JiraFactory._select_single_repo(
payload.issue_key, potential_repos, verified_repos
)
@staticmethod
async def create_view(
payload: JiraWebhookPayload,
workspace: JiraWorkspace,
user: JiraUser,
user_auth: UserAuth,
decrypted_api_key: str,
) -> JiraViewInterface:
"""Create appropriate Jira view based on the message and user state"""
"""Create a Jira view with repository already selected.
if not jira_user or not saas_user_auth or not jira_workspace:
raise StartingConvoException('User not authenticated with Jira integration')
This factory method:
1. Creates the view with payload and auth context
2. Fetches issue details (needed for repo inference)
3. Infers and selects the repository
conversation = await integration_store.get_user_conversations_by_issue_id(
job_context.issue_id, jira_user.id
If any step fails, an appropriate exception is raised with
a user-friendly message.
Args:
payload: Parsed webhook payload
workspace: The Jira workspace
user: The Jira user
user_auth: OpenHands user authentication
decrypted_api_key: Decrypted service account API key
Returns:
A JiraViewInterface with selected_repo populated
Raises:
StartingConvoException: If view creation fails
RepositoryNotFoundError: If repository cannot be determined
"""
logger.info(
'[Jira] Creating view',
extra={
'issue_key': payload.issue_key,
'event_type': payload.event_type.value,
},
)
if conversation:
logger.info(
f'[Jira] Found existing conversation for issue {job_context.issue_id}'
)
return JiraExistingConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
jira_user=jira_user,
jira_workspace=jira_workspace,
selected_repo=None,
conversation_id=conversation.conversation_id,
)
return JiraNewConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
jira_user=jira_user,
jira_workspace=jira_workspace,
selected_repo=None, # Will be set later after repo inference
conversation_id='', # Will be set when conversation is created
# Create the view
view = JiraNewConversationView(
payload=payload,
saas_user_auth=user_auth,
jira_user=user,
jira_workspace=workspace,
_decrypted_api_key=decrypted_api_key,
)
# Fetch issue details (needed for repo inference)
try:
issue_title, issue_description = await view.get_issue_details()
except StartingConvoException:
raise # Re-raise with original message
except Exception as e:
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
# Infer and select repository
selected_repo = await JiraFactory._infer_repository(
payload=payload,
user_auth=user_auth,
issue_title=issue_title,
issue_description=issue_description,
)
view.selected_repo = selected_repo
logger.info(
'[Jira] View created successfully',
extra={
'issue_key': payload.issue_key,
'selected_repo': selected_repo,
},
)
return view

View File

@@ -16,11 +16,6 @@ class Manager(ABC):
"Send message to integration from Openhands server"
raise NotImplementedError
@abstractmethod
async def is_job_requested(self, message: Message) -> bool:
"Confirm that a job is being requested"
raise NotImplementedError
@abstractmethod
def start_job(self):
"Kick off a job with openhands agent"

View File

@@ -1,6 +1,6 @@
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -14,6 +14,7 @@ class ResolverUserContext(UserContext):
saas_user_auth: UserAuth,
):
self.saas_user_auth = saas_user_auth
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
return await self.saas_user_auth.get_user_id()
@@ -29,17 +30,34 @@ class ResolverUserContext(UserContext):
return UserInfo(id=user_id)
async def get_authenticated_git_url(self, repository: str) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
async def _get_provider_handler(self) -> ProviderHandler:
"""Get or create a ProviderHandler for git operations."""
if self._provider_handler is None:
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens is None:
raise ValueError('No provider tokens available')
user_id = await self.saas_user_auth.get_user_id()
self._provider_handler = ProviderHandler(
provider_tokens=provider_tokens, external_auth_id=user_id
)
return self._provider_handler
async def get_authenticated_git_url(
self, repository: str, is_optional: bool = False
) -> str:
provider_handler = await self._get_provider_handler()
url = await provider_handler.get_authenticated_git_url(
repository, is_optional=is_optional
)
return url
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token from git_provider_tokens
# Return the appropriate token string from git_provider_tokens
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens:
return provider_tokens.get(provider_type)
provider_token = provider_tokens.get(provider_type)
if provider_token and provider_token.token:
return provider_token.token.get_secret_value()
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:

View File

@@ -16,9 +16,8 @@ from integrations.utils import (
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_session_expired_message,
)
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.constants import SLACK_CLIENT_ID
from server.utils.conversation_callback_utils import register_callback_processor
from slack_sdk.oauth import AuthorizeUrlGenerator
@@ -59,17 +58,6 @@ class SlackManager(Manager):
if message.source != SourceType.SLACK:
raise ValueError(f'Unexpected message source {message.source}')
async def _get_user_auth(self, keycloak_user_id: str) -> UserAuth:
offline_token = await self.token_manager.load_offline_token(keycloak_user_id)
if offline_token is None:
logger.info('no_offline_token_found')
user_auth = SaasUserAuth(
user_id=keycloak_user_id,
refresh_token=SecretStr(offline_token),
)
return user_auth
async def authenticate_user(
self, slack_user_id: str
) -> tuple[SlackUser | None, UserAuth | None]:
@@ -86,7 +74,9 @@ class SlackManager(Manager):
saas_user_auth = None
if slack_user:
saas_user_auth = await self._get_user_auth(slack_user.keycloak_user_id)
saas_user_auth = await get_saas_user_auth(
slack_user.keycloak_user_id, self.token_manager
)
# slack_view.saas_user_auth = await self._get_user_auth(slack_view.slack_to_openhands_user.keycloak_user_id)
return slack_user, saas_user_auth
@@ -249,13 +239,11 @@ class SlackManager(Manager):
async def is_job_requested(
self, message: Message, slack_view: SlackViewInterface
) -> bool:
"""
A job is always request we only receive webhooks for events associated with the slack bot
"""A job is always request we only receive webhooks for events associated with the slack bot
This method really just checks
1. Is the user is authenticated
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
"""
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
if isinstance(slack_view, SlackUpdateExistingConversationView):
return True
@@ -322,10 +310,15 @@ class SlackManager(Manager):
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
)
if not isinstance(slack_view, SlackUpdateExistingConversationView):
# Only add SlackCallbackProcessor for new conversations (not updates) and non-v1 conversations
if (
not isinstance(slack_view, SlackUpdateExistingConversationView)
and not slack_view.v1_enabled
):
# We don't re-subscribe for follow up messages from slack.
# Summaries are generated for every messages anyways, we only need to do
# this subscription once for the event which kicked off the job.
processor = SlackCallbackProcessor(
slack_user_id=slack_view.slack_user_id,
channel_id=slack_view.channel_id,
@@ -340,6 +333,14 @@ class SlackManager(Manager):
logger.info(
f'[Slack] Created callback processor for conversation {conversation_id}'
)
elif isinstance(slack_view, SlackUpdateExistingConversationView):
logger.info(
f'[Slack] Skipping callback processor for existing conversation update {conversation_id}'
)
elif slack_view.v1_enabled:
logger.info(
f'[Slack] Skipping callback processor for v1 conversation {conversation_id}'
)
msg_info = slack_view.get_response_msg()

View File

@@ -21,20 +21,16 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"Instructions passed when conversation is first initialized"
"""Instructions passed when conversation is first initialized"""
pass
@abstractmethod
async def create_or_update_conversation(self, jinja_env: Environment):
"Create a new conversation"
pass
@abstractmethod
def get_callback_id(self) -> str:
"Unique callback id for subscribription made to EventStream for fetching agent summary"
"""Create a new conversation"""
pass
@abstractmethod
@@ -43,6 +39,4 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
class StartingConvoException(Exception):
"""
Raised when trying to send message to a conversation that's is still starting up
"""
"""Raised when trying to send message to a conversation that's is still starting up"""

View File

@@ -0,0 +1,273 @@
import logging
from uuid import UUID
import httpx
from integrations.utils import CONVERSATION_URL, get_summary_instruction
from pydantic import Field
from slack_sdk import WebClient
from storage.slack_team_store import SlackTeamStore
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
_logger = logging.getLogger(__name__)
class SlackV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Slack V1 integrations."""
slack_view_data: dict[str, str | None] = Field(default_factory=dict)
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Slack V1 integration."""
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
return None
_logger.info('[Slack V1] Callback agent state was %s', event)
try:
summary = await self._request_summary(conversation_id)
await self._post_summary_to_slack(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception('[Slack V1] Error processing callback: %s', e)
# Only try to post error to Slack if we have basic requirements
try:
await self._post_summary_to_slack(
f'OpenHands encountered an error: **{str(e)}**.\n\n'
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
'for more information.'
)
except Exception as post_error:
_logger.warning(
'[Slack V1] Failed to post error message to Slack: %s', post_error
)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
# -------------------------------------------------------------------------
# Slack helpers
# -------------------------------------------------------------------------
def _get_bot_access_token(self):
slack_team_store = SlackTeamStore.get_instance()
bot_access_token = slack_team_store.get_team_bot_token(
self.slack_view_data['team_id']
)
return bot_access_token
async def _post_summary_to_slack(self, summary: str) -> None:
"""Post a summary message to the configured Slack channel."""
bot_access_token = self._get_bot_access_token()
if not bot_access_token:
raise RuntimeError('Missing Slack bot access token')
channel_id = self.slack_view_data['channel_id']
thread_ts = self.slack_view_data.get('thread_ts') or self.slack_view_data.get(
'message_ts'
)
client = WebClient(token=bot_access_token)
try:
# Post the summary as a threaded reply
response = client.chat_postMessage(
channel=channel_id,
text=summary,
thread_ts=thread_ts,
unfurl_links=False,
unfurl_media=False,
)
if not response['ok']:
raise RuntimeError(
f"Slack API error: {response.get('error', 'Unknown error')}"
)
_logger.info(
'[Slack V1] Successfully posted summary to channel %s', channel_id
)
except Exception as e:
_logger.error('[Slack V1] Failed to post message to Slack: %s', e)
raise
# -------------------------------------------------------------------------
# Agent / sandbox helpers
# -------------------------------------------------------------------------
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f'{agent_server_url.rstrip("/")}'
f'/api/conversations/{conversation_id}/ask_agent'
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception: # noqa: BLE001
pass
_logger.error(
'[Slack V1] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
exc_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.error(
'[Slack V1] %s. Request payload: %s',
error_detail,
payload,
exc_info=True,
)
raise Exception(error_detail)
except httpx.RequestError as e:
error_detail = f'Request error to {url}: {str(e)}'
_logger.error(
'[Slack V1] %s. Request payload: %s',
error_detail,
payload,
exc_info=True,
)
raise Exception(error_detail)
# -------------------------------------------------------------------------
# Summary orchestration
# -------------------------------------------------------------------------
async def _request_summary(self, conversation_id: UUID) -> str:
"""
Ask the agent to produce a summary of its work and return the agent response.
NOTE: This method now returns a string (the agent server's response text)
and raises exceptions on errors. The wrapping into EventCallbackResult
is handled by __call__.
"""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)

View File

@@ -1,8 +1,16 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.slack.slack_types import SlackViewInterface, StartingConvoException
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
from integrations.utils import (
CONVERSATION_URL,
ENABLE_V1_SLACK_RESOLVER,
get_final_agent_observation,
get_user_v1_enabled_setting,
)
from jinja2 import Environment
from slack_sdk import WebClient
from storage.slack_conversation import SlackConversation
@@ -10,22 +18,34 @@ from storage.slack_conversation_store import SlackConversationStore
from storage.slack_team_store import SlackTeamStore
from storage.slack_user import SlackUser
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
SendMessageRequest,
)
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
)
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
# =================================================
# SECTION: Github view types
# SECTION: Slack view types
# =================================================
@@ -34,6 +54,10 @@ slack_conversation_store = SlackConversationStore.get_instance()
slack_team_store = SlackTeamStore.get_instance()
async def is_v1_enabled_for_slack_resolver(user_id: str) -> bool:
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_SLACK_RESOLVER
@dataclass
class SlackUnkownUserView(SlackViewInterface):
bot_access_token: str
@@ -49,6 +73,7 @@ class SlackUnkownUserView(SlackViewInterface):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
raise NotImplementedError
@@ -56,9 +81,6 @@ class SlackUnkownUserView(SlackViewInterface):
async def create_or_update_conversation(self, jinja_env: Environment):
raise NotImplementedError
def get_callback_id(self) -> str:
raise NotImplementedError
def get_response_msg(self) -> str:
raise NotImplementedError
@@ -78,6 +100,7 @@ class SlackNewConversationView(SlackViewInterface):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
def _get_initial_prompt(self, text: str, blocks: list[dict]):
bot_id = self._get_bot_id(blocks)
@@ -96,8 +119,7 @@ class SlackNewConversationView(SlackViewInterface):
return ''
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"Instructions passed when conversation is first initialized"
"""Instructions passed when conversation is first initialized"""
user_info: SlackUser = self.slack_to_openhands_user
messages = []
@@ -157,7 +179,7 @@ class SlackNewConversationView(SlackViewInterface):
'Attempting to start conversation without confirming selected repo from user'
)
async def save_slack_convo(self):
async def save_slack_convo(self, v1_enabled: bool = False):
if self.slack_to_openhands_user:
user_info: SlackUser = self.slack_to_openhands_user
@@ -167,26 +189,59 @@ class SlackNewConversationView(SlackViewInterface):
'channel_id': self.channel_id,
'conversation_id': self.conversation_id,
'keycloak_user_id': user_info.keycloak_user_id,
'org_id': user_info.org_id,
'parent_id': self.thread_ts or self.message_ts,
'v1_enabled': v1_enabled,
},
)
slack_conversation = SlackConversation(
conversation_id=self.conversation_id,
channel_id=self.channel_id,
keycloak_user_id=user_info.keycloak_user_id,
org_id=user_info.org_id,
parent_id=self.thread_ts
or self.message_ts, # conversations can start in a thread reply as well; we should always references the parent's (root level msg's) message ID
v1_enabled=v1_enabled,
)
await slack_conversation_store.create_slack_conversation(slack_conversation)
def _create_slack_v1_callback_processor(self) -> SlackV1CallbackProcessor:
"""Create a SlackV1CallbackProcessor for V1 conversation handling."""
return SlackV1CallbackProcessor(
slack_view_data={
'channel_id': self.channel_id,
'message_ts': self.message_ts,
'thread_ts': self.thread_ts,
'team_id': self.team_id,
'slack_user_id': self.slack_user_id,
}
)
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""
Only creates a new conversation
"""
"""Only creates a new conversation"""
self._verify_necessary_values_are_set()
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Check if V1 conversations are enabled for this user
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
self.slack_to_openhands_user.keycloak_user_id
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(jinja)
return self.conversation_id
else:
# Use existing V0 conversation service
await self._create_v0_conversation(jinja, provider_tokens, user_secrets)
return self.conversation_id
async def _create_v0_conversation(
self, jinja: Environment, provider_tokens, user_secrets
) -> None:
"""Create conversation using the legacy V0 system."""
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Determine git provider from repository
@@ -213,11 +268,65 @@ class SlackNewConversationView(SlackViewInterface):
)
self.conversation_id = agent_loop_info.conversation_id
await self.save_slack_convo()
return self.conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
def get_callback_id(self) -> str:
return f'slack_{self.channel_id}_{self.message_ts}'
async def _create_v1_conversation(self, jinja: Environment) -> None:
"""Create conversation using the new V1 app conversation system."""
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=user_instructions)]
)
# Create the Slack V1 callback processor
slack_callback_processor = self._create_slack_v1_callback_processor()
# Determine git provider from repository
git_provider = None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = ProviderType(repository.git_provider.value)
# Get the app conversation service and start the conversation
injector_state = InjectorState()
# Create the V1 conversation start request with the callback processor
self.conversation_id = uuid4().hex
start_request = AppConversationStartRequest(
conversation_id=UUID(self.conversation_id),
system_message_suffix=conversation_instructions,
initial_message=initial_message,
selected_repository=self.selected_repo,
git_provider=git_provider,
title=f'Slack conversation from {self.slack_to_openhands_user.slack_display_name}',
trigger=ConversationTrigger.SLACK,
processors=[
slack_callback_processor
], # Pass the callback processor directly
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
logger.info(f'[Slack V1]: Created new conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=True)
def get_response_msg(self) -> str:
user_info: SlackUser = self.slack_to_openhands_user
@@ -254,32 +363,20 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
return user_message, ''
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""
Send new user message to converation
"""
async def send_message_to_v0_conversation(self, jinja: Environment):
user_info: SlackUser = self.slack_to_openhands_user
saas_user_auth: UserAuth = self.saas_user_auth
user_id = user_info.keycloak_user_id
# Org management in the future will get rid of this
# For now, only user that created the conversation can send follow up messages to it
if user_id != self.slack_conversation.keycloak_user_id:
raise StartingConvoException(
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
)
# Check if conversation has been deleted
# Update logic when soft delete is implemented
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
saas_user_auth: UserAuth = self.saas_user_auth
provider_tokens = await saas_user_auth.get_provider_tokens()
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await saas_user_auth.get_provider_tokens()
# Should we raise here if there are no provider tokens?
providers_set = list(provider_tokens.keys()) if provider_tokens else []
@@ -304,12 +401,123 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
user_msg, _ = self._get_instructions(jinja)
user_msg_action = MessageAction(content=user_msg)
instructions, _ = self._get_instructions(jinja)
user_msg = MessageAction(content=instructions)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_msg_action)
self.conversation_id, event_to_dict(user_msg)
)
async def send_message_to_v1_conversation(self, jinja: Environment):
"""Send a message to a v1 conversation using the agent server API."""
# Import services within the method to avoid circular imports
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
get_agent_server_url_from_sandbox,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
UUID(self.conversation_id)
),
UUID(self.conversation_id),
)
# 2. Sandbox lookup + validation
sandbox = await sandbox_service.get_sandbox(
app_conversation_info.sandbox_id
)
if sandbox and sandbox.status == SandboxStatus.PAUSED:
# Resume paused sandbox and wait for it to be running
logger.info('[Slack V1]: Attempting to resume paused sandbox')
await sandbox_service.resume_sandbox(app_conversation_info.sandbox_id)
# Wait for sandbox to be running (handles both fresh start and resume)
running_sandbox = await sandbox_service.wait_for_sandbox_running(
app_conversation_info.sandbox_id,
timeout=120,
poll_interval=2,
httpx_client=httpx_client,
)
assert (
running_sandbox.session_api_key is not None
), f'No session API key for sandbox: {running_sandbox.id}'
# 3. Get the agent server URL
agent_server_url = get_agent_server_url_from_sandbox(running_sandbox)
# 4. Prepare the message content
user_msg, _ = self._get_instructions(jinja)
# 5. Create the message request
send_message_request = SendMessageRequest(
role='user', content=[TextContent(text=user_msg)], run=True
)
# 6. Send the message to the agent server
url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events'
headers = {'X-Session-API-Key': running_sandbox.session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
except Exception as e:
logger.error(
'[Slack V1] Failed to send message to conversation %s: %s',
self.conversation_id,
str(e),
exc_info=True,
)
raise Exception(f'Failed to send message to v1 conversation: {str(e)}')
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""Send new user message to converation"""
user_info: SlackUser = self.slack_to_openhands_user
user_id = user_info.keycloak_user_id
# Org management in the future will get rid of this
# For now, only user that created the conversation can send follow up messages to it
if user_id != self.slack_conversation.keycloak_user_id:
raise StartingConvoException(
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
)
if self.slack_conversation.v1_enabled:
await self.send_message_to_v1_conversation(jinja)
else:
await self.send_message_to_v0_conversation(jinja)
return self.conversation_id
def get_response_msg(self):
@@ -361,7 +569,7 @@ class SlackFactory:
'channel_id': channel_id,
},
)
raise Exception('Did not slack team')
raise Exception('Did not find slack team')
# Determine if this is a known slack user by openhands
if not slack_user or not saas_user_auth or not channel_id:
@@ -379,6 +587,7 @@ class SlackFactory:
send_summary_instruction=False,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)
conversation: SlackConversation | None = call_async_from_sync(
@@ -409,6 +618,7 @@ class SlackFactory:
conversation_id=conversation.conversation_id,
slack_conversation=conversation,
team_id=team_id,
v1_enabled=False,
)
elif SlackFactory.did_user_select_repo_from_form(message):
@@ -426,6 +636,7 @@ class SlackFactory:
send_summary_instruction=True,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)
else:
@@ -443,4 +654,5 @@ class SlackFactory:
send_summary_instruction=True,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)

View File

@@ -0,0 +1,53 @@
from storage.repository_store import RepositoryStore
from storage.stored_repository import StoredRepository
from storage.user_repo_map import UserRepositoryMap
from storage.user_repo_map_store import UserRepositoryMapStore
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import Repository
async def store_repositories_in_db(repos: list[Repository], user_id: str) -> None:
"""
Store repositories in DB and create user-repository mappings
Args:
repos: List of Repository objects to store
user_id: User ID associated with these repositories
"""
# Convert Repository objects to StoredRepository objects
# Convert Repository objects to UserRepositoryMap objects
stored_repos = []
user_repos = []
for repo in repos:
repo_id = f'{repo.git_provider.value}##{str(repo.id)}'
stored_repo = StoredRepository(
repo_name=repo.full_name,
repo_id=repo_id,
is_public=repo.is_public,
# Optional fields set to None by default
has_microagent=None,
has_setup_script=None,
)
stored_repos.append(stored_repo)
user_repo_map = UserRepositoryMap(user_id=user_id, repo_id=repo_id, admin=None)
user_repos.append(user_repo_map)
# Get config instance
config = OpenHandsConfig()
try:
# Store repositories in the repos table
repo_store = RepositoryStore.get_instance(config)
repo_store.store_projects(stored_repos)
# Store user-repository mappings in the user-repos table
user_repo_store = UserRepositoryMapStore.get_instance(config)
user_repo_store.store_user_repo_mappings(user_repos)
logger.info(f'Saved repos for user {user_id}')
except Exception:
logger.warning('Failed to save repos', exc_info=True)

View File

@@ -1,19 +1,24 @@
from uuid import UUID
import stripe
from server.auth.token_manager import TokenManager
from server.constants import STRIPE_API_KEY
from server.logger import logger
from sqlalchemy.orm import Session
from storage.database import session_maker
from storage.org import Org
from storage.org_store import OrgStore
from storage.stripe_customer import StripeCustomer
from openhands.utils.async_utils import call_sync_from_async
stripe.api_key = STRIPE_API_KEY
async def find_customer_id_by_user_id(user_id: str) -> str | None:
# First search our own DB...
async def find_customer_id_by_org_id(org_id: UUID) -> str | None:
with session_maker() as session:
stripe_customer = (
session.query(StripeCustomer)
.filter(StripeCustomer.keycloak_user_id == user_id)
.filter(StripeCustomer.org_id == org_id)
.first()
)
if stripe_customer:
@@ -21,46 +26,76 @@ async def find_customer_id_by_user_id(user_id: str) -> str | None:
# If that fails, fallback to stripe
search_result = await stripe.Customer.search_async(
query=f"metadata['user_id']:'{user_id}'",
query=f"metadata['org_id']:'{str(org_id)}'",
)
data = search_result.data
if not data:
logger.info('no_customer_for_user_id', extra={'user_id': user_id})
logger.info(
'no_customer_for_org_id',
extra={'org_id': str(org_id)},
)
return None
return data[0].id # type: ignore [attr-defined]
async def find_or_create_customer(user_id: str) -> str:
customer_id = await find_customer_id_by_user_id(user_id)
if customer_id:
return customer_id
logger.info('creating_customer', extra={'user_id': user_id})
async def find_customer_id_by_user_id(user_id: str) -> str | None:
# First search our own DB...
org = await call_sync_from_async(
OrgStore.get_current_org_from_keycloak_user_id, user_id
)
if not org:
logger.warning(f'Org not found for user {user_id}')
return None
customer_id = await find_customer_id_by_org_id(org.id)
return customer_id
# Get the user info from keycloak
token_manager = TokenManager()
user_info = await token_manager.get_user_info_from_user_id(user_id) or {}
async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
# Get the current org for the user
org = await call_sync_from_async(
OrgStore.get_current_org_from_keycloak_user_id, user_id
)
if not org:
logger.warning(f'Org not found for user {user_id}')
return None
customer_id = await find_customer_id_by_org_id(org.id)
if customer_id:
return {'customer_id': customer_id, 'org_id': str(org.id)}
logger.info(
'creating_customer',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
# Create the customer in stripe
customer = await stripe.Customer.create_async(
email=str(user_info.get('email', '')),
metadata={'user_id': user_id},
email=org.contact_email,
metadata={'org_id': str(org.id)},
)
# Save the stripe customer in the local db
with session_maker() as session:
session.add(
StripeCustomer(keycloak_user_id=user_id, stripe_customer_id=customer.id)
StripeCustomer(
keycloak_user_id=user_id,
org_id=org.id,
stripe_customer_id=customer.id,
)
)
session.commit()
logger.info(
'created_customer',
extra={'user_id': user_id, 'stripe_customer_id': customer.id},
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
return customer.id
return {'customer_id': customer.id, 'org_id': str(org.id)}
async def has_payment_method(user_id: str) -> bool:
async def has_payment_method_by_user_id(user_id: str) -> bool:
customer_id = await find_customer_id_by_user_id(user_id)
if customer_id is None:
return False
@@ -71,3 +106,28 @@ async def has_payment_method(user_id: str) -> bool:
f'has_payment_method:{user_id}:{customer_id}:{bool(payment_methods.data)}'
)
return bool(payment_methods.data)
async def migrate_customer(session: Session, user_id: str, org: Org):
stripe_customer = (
session.query(StripeCustomer)
.filter(StripeCustomer.keycloak_user_id == user_id)
.first()
)
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)

View File

@@ -45,7 +45,3 @@ class ResolverViewInterface(SummaryExtractionTracker):
async def create_new_conversation(self, jinja_env: Environment, token: str):
"Create a new conversation"
raise NotImplementedError()
def get_callback_id(self) -> str:
"Unique callback id for subscribription made to EventStream for fetching agent summary"
raise NotImplementedError()

View File

@@ -7,12 +7,8 @@ from typing import TYPE_CHECKING
from jinja2 import Environment, FileSystemLoader
from server.constants import WEB_HOST
from storage.repository_store import RepositoryStore
from storage.stored_repository import StoredRepository
from storage.user_repo_map import UserRepositoryMap
from storage.user_repo_map_store import UserRepositoryMapStore
from storage.org_store import OrgStore
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events import Event, EventSource
@@ -25,6 +21,7 @@ from openhands.events.event_store_abc import EventStoreABC
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.integrations.service_types import Repository
from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.utils.async_utils import call_sync_from_async
if TYPE_CHECKING:
from openhands.server.conversation_manager.conversation_manager import (
@@ -36,7 +33,7 @@ if TYPE_CHECKING:
HOST = WEB_HOST
# ---- DO NOT REMOVE ----
HOST_URL = f'https://{HOST}'
HOST_URL = f'https://{HOST}' if 'localhost' not in HOST else f'http://{HOST}'
GITHUB_WEBHOOK_URL = f'{HOST_URL}/integration/github/events'
GITLAB_WEBHOOK_URL = f'{HOST_URL}/integration/gitlab/events'
conversation_prefix = 'conversations/{}'
@@ -78,8 +75,14 @@ ENABLE_V1_GITHUB_RESOLVER = (
os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true'
)
ENABLE_V1_SLACK_RESOLVER = (
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
)
OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/'
OPENHANDS_RESOLVER_TEMPLATES_DIR = (
os.getenv('OPENHANDS_RESOLVER_TEMPLATES_DIR')
or 'openhands/integrations/templates/resolver/'
)
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
@@ -107,6 +110,28 @@ def get_summary_instruction():
return summary_instruction
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
"""Get the user's V1 conversation API setting.
Args:
user_id: The keycloak user ID
Returns:
True if V1 conversations are enabled for this user, False otherwise
"""
if not user_id:
return False
org = await call_sync_from_async(
OrgStore.get_current_org_from_keycloak_user_id, user_id
)
if not org or org.v1_enabled is None:
return False
return org.v1_enabled
def has_exact_mention(text: str, mention: str) -> bool:
"""Check if the text contains an exact mention (not part of a larger word).
@@ -369,102 +394,46 @@ def append_conversation_footer(message: str, conversation_id: str) -> str:
return message + footer
async def store_repositories_in_db(repos: list[Repository], user_id: str) -> None:
"""
Store repositories in DB and create user-repository mappings
Args:
repos: List of Repository objects to store
user_id: User ID associated with these repositories
"""
# Convert Repository objects to StoredRepository objects
# Convert Repository objects to UserRepositoryMap objects
stored_repos = []
user_repos = []
for repo in repos:
repo_id = f'{repo.git_provider.value}##{str(repo.id)}'
stored_repo = StoredRepository(
repo_name=repo.full_name,
repo_id=repo_id,
is_public=repo.is_public,
# Optional fields set to None by default
has_microagent=None,
has_setup_script=None,
)
stored_repos.append(stored_repo)
user_repo_map = UserRepositoryMap(user_id=user_id, repo_id=repo_id, admin=None)
user_repos.append(user_repo_map)
# Get config instance
config = OpenHandsConfig()
try:
# Store repositories in the repos table
repo_store = RepositoryStore.get_instance(config)
repo_store.store_projects(stored_repos)
# Store user-repository mappings in the user-repos table
user_repo_store = UserRepositoryMapStore.get_instance(config)
user_repo_store.store_user_repo_mappings(user_repos)
logger.info(f'Saved repos for user {user_id}')
except Exception:
logger.warning('Failed to save repos', exc_info=True)
def infer_repo_from_message(user_msg: str) -> list[str]:
"""
Extract all repository names in the format 'owner/repo' from various Git provider URLs
and direct mentions in text. Supports GitHub, GitLab, and BitBucket.
Args:
user_msg: Input message that may contain repository references
Returns:
List of repository names in 'owner/repo' format, empty list if none found
"""
# Normalize the message by removing extra whitespace and newlines
normalized_msg = re.sub(r'\s+', ' ', user_msg.strip())
# Pattern to match Git URLs from GitHub, GitLab, and BitBucket
# Captures: protocol, domain, owner, repo (with optional .git extension)
git_url_pattern = r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:[/?#].*?)?(?=\s|$|[^\w.-])'
# Pattern to match direct owner/repo mentions (e.g., "OpenHands/OpenHands")
# Must be surrounded by word boundaries or specific characters to avoid false positives
direct_pattern = (
r'(?:^|\s|[\[\(\'"])([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)(?=\s|$|[\]\)\'",.])'
git_url_pattern = (
r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/'
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?'
r'(?:[/?#].*?)?(?=\s|$|[^\w.-])'
)
matches = []
# UPDATED: allow {{ owner/repo }} in addition to existing boundaries
direct_pattern = (
r'(?:^|\s|{{|[\[\(\'":`])' # left boundary
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)'
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
)
# First, find all Git URLs (highest priority)
git_matches = re.findall(git_url_pattern, normalized_msg)
for owner, repo in git_matches:
# Remove .git extension if present
matches: list[str] = []
# Git URLs first (highest priority)
for owner, repo in re.findall(git_url_pattern, normalized_msg):
repo = re.sub(r'\.git$', '', repo)
matches.append(f'{owner}/{repo}')
# Second, find all direct owner/repo mentions
direct_matches = re.findall(direct_pattern, normalized_msg)
for owner, repo in direct_matches:
# Direct mentions
for owner, repo in re.findall(direct_pattern, normalized_msg):
full_match = f'{owner}/{repo}'
# Skip if it looks like a version number, date, or file path
if (
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match) # version numbers
or re.match(r'^\d{1,2}/\d{1,2}$', full_match) # dates
or re.match(r'^[A-Z]/[A-Z]$', full_match) # single letters
or repo.endswith('.txt')
or repo.endswith('.md') # file extensions
or repo.endswith('.py')
or repo.endswith('.js')
or '.' in repo
and len(repo.split('.')) > 2
): # complex file paths
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match)
or re.match(r'^\d{1,2}/\d{1,2}$', full_match)
or re.match(r'^[A-Z]/[A-Z]$', full_match)
or repo.endswith(('.txt', '.md', '.py', '.js'))
or ('.' in repo and len(repo.split('.')) > 2)
):
continue
# Avoid duplicates from Git URLs already found
if full_match not in matches:
matches.append(full_match)

View File

@@ -1,10 +1,15 @@
import logging
import os
from logging.config import fileConfig
from alembic import context
from google.cloud.sql.connector import Connector
from sqlalchemy import create_engine
from storage.base import Base
# Suppress alembic.runtime.plugins INFO logs during import to prevent non-JSON logs in production
# These plugin setup messages would otherwise appear before logging is configured
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata

View File

@@ -20,6 +20,8 @@ down_revision = '059'
branch_labels = None
depends_on = None
# TODO: decide whether to modify this for orgs or users
def upgrade():
"""
@@ -28,8 +30,10 @@ def upgrade():
This replaces the functionality of the removed admin maintenance endpoint.
"""
# Import here to avoid circular imports
from server.constants import CURRENT_USER_SETTINGS_VERSION
# Hardcoded value to prevent migration failures when constant is removed from codebase
# This migration has already run in production, so we use the value that was current at the time
CURRENT_USER_SETTINGS_VERSION = 4
# Create a connection and bind it to a session
connection = op.get_bind()

View File

@@ -0,0 +1,30 @@
"""add v1 column to slack conversation table
Revision ID: 086
Revises: 085
Create Date: 2025-12-02 15:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '086'
down_revision: Union[str, None] = '085'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add v1 column
op.add_column(
'slack_conversation', sa.Column('v1_enabled', sa.Boolean(), nullable=True)
)
def downgrade() -> None:
# Drop v1 column
op.drop_column('slack_conversation', 'v1_enabled')

View File

@@ -0,0 +1,61 @@
"""bump condenser defaults: max_size 120->240
Revision ID: 087
Revises: 086
Create Date: 2026-01-05
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = '087'
down_revision: Union[str, None] = '086'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema.
Update existing users with condenser_max_size=120 or NULL to 240.
This covers both users who had the old default (120) explicitly set
and users who had NULL (which defaulted to 120 in the application code).
The SDK default for keep_first will be used automatically.
"""
user_settings_table = table(
'user_settings',
column('condenser_max_size', sa.Integer),
)
# Update users with explicit 120 value
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size == 120)
.values(condenser_max_size=240)
)
# Update users with NULL value (which defaulted to 120 in application code)
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size.is_(None))
.values(condenser_max_size=240)
)
def downgrade() -> None:
"""Downgrade schema.
Note: This sets all 240 values back to NULL (not 120) since we can't
distinguish between users who had 120 vs NULL before the upgrade.
"""
user_settings_table = table(
'user_settings', column('condenser_max_size', sa.Integer)
)
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size == 240)
.values(condenser_max_size=None)
)

View File

@@ -0,0 +1,54 @@
"""create blocked_email_domains table
Revision ID: 088
Revises: 087
Create Date: 2025-01-27 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '088'
down_revision: Union[str, None] = '087'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create blocked_email_domains table for storing blocked email domain patterns."""
op.create_table(
'blocked_email_domains',
sa.Column('id', sa.Integer(), sa.Identity(), nullable=False, primary_key=True),
sa.Column('domain', sa.String(), nullable=False),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column(
'updated_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.PrimaryKeyConstraint('id'),
)
# Create unique index on domain column
op.create_index(
'ix_blocked_email_domains_domain',
'blocked_email_domains',
['domain'],
unique=True,
)
def downgrade() -> None:
"""Drop blocked_email_domains table."""
op.drop_index('ix_blocked_email_domains_domain', table_name='blocked_email_domains')
op.drop_table('blocked_email_domains')

View File

@@ -0,0 +1,272 @@
"""create org tables from pgerd schema
Revision ID: 089
Revises: 088
Create Date: 2025-01-07 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '089'
down_revision: Union[str, None] = '088'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Remove current settings table
op.execute('DROP TABLE IF EXISTS settings')
# Add already_migrated column to user_settings table
op.add_column(
'user_settings',
sa.Column(
'already_migrated',
sa.Boolean,
nullable=True,
server_default=sa.text('false'),
),
)
# Create role table
op.create_table(
'role',
sa.Column('id', sa.Integer, sa.Identity(), primary_key=True),
sa.Column('name', sa.String, nullable=False),
sa.Column('rank', sa.Integer, nullable=False),
sa.UniqueConstraint('name', name='role_name_unique'),
)
# 1. Create default roles
op.execute(
sa.text("""
INSERT INTO role (name, rank) VALUES ('owner', 10), ('admin', 20), ('user', 1000)
ON CONFLICT (name) DO NOTHING;
""")
)
# Create org table with settings fields
op.create_table(
'org',
sa.Column(
'id',
postgresql.UUID(as_uuid=True),
primary_key=True,
),
sa.Column('name', sa.String, nullable=False),
sa.Column('contact_name', sa.String, nullable=True),
sa.Column('contact_email', sa.String, nullable=True),
sa.Column('conversation_expiration', sa.Integer, nullable=True),
# Settings fields moved to org table
sa.Column('agent', sa.String, nullable=True),
sa.Column('default_max_iterations', sa.Integer, nullable=True),
sa.Column('security_analyzer', sa.String, nullable=True),
sa.Column(
'confirmation_mode',
sa.Boolean,
nullable=True,
server_default=sa.text('false'),
),
sa.Column('default_llm_model', sa.String, nullable=True),
sa.Column('default_llm_base_url', sa.String, nullable=True),
sa.Column('remote_runtime_resource_factor', sa.Integer, nullable=True),
sa.Column(
'enable_default_condenser',
sa.Boolean,
nullable=False,
server_default=sa.text('true'),
),
sa.Column('billing_margin', sa.Float, nullable=True),
sa.Column(
'enable_proactive_conversation_starters',
sa.Boolean,
nullable=False,
server_default=sa.text('true'),
),
sa.Column('sandbox_base_container_image', sa.String, nullable=True),
sa.Column('sandbox_runtime_container_image', sa.String, nullable=True),
sa.Column(
'org_version', sa.Integer, nullable=False, server_default=sa.text('0')
),
sa.Column('mcp_config', sa.JSON, nullable=True),
sa.Column('_search_api_key', sa.String, nullable=True),
sa.Column('_sandbox_api_key', sa.String, nullable=True),
sa.Column('max_budget_per_task', sa.Float, nullable=True),
sa.Column(
'enable_solvability_analysis',
sa.Boolean,
nullable=True,
server_default=sa.text('false'),
),
sa.Column('v1_enabled', sa.Boolean, nullable=True),
sa.Column('condenser_max_size', sa.Integer, nullable=True),
sa.UniqueConstraint('name', name='org_name_unique'),
)
# Create user table with user-specific settings fields
op.create_table(
'user',
sa.Column(
'id',
postgresql.UUID(as_uuid=True),
primary_key=True,
),
sa.Column('current_org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('role_id', sa.Integer, nullable=True),
sa.Column('accepted_tos', sa.DateTime, nullable=True),
sa.Column(
'enable_sound_notifications',
sa.Boolean,
nullable=True,
server_default=sa.text('false'),
),
sa.Column('language', sa.String, nullable=True),
sa.Column('user_consents_to_analytics', sa.Boolean, nullable=True),
sa.Column('email', sa.String, nullable=True),
sa.Column('email_verified', sa.Boolean, nullable=True),
sa.ForeignKeyConstraint(
['current_org_id'], ['org.id'], name='current_org_fkey'
),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], name='user_role_fkey'),
)
# Create org_member table (junction table for many-to-many relationship)
op.create_table(
'org_member',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('role_id', sa.Integer, nullable=False),
sa.Column('_llm_api_key', sa.String, nullable=False),
sa.Column('max_iterations', sa.Integer, nullable=True),
sa.Column('llm_model', sa.String, nullable=True),
sa.Column('_llm_api_key_for_byor', sa.String, nullable=True),
sa.Column('llm_base_url', sa.String, nullable=True),
sa.Column('status', sa.String, nullable=True),
sa.ForeignKeyConstraint(['org_id'], ['org.id'], name='om_org_fkey'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='om_user_fkey'),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], name='om_role_fkey'),
sa.PrimaryKeyConstraint('org_id', 'user_id'),
)
# Add org_id column to existing tables
# billing_sessions
op.add_column(
'billing_sessions',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'billing_sessions_org_fkey', 'billing_sessions', 'org', ['org_id'], ['id']
)
# Create conversation_metadata_saas table
op.create_table(
'conversation_metadata_saas',
sa.Column('conversation_id', sa.String(), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
['user_id'], ['user.id'], name='conversation_metadata_saas_user_fkey'
),
sa.ForeignKeyConstraint(
['org_id'], ['org.id'], name='conversation_metadata_saas_org_fkey'
),
sa.PrimaryKeyConstraint('conversation_id'),
)
# custom_secrets
op.add_column(
'custom_secrets',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'custom_secrets_org_fkey', 'custom_secrets', 'org', ['org_id'], ['id']
)
# api_keys
op.add_column(
'api_keys', sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True)
)
op.create_foreign_key('api_keys_org_fkey', 'api_keys', 'org', ['org_id'], ['id'])
# slack_conversation
op.add_column(
'slack_conversation',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'slack_conversation_org_fkey', 'slack_conversation', 'org', ['org_id'], ['id']
)
# slack_users
op.add_column(
'slack_users', sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True)
)
op.create_foreign_key(
'slack_users_org_fkey', 'slack_users', 'org', ['org_id'], ['id']
)
# stripe_customers
op.alter_column(
'stripe_customers',
'keycloak_user_id',
existing_type=sa.String(),
nullable=True,
)
op.add_column(
'stripe_customers',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'stripe_customers_org_fkey', 'stripe_customers', 'org', ['org_id'], ['id']
)
def downgrade() -> None:
# Drop already_migrated column from user_settings table
op.drop_column('user_settings', 'already_migrated')
# Drop foreign keys and columns added to existing tables
op.drop_constraint(
'stripe_customers_org_fkey', 'stripe_customers', type_='foreignkey'
)
op.drop_column('stripe_customers', 'org_id')
op.alter_column(
'stripe_customers',
'keycloak_user_id',
existing_type=sa.String(),
nullable=False,
)
op.drop_constraint('slack_users_org_fkey', 'slack_users', type_='foreignkey')
op.drop_column('slack_users', 'org_id')
op.drop_constraint(
'slack_conversation_org_fkey', 'slack_conversation', type_='foreignkey'
)
op.drop_column('slack_conversation', 'org_id')
op.drop_constraint('api_keys_org_fkey', 'api_keys', type_='foreignkey')
op.drop_column('api_keys', 'org_id')
op.drop_constraint('custom_secrets_org_fkey', 'custom_secrets', type_='foreignkey')
op.drop_column('custom_secrets', 'org_id')
# Drop conversation_metadata_saas table
op.drop_table('conversation_metadata_saas')
op.drop_constraint(
'billing_sessions_org_fkey', 'billing_sessions', type_='foreignkey'
)
op.drop_column('billing_sessions', 'org_id')
# Drop tables in reverse order due to foreign key constraints
op.drop_table('org_member')
op.drop_table('user')
op.drop_table('org')
op.drop_table('role')

View File

@@ -0,0 +1,28 @@
"""Add git_user_name and git_user_email columns to user table.
Revision ID: 090
Revises: 089
Create Date: 2025-01-22
"""
import sqlalchemy as sa
from alembic import op
revision = '090'
down_revision = '089'
def upgrade() -> None:
op.add_column(
'user',
sa.Column('git_user_name', sa.String, nullable=True),
)
op.add_column(
'user',
sa.Column('git_user_email', sa.String, nullable=True),
)
def downgrade() -> None:
op.drop_column('user', 'git_user_email')
op.drop_column('user', 'git_user_name')

View File

@@ -0,0 +1,46 @@
"""Add byor_export_enabled flag to org table.
Revision ID: 091
Revises: 090
Create Date: 2025-01-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '091'
down_revision: Union[str, None] = '090'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add byor_export_enabled column to org table with default false
op.add_column(
'org',
sa.Column(
'byor_export_enabled',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
)
# Set byor_export_enabled to true for orgs that have completed billing sessions
op.execute(
sa.text("""
UPDATE org SET byor_export_enabled = TRUE
WHERE id IN (
SELECT DISTINCT org_id FROM billing_sessions
WHERE status = 'completed' AND org_id IS NOT NULL
)
""")
)
def downgrade() -> None:
op.drop_column('org', 'byor_export_enabled')

View File

@@ -0,0 +1,29 @@
"""Rename 'user' role to 'member' in role table.
Revision ID: 092
Revises: 091
Create Date: 2025-02-12 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '092'
down_revision: Union[str, None] = '091'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Rename 'user' role to 'member' for clarity
# This avoids confusion between the 'user' role and the 'user' entity/account
op.execute(sa.text("UPDATE role SET name = 'member' WHERE name = 'user'"))
def downgrade() -> None:
# Revert 'member' role back to 'user'
op.execute(sa.text("UPDATE role SET name = 'user' WHERE name = 'member'"))

11769
enterprise/poetry.lock generated

File diff suppressed because one or more lines are too long

View File

@@ -29,7 +29,6 @@ cloud-sql-python-connector = "^1.16.0"
psycopg2-binary = "^2.9.10"
pg8000 = "^1.31.2"
stripe = "^11.5.0"
prometheus-fastapi-instrumentator = "^7.0.2"
python-json-logger = "^3.2.1"
python-keycloak = "^5.3.1"
asyncpg = "^0.30.0"
@@ -44,6 +43,13 @@ coredis = "^4.22.0"
httpx = "*"
scikit-learn = "^1.7.0"
shap = "^0.48.0"
google-cloud-recaptcha-enterprise = "^1.24.0"
# Dependencies previously only in Dockerfile, now managed via poetry.lock
prometheus-client = "^0.24.0"
pandas = "^2.2.0"
numpy = "^2.2.0"
mcp = "^1.10.0"
pillow = "^12.1.0"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.3"

View File

@@ -4,6 +4,10 @@ from dotenv import load_dotenv
load_dotenv()
# Ensure SAAS configuration is used
if not os.getenv('OPENHANDS_CONFIG_CLS'):
os.environ['OPENHANDS_CONFIG_CLS'] = 'server.config.SaaSServerConfig'
import socketio # noqa: E402
from fastapi import Request, status # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
@@ -18,7 +22,6 @@ from server.auth.constants import ( # noqa: E402
)
from server.constants import PERMITTED_CORS_ORIGINS # noqa: E402
from server.logger import logger # noqa: E402
from server.metrics import metrics_app # noqa: E402
from server.middleware import SetAuthCookieMiddleware # noqa: E402
from server.rate_limit import setup_rate_limit_handler # noqa: E402
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
@@ -35,6 +38,7 @@ from server.routes.integration.linear import linear_integration_router # noqa:
from server.routes.integration.slack import slack_router # noqa: E402
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
from server.routes.oauth_device import oauth_device_router # noqa: E402
from server.routes.orgs import org_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.sharing.shared_conversation_router import ( # noqa: E402
@@ -61,9 +65,6 @@ def is_saas():
return {'saas': True}
# This requires a trailing slash to access, like /api/metrics/
base_app.mount('/internal/metrics', metrics_app())
base_app.include_router(readiness_router) # Add routes for readiness checks
base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback
@@ -77,8 +78,15 @@ base_app.include_router(shared_event_router)
# Add GitHub integration router only if GITHUB_APP_CLIENT_ID is set
if GITHUB_APP_CLIENT_ID:
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
from integrations.github.github_v1_callback_processor import ( # noqa: E402
GithubV1CallbackProcessor,
)
from server.routes.integration.github import github_integration_router # noqa: E402
# Bludgeon mypy into not deleting my import
logger.debug(f'Loaded {GithubV1CallbackProcessor.__name__}')
base_app.include_router(
github_integration_router
) # Add additional route for integration webhook events
@@ -90,6 +98,7 @@ if GITLAB_APP_CLIENT_ID:
base_app.include_router(gitlab_integration_router)
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(org_router) # Add routes for organization management
add_github_proxy_routes(base_app)
add_debugging_routes(
base_app

View File

@@ -0,0 +1,320 @@
"""
Permission-based authorization dependencies for API endpoints (SAAS mode).
This module provides FastAPI dependencies for checking user permissions
within organizations. It uses a permission-based authorization model where
roles (owner, admin, member) are mapped to specific permissions.
This is the SAAS/enterprise implementation that performs real authorization
checks against the database.
Usage:
from server.auth.authorization import (
Permission,
require_permission,
require_org_role,
require_org_user,
require_org_admin,
require_org_owner,
)
@router.get('/{org_id}/settings')
async def get_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
):
# Only users with VIEW_LLM_SETTINGS permission can access
...
"""
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.auth import Permission
from openhands.server.user_auth import get_user_id
class RoleName(str, Enum):
"""Role names used in the system."""
OWNER = 'owner'
ADMIN = 'admin'
MEMBER = 'member'
# Permission mappings for each role
ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
RoleName.OWNER: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
Permission.VIEW_LLM_SETTINGS,
Permission.EDIT_LLM_SETTINGS,
Permission.VIEW_BILLING,
Permission.ADD_CREDITS,
# Organization Members
Permission.INVITE_USER_TO_ORGANIZATION,
Permission.CHANGE_USER_ROLE_MEMBER,
Permission.CHANGE_USER_ROLE_ADMIN,
Permission.CHANGE_USER_ROLE_OWNER,
# Organization Management (Owner only)
Permission.CHANGE_ORGANIZATION_NAME,
Permission.DELETE_ORGANIZATION,
]
),
RoleName.ADMIN: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
Permission.VIEW_LLM_SETTINGS,
Permission.EDIT_LLM_SETTINGS,
Permission.VIEW_BILLING,
Permission.ADD_CREDITS,
# Organization Members
Permission.INVITE_USER_TO_ORGANIZATION,
Permission.CHANGE_USER_ROLE_MEMBER,
Permission.CHANGE_USER_ROLE_ADMIN,
]
),
RoleName.MEMBER: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
# LLM Settings (View only)
Permission.VIEW_LLM_SETTINGS,
]
),
}
def get_role_permissions(role_name: str) -> frozenset[Permission]:
"""Get the permissions for a role."""
try:
role_enum = RoleName(role_name)
return ROLE_PERMISSIONS.get(role_enum, frozenset())
except ValueError:
return frozenset()
def get_user_org_role(user_id: str, org_id: UUID) -> Role | None:
"""
Get the user's role in an organization.
Args:
user_id: User ID (string that will be converted to UUID)
org_id: Organization ID
Returns:
Role object if user is a member, None otherwise
"""
from uuid import UUID as parse_uuid
org_member = OrgMemberStore.get_org_member(org_id, parse_uuid(user_id))
if not org_member:
return None
return RoleStore.get_role_by_id(org_member.role_id)
def has_permission(user_role: Role, permission: Permission) -> bool:
"""
Check if a role has a specific permission.
Args:
user_role: User's Role object
permission: Permission to check
Returns:
True if the role has the permission
"""
permissions = get_role_permissions(user_role.name)
return permission in permissions
def has_required_role(user_role: Role, required_role: Role) -> bool:
"""
Check if user's role meets or exceeds the required role.
Uses role hierarchy based on rank where lower rank = higher position
(e.g., rank 1 owner > rank 2 admin > rank 3 user).
Args:
user_role: User's actual Role object
required_role: Minimum required Role object
Returns:
True if user has sufficient permissions
"""
return user_role.rank <= required_role.rank
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
async def get_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
):
...
Args:
permission: The permission required to access the endpoint
Returns:
Dependency function that validates permission and returns user_id
"""
async def permission_checker(
org_id: UUID,
user_id: str | None = Depends(get_user_id),
) -> str:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_role = get_user_org_role(user_id, org_id)
if not user_role:
logger.warning(
'User not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
if not has_permission(user_role, permission):
logger.warning(
'Insufficient permissions',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
'required_permission': permission.value,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Requires {permission.value} permission',
)
return user_id
return permission_checker
def require_org_role(required_role_name: str):
"""
Factory function that creates a dependency to require a minimum org role.
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required role in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Role hierarchy is based on rank from the Role class, where
lower rank = higher position (e.g., rank 1 > rank 2 > rank 3).
Usage:
@router.get('/{org_id}/resource')
async def get_resource(
org_id: UUID,
user_id: str = Depends(require_org_role('user')),
):
...
Args:
required_role_name: Name of the minimum required role to access the endpoint
Returns:
Dependency function that validates role and returns user_id
"""
async def role_checker(
org_id: UUID,
user_id: str | None = Depends(get_user_id),
) -> str:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_role = get_user_org_role(user_id, org_id)
if not user_role:
logger.warning(
'User not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
required_role = RoleStore.get_role_by_name(required_role_name)
if not required_role:
logger.error(
'Required role not found in database',
extra={'required_role': required_role_name},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Role configuration error',
)
if not has_required_role(user_role, required_role):
logger.warning(
'Insufficient role permissions',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
'required_role': required_role_name,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Requires {required_role_name} role or higher',
)
return user_id
return role_checker
# Convenience dependencies for common role checks
require_org_user = require_org_role('user')
require_org_admin = require_org_role('admin')
require_org_owner = require_org_role('owner')

View File

@@ -38,8 +38,18 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
'y',
'on',
)
BLOCKED_EMAIL_DOMAINS = [
domain.strip().lower()
for domain in os.getenv('BLOCKED_EMAIL_DOMAINS', '').split(',')
if domain.strip()
]
DUPLICATE_EMAIL_CHECK = os.getenv('DUPLICATE_EMAIL_CHECK', 'true') in ('1', 'true')
# reCAPTCHA Enterprise
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
# Account Defender labels that indicate suspicious activity
SUSPICIOUS_LABELS = {
'SUSPICIOUS_LOGIN_ACTIVITY',
'SUSPICIOUS_ACCOUNT_CREATION',
'RELATED_ACCOUNTS_NUMBER_HIGH',
}

View File

@@ -1,20 +1,13 @@
from server.auth.constants import BLOCKED_EMAIL_DOMAINS
from storage.blocked_email_domain_store import BlockedEmailDomainStore
from storage.database import session_maker
from openhands.core.logger import openhands_logger as logger
class DomainBlocker:
def __init__(self) -> None:
def __init__(self, store: BlockedEmailDomainStore) -> None:
logger.debug('Initializing DomainBlocker')
self.blocked_domains: list[str] = BLOCKED_EMAIL_DOMAINS
if self.blocked_domains:
logger.info(
f'Successfully loaded {len(self.blocked_domains)} blocked email domains: {self.blocked_domains}'
)
def is_active(self) -> bool:
"""Check if domain blocking is enabled"""
return bool(self.blocked_domains)
self.store = store
def _extract_domain(self, email: str) -> str | None:
"""Extract and normalize email domain from email address"""
@@ -31,16 +24,16 @@ class DomainBlocker:
return None
def is_domain_blocked(self, email: str) -> bool:
"""Check if email domain is blocked
"""Check if email domain is blocked by querying the database directly via SQL.
Supports blocking:
- Exact domains: 'example.com' blocks 'user@example.com'
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
"""
if not self.is_active():
return False
The blocking logic is handled efficiently in SQL, avoiding the need to load
all blocked domains into memory.
"""
if not email:
logger.debug('No email provided for domain check')
return False
@@ -50,26 +43,25 @@ class DomainBlocker:
logger.debug(f'Could not extract domain from email: {email}')
return False
# Check if domain matches any blocked pattern
for blocked_pattern in self.blocked_domains:
if blocked_pattern.startswith('.'):
# TLD pattern (e.g., '.us') - check if domain ends with it
if domain.endswith(blocked_pattern):
logger.warning(
f'Email domain {domain} is blocked by TLD pattern {blocked_pattern} for email: {email}'
)
return True
try:
# Query database directly via SQL to check if domain is blocked
is_blocked = self.store.is_domain_blocked(domain)
if is_blocked:
logger.warning(f'Email domain {domain} is blocked for email: {email}')
else:
# Full domain pattern (e.g., 'example.com')
# Block exact match or subdomains
if domain == blocked_pattern or domain.endswith(f'.{blocked_pattern}'):
logger.warning(
f'Email domain {domain} is blocked by domain pattern {blocked_pattern} for email: {email}'
)
return True
logger.debug(f'Email domain {domain} is not blocked')
logger.debug(f'Email domain {domain} is not blocked')
return False
return is_blocked
except Exception as e:
logger.error(
f'Error checking if domain is blocked for email {email}: {e}',
exc_info=True,
)
# Fail-safe: if database query fails, don't block (allow auth to proceed)
return False
domain_blocker = DomainBlocker()
# Initialize store and domain blocker
_store = BlockedEmailDomainStore(session_maker=session_maker)
domain_blocker = DomainBlocker(store=_store)

View File

@@ -1,12 +1,36 @@
import asyncio
from integrations.gitlab.gitlab_service import SaaSGitLabService
from pydantic import SecretStr
from sqlalchemy import select
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import ProviderType
from openhands.server.types import AppMode
async def _user_has_gitlab_provider(user_id: str) -> bool:
"""Check if the user has authenticated with GitLab.
Args:
user_id: The Keycloak user ID
Returns:
True if the user has a GitLab provider token, False otherwise
"""
# Lazy import to avoid circular dependency issues at module load time
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
async with a_session_maker() as session:
result = await session.execute(
select(AuthTokens).where(
AuthTokens.keycloak_user_id == user_id,
AuthTokens.identity_provider == ProviderType.GITLAB.value,
)
)
return result.scalars().first() is not None
def schedule_gitlab_repo_sync(
user_id: str, keycloak_access_token: SecretStr | None = None
) -> None:
@@ -15,10 +39,26 @@ def schedule_gitlab_repo_sync(
Because the outer call is already a background task, we instruct the service
to store repository data synchronously (store_in_background=False) to avoid
nested background tasks while still keeping the overall operation async.
The sync is only performed if the user has authenticated with GitLab.
"""
async def _run():
try:
# Check if the user has a GitLab provider token before syncing
if not await _user_has_gitlab_provider(user_id):
logger.debug(
'gitlab_repo_sync_skipped: user has no GitLab provider',
extra={'user_id': user_id},
)
return
# Lazy import to avoid circular dependency:
# middleware -> gitlab_sync -> integrations.gitlab.gitlab_service
# -> openhands.integrations.gitlab.gitlab_service -> get_impl
# -> integrations.gitlab.gitlab_service (circular)
from integrations.gitlab.gitlab_service import SaaSGitLabService
service = SaaSGitLabService(
external_auth_id=user_id, external_auth_token=keycloak_access_token
)

View File

@@ -0,0 +1,164 @@
import hashlib
import hmac
from dataclasses import dataclass
from google.cloud import recaptchaenterprise_v1
from server.auth.constants import (
RECAPTCHA_BLOCK_THRESHOLD,
RECAPTCHA_HMAC_SECRET,
RECAPTCHA_PROJECT_ID,
RECAPTCHA_SITE_KEY,
SUSPICIOUS_LABELS,
)
from openhands.core.logger import openhands_logger as logger
@dataclass
class AssessmentResult:
"""Result of a reCAPTCHA Enterprise assessment."""
name: str
score: float
valid: bool
action_valid: bool
reason_codes: list[str]
account_defender_labels: list[str]
allowed: bool
class RecaptchaService:
"""Service for creating reCAPTCHA Enterprise assessments."""
def __init__(self):
self._client = None
self.project_id = RECAPTCHA_PROJECT_ID
self.site_key = RECAPTCHA_SITE_KEY
@property
def client(self):
"""Lazily initialize the reCAPTCHA client to avoid credential errors at import time."""
if self._client is None:
self._client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
return self._client
def hash_account_id(self, email: str) -> str:
"""Hash email using SHA256-HMAC for Account Defender.
Args:
email: The user's email address.
Returns:
Hex-encoded HMAC-SHA256 hash of the lowercase email.
"""
return hmac.new(
RECAPTCHA_HMAC_SECRET.encode(),
email.lower().encode(),
hashlib.sha256,
).hexdigest()
def create_assessment(
self,
token: str,
action: str,
user_ip: str,
user_agent: str,
email: str | None = None,
user_id: str | None = None,
) -> AssessmentResult:
"""Create a reCAPTCHA Enterprise assessment.
Args:
token: The reCAPTCHA token from the frontend.
action: The expected action name (e.g., 'LOGIN').
user_ip: The user's IP address.
user_agent: The user's browser user agent.
email: Optional email for Account Defender hashing.
user_id: Optional Keycloak user ID for logging correlation.
Returns:
AssessmentResult with score, validity, and allowed status.
"""
event = recaptchaenterprise_v1.Event()
event.site_key = self.site_key
event.token = token
event.user_ip_address = user_ip
event.user_agent = user_agent
event.expected_action = action
# Account Defender: use user_info.account_id (hashed_account_id is deprecated)
if email:
user_info = recaptchaenterprise_v1.UserInfo()
user_info.account_id = self.hash_account_id(email)
# Also include email as a user identifier for better fraud detection
user_info.user_ids.append(recaptchaenterprise_v1.UserId(email=email))
event.user_info = user_info
assessment = recaptchaenterprise_v1.Assessment()
assessment.event = event
request = recaptchaenterprise_v1.CreateAssessmentRequest()
request.assessment = assessment
request.parent = f'projects/{self.project_id}'
response = self.client.create_assessment(request)
# Capture assessment name for potential annotation later
# Format: projects/{project_id}/assessments/{assessment_id}
assessment_name = response.name
token_properties = response.token_properties
risk_analysis = response.risk_analysis
score = risk_analysis.score
valid = token_properties.valid
action_valid = token_properties.action == action
reason_codes = [str(r) for r in risk_analysis.reasons]
# Extract Account Defender labels
account_defender_labels = []
if response.account_defender_assessment:
account_defender_labels = [
str(label) for label in response.account_defender_assessment.labels
]
# Check if any suspicious labels are present
has_suspicious_labels = bool(set(account_defender_labels) & SUSPICIOUS_LABELS)
# Block if: invalid token, wrong action, low score, OR suspicious Account Defender labels
allowed = (
valid
and action_valid
and score >= RECAPTCHA_BLOCK_THRESHOLD
and not has_suspicious_labels
)
logger.info(
'recaptcha_assessment',
extra={
'assessment_name': assessment_name,
'score': score,
'valid': valid,
'action_valid': action_valid,
'reasons': reason_codes,
'account_defender_labels': account_defender_labels,
'has_suspicious_labels': has_suspicious_labels,
'allowed': allowed,
'user_ip': user_ip,
'user_id': user_id,
'email': email,
},
)
return AssessmentResult(
name=assessment_name,
score=score,
valid=valid,
action_valid=action_valid,
reason_codes=reason_codes,
account_defender_labels=account_defender_labels,
allowed=allowed,
)
recaptcha_service = RecaptchaService()

View File

@@ -77,6 +77,15 @@ class SaasUserAuth(UserAuth):
self.access_token = SecretStr(tokens['access_token'])
self.refresh_token = SecretStr(tokens['refresh_token'])
self.refreshed = True
if not self.email or not self.email_verified or not self.user_id:
# We don't need to verify the signature here because we just refreshed
# this token from the IDP via token_manager.refresh()
access_token_payload = jwt.decode(
tokens['access_token'], options={'verify_signature': False}
)
self.user_id = access_token_payload['sub']
self.email = access_token_payload['email']
self.email_verified = access_token_payload['email_verified']
def _is_token_expired(self, token: SecretStr):
logger.debug('saas_user_auth_is_token_expired')
@@ -103,7 +112,6 @@ class SaasUserAuth(UserAuth):
return settings
settings_store = await self.get_user_settings_store()
settings = await settings_store.load()
# If load() returned None, should settings be created?
if settings:
settings.email = self.email
settings.email_verified = self.email_verified
@@ -208,9 +216,9 @@ class SaasUserAuth(UserAuth):
async def get_mcp_api_key(self) -> str:
api_key_store = ApiKeyStore.get_instance()
mcp_api_key = api_key_store.retrieve_mcp_api_key(self.user_id)
mcp_api_key = await api_key_store.retrieve_mcp_api_key(self.user_id)
if not mcp_api_key:
mcp_api_key = api_key_store.create_api_key(
mcp_api_key = await api_key_store.create_api_key(
self.user_id, 'MCP_API_KEY', None
)
return mcp_api_key
@@ -274,11 +282,13 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
if not user_id:
return None
offline_token = await token_manager.load_offline_token(user_id)
return SaasUserAuth(
saas_user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
)
await saas_user_auth.refresh()
return saas_user_auth
except Exception as exc:
raise BearerTokenError from exc
@@ -317,7 +327,7 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
email_verified = access_token_payload['email_verified']
# Check if email domain is blocked
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
if email and domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for existing user with email: {email}'
)

View File

@@ -16,9 +16,11 @@ from keycloak.exceptions import (
KeycloakError,
KeycloakPostError,
)
from server.auth.auth_error import ExpiredError
from server.auth.constants import (
BITBUCKET_APP_CLIENT_ID,
BITBUCKET_APP_CLIENT_SECRET,
DUPLICATE_EMAIL_CHECK,
GITHUB_APP_CLIENT_ID,
GITHUB_APP_CLIENT_SECRET,
GITLAB_APP_CLIENT_ID,
@@ -425,6 +427,8 @@ class TokenManager:
access_token = data.get('access_token')
refresh_token = data.get('refresh_token')
if not access_token or not refresh_token:
if data.get('error') == 'bad_refresh_token':
raise ExpiredError()
raise ValueError(
'Failed to refresh token: missing access_token or refresh_token in response.'
)
@@ -646,6 +650,10 @@ class TokenManager:
if not email:
return False
# We have the option to skip the duplicate email check in test environments
if not DUPLICATE_EMAIL_CHECK:
return False
base_email = extract_base_email(email)
if not base_email:
logger.warning(f'Could not extract base email from: {email}')

View File

@@ -8,8 +8,7 @@ import socketio
from server.logger import logger
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.core.config import LLMConfig
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -525,16 +524,18 @@ class ClusteredConversationManager(StandaloneConversationManager):
)
# Look up the user_id from the database
with session_maker() as session:
conversation_metadata = (
session.query(StoredConversationMetadata)
conversation_metadata_saas = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadata.conversation_id
StoredConversationMetadataSaas.conversation_id
== conversation_id
)
.first()
)
user_id = (
conversation_metadata.user_id if conversation_metadata else None
str(conversation_metadata_saas.user_id)
if conversation_metadata_saas
else None
)
# Handle the stopped conversation asynchronously
asyncio.create_task(
@@ -743,6 +744,8 @@ class ClusteredConversationManager(StandaloneConversationManager):
return
# Restart the agent loop
from storage.saas_settings_store import SaasSettingsStore
config = load_openhands_config()
settings_store = await SaasSettingsStore.get_instance(config, user_id)
settings = await settings_store.load()

View File

@@ -17,6 +17,7 @@ from server.auth.constants import (
GITHUB_APP_PRIVATE_KEY,
GITHUB_APP_WEBHOOK_SECRET,
GITLAB_APP_CLIENT_ID,
RECAPTCHA_SITE_KEY,
)
from openhands.core.config.utils import load_openhands_config
@@ -187,4 +188,7 @@ class SaaSServerConfig(ServerConfig):
if self.auth_url:
config['AUTH_URL'] = self.auth_url
if RECAPTCHA_SITE_KEY:
config['RECAPTCHA_SITE_KEY'] = RECAPTCHA_SITE_KEY
return config

View File

@@ -15,12 +15,17 @@ IS_FEATURE_ENV = (
) # Does not include the staging deployment
IS_LOCAL_ENV = bool(HOST == 'localhost')
# Role name constants
ROLE_OWNER = 'owner'
ROLE_ADMIN = 'admin'
ROLE_MEMBER = 'member'
# Deprecated - billing margins are now handled internally in litellm
DEFAULT_BILLING_MARGIN = float(os.environ.get('DEFAULT_BILLING_MARGIN', '1.0'))
# Map of user settings versions to their corresponding default LLM models
# This ensures that CURRENT_USER_SETTINGS_VERSION and LITELLM_DEFAULT_MODEL stay in sync
USER_SETTINGS_VERSION_TO_MODEL = {
# This ensures that PERSONAL_WORKSPACE_VERSION_TO_MODEL and LITELLM_DEFAULT_MODEL stay in sync
PERSONAL_WORKSPACE_VERSION_TO_MODEL = {
1: 'claude-3-5-sonnet-20241022',
2: 'claude-3-7-sonnet-20250219',
3: 'claude-sonnet-4-20250514',
@@ -31,7 +36,8 @@ USER_SETTINGS_VERSION_TO_MODEL = {
LITELLM_DEFAULT_MODEL = os.getenv('LITELLM_DEFAULT_MODEL')
# Current user settings version - this should be the latest key in USER_SETTINGS_VERSION_TO_MODEL
CURRENT_USER_SETTINGS_VERSION = max(USER_SETTINGS_VERSION_TO_MODEL.keys())
ORG_SETTINGS_VERSION = max(PERSONAL_WORKSPACE_VERSION_TO_MODEL.keys())
PERSONAL_WORKSPACE_VERSION = max(PERSONAL_WORKSPACE_VERSION_TO_MODEL.keys())
LITE_LLM_API_URL = os.environ.get(
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
@@ -55,7 +61,6 @@ SUBSCRIPTION_PRICE_DATA = {
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', None)
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')
SLACK_CLIENT_ID = os.environ.get('SLACK_CLIENT_ID', None)
@@ -73,37 +78,23 @@ PERMITTED_CORS_ORIGINS = [
def build_litellm_proxy_model_path(model_name: str) -> str:
"""
Build the LiteLLM proxy model path based on environment and model name.
This utility constructs the full model path for LiteLLM proxy based on:
- Environment type (staging vs prod)
- The provided model name
"""Build the LiteLLM proxy model path based on model name.
Args:
model_name: The base model name (e.g., 'claude-3-7-sonnet-20250219')
Returns:
The full LiteLLM proxy model path (e.g., 'litellm_proxy/prod/claude-3-7-sonnet-20250219')
The full LiteLLM proxy model path (e.g., 'litellm_proxy/claude-3-7-sonnet-20250219')
"""
if 'prod' in model_name or 'litellm' in model_name or 'proxy' in model_name:
if 'litellm' in model_name:
raise ValueError("Only include model name, don't include prefix")
prefix = 'litellm_proxy/'
if not IS_STAGING_ENV and not IS_LOCAL_ENV:
prefix += 'prod/'
return prefix + model_name
return 'litellm_proxy/' + model_name
def get_default_litellm_model():
"""
Construct proxy for litellm model based on user settings and environment type (staging vs prod)
if not set explicitly
"""
"""Construct proxy for litellm model based on user settings if not set explicitly."""
if LITELLM_DEFAULT_MODEL:
return LITELLM_DEFAULT_MODEL
model = USER_SETTINGS_VERSION_TO_MODEL[CURRENT_USER_SETTINGS_VERSION]
model = PERSONAL_WORKSPACE_VERSION_TO_MODEL[PERSONAL_WORKSPACE_VERSION]
return build_litellm_proxy_model_path(model)

View File

@@ -14,7 +14,6 @@ from storage.conversation_callback import (
ConversationCallback,
ConversationCallbackProcessor,
)
from storage.database import session_maker
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
@@ -108,13 +107,10 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
f'[GitHub] Sent summary instruction to conversation {conversation_id} {summary_event}'
)
# Update the processor state
# Update the processor state - the outer session will commit this
self.send_summary_instruction = False
callback.set_processor(self)
callback.updated_at = datetime.now()
with session_maker() as session:
session.merge(callback)
session.commit()
return
# Extract the summary from the event store
@@ -130,14 +126,15 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
logger.info(f'[GitHub] Summary sent for conversation {conversation_id}')
# Mark callback as completed status
# Mark callback as completed status - the outer session will commit this
callback.status = CallbackStatus.COMPLETED
callback.updated_at = datetime.now()
with session_maker() as session:
session.merge(callback)
session.commit()
except Exception as e:
logger.exception(
f'[GitHub] Error processing conversation callback: {str(e)}'
)
# Mark callback as error to prevent infinite re-invocation
# The outer session will commit this
callback.status = CallbackStatus.ERROR
callback.updated_at = datetime.now()

View File

@@ -0,0 +1,68 @@
"""
Email domain validation utilities for enterprise endpoints.
"""
from fastapi import Depends, HTTPException, Request, status
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_auth, get_user_id
async def get_admin_user_id(
request: Request, user_id: str | None = Depends(get_user_id)
) -> str:
"""
Dependency that validates user has @openhands.dev email domain.
This dependency can be used in place of get_user_id for endpoints that
should only be accessible to admin users. Currently, this is implemented
by checking for @openhands.dev email domain.
TODO: In the future, this should be replaced with an explicit is_admin flag
in user/org settings instead of relying on email domain validation.
Args:
request: FastAPI request object
user_id: User ID from get_user_id dependency
Returns:
str: User ID if email domain is valid
Raises:
HTTPException: 403 if email domain is not @openhands.dev
HTTPException: 401 if user is not authenticated
Example:
@router.post('/endpoint')
async def create_resource(
user_id: str = Depends(get_admin_user_id),
):
# Only admin users can access this endpoint
pass
"""
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_auth = await get_user_auth(request)
user_email = await user_auth.get_user_email()
if not user_email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User email not available',
)
if not user_email.endswith('@openhands.dev'):
logger.warning(
'Access denied - invalid email domain',
extra={'user_id': user_id, 'email_domain': user_email.split('@')[-1]},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Access restricted to @openhands.dev users',
)
return user_id

View File

@@ -44,11 +44,13 @@ class MyProcessor(MaintenanceTaskProcessor):
### UserVersionUpgradeProcessor
Located in `user_version_upgrade_processor.py`, this processor:
- Handles up to 100 user IDs per task
- Upgrades users with `user_version < CURRENT_USER_SETTINGS_VERSION`
- Upgrades users with `user_version < ORG_SETTINGS_VERSION`
- Uses `SaasSettingsStore.create_default_settings()` for upgrades
**Usage:**
```python
from server.maintenance_task_processor.user_version_upgrade_processor import UserVersionUpgradeProcessor
@@ -144,22 +146,26 @@ task = create_maintenance_task(
## Best Practices
### Processor Design
- Keep tasks short-running (under 1 minute)
- Handle errors gracefully and return meaningful error information
- Use batch processing for large datasets
- Include progress information in the return dict
### Error Handling
- Always wrap your processor logic in try-catch blocks
- Return structured error information
- Log important events for debugging
### Performance
- Limit batch sizes to avoid long-running tasks
- Use database sessions efficiently
- Consider memory usage for large datasets
### Testing
- Create unit tests for your processors
- Test error conditions
- Verify the processor serialization/deserialization works correctly
@@ -167,6 +173,7 @@ task = create_maintenance_task(
## Database Patterns
The maintenance task system follows the repository's established patterns:
- Uses `session_maker()` for database operations
- Wraps sync database operations in `call_sync_from_async` for async routes
- Follows proper SQLAlchemy query patterns
@@ -174,15 +181,18 @@ The maintenance task system follows the repository's established patterns:
## Integration with Existing Systems
### User Management
- Integrates with the existing `UserSettings` model
- Uses the current user versioning system (`CURRENT_USER_SETTINGS_VERSION`)
- Uses the current user versioning system (`ORG_SETTINGS_VERSION`)
- Maintains compatibility with existing user management workflows
### Authentication
- Admin endpoints use the existing SaaS authentication system
- Requires users to have `admin = True` in their UserSettings
### Monitoring
- Tasks are logged with structured information
- Status updates are tracked in the database
- Error information is preserved for debugging
@@ -206,6 +216,7 @@ The maintenance task system follows the repository's established patterns:
## Future Enhancements
Potential improvements that could be added:
- Task dependencies and scheduling
- Retry mechanisms for failed tasks
- Real-time progress updates

View File

@@ -1,155 +0,0 @@
from __future__ import annotations
from typing import List
from server.constants import CURRENT_USER_SETTINGS_VERSION
from server.logger import logger
from storage.database import session_maker
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskProcessor
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
from openhands.core.config import load_openhands_config
class UserVersionUpgradeProcessor(MaintenanceTaskProcessor):
"""
Processor for upgrading user settings to the current version.
This processor takes a list of user IDs and upgrades any users
whose user_version is less than CURRENT_USER_SETTINGS_VERSION.
"""
user_ids: List[str]
async def __call__(self, task: MaintenanceTask) -> dict:
"""
Process user version upgrades for the specified user IDs.
Args:
task: The maintenance task being processed
Returns:
dict: Results containing successful and failed user IDs
"""
logger.info(
'user_version_upgrade_processor:start',
extra={
'task_id': task.id,
'user_count': len(self.user_ids),
'current_version': CURRENT_USER_SETTINGS_VERSION,
},
)
if len(self.user_ids) > 100:
raise ValueError(
f'Too many user IDs: {len(self.user_ids)}. Maximum is 100.'
)
config = load_openhands_config()
# Track results
successful_upgrades = []
failed_upgrades = []
users_already_current = []
# Find users that need upgrading
with session_maker() as session:
users_to_upgrade = (
session.query(UserSettings)
.filter(
UserSettings.keycloak_user_id.in_(self.user_ids),
UserSettings.user_version < CURRENT_USER_SETTINGS_VERSION,
)
.all()
)
# Track users that are already current
users_needing_upgrade_ids = {u.keycloak_user_id for u in users_to_upgrade}
users_already_current = [
uid for uid in self.user_ids if uid not in users_needing_upgrade_ids
]
logger.info(
'user_version_upgrade_processor:found_users',
extra={
'task_id': task.id,
'users_to_upgrade': len(users_to_upgrade),
'users_already_current': len(users_already_current),
'total_requested': len(self.user_ids),
},
)
# Process each user that needs upgrading
for user_settings in users_to_upgrade:
user_id = user_settings.keycloak_user_id
old_version = user_settings.user_version
try:
logger.info(
'user_version_upgrade_processor:upgrading_user',
extra={
'task_id': task.id,
'user_id': user_id,
'old_version': old_version,
'new_version': CURRENT_USER_SETTINGS_VERSION,
},
)
# Create SaasSettingsStore instance and upgrade
settings_store = await SaasSettingsStore.get_instance(config, user_id)
await settings_store.create_default_settings(user_settings)
successful_upgrades.append(
{
'user_id': user_id,
'old_version': old_version,
'new_version': CURRENT_USER_SETTINGS_VERSION,
}
)
logger.info(
'user_version_upgrade_processor:user_upgraded',
extra={
'task_id': task.id,
'user_id': user_id,
'old_version': old_version,
'new_version': CURRENT_USER_SETTINGS_VERSION,
},
)
except Exception as e:
failed_upgrades.append(
{'user_id': user_id, 'old_version': old_version, 'error': str(e)}
)
logger.error(
'user_version_upgrade_processor:user_upgrade_failed',
extra={
'task_id': task.id,
'user_id': user_id,
'old_version': old_version,
'error': str(e),
},
)
# Create result summary
result = {
'total_users': len(self.user_ids),
'users_already_current': users_already_current,
'successful_upgrades': successful_upgrades,
'failed_upgrades': failed_upgrades,
'summary': (
f'Processed {len(self.user_ids)} users: '
f'{len(successful_upgrades)} upgraded, '
f'{len(users_already_current)} already current, '
f'{len(failed_upgrades)} errors'
),
}
logger.info(
'user_version_upgrade_processor:completed',
extra={'task_id': task.id, 'result': result},
)
return result

View File

@@ -1,7 +1,5 @@
from typing import TYPE_CHECKING
from storage.api_key_store import ApiKeyStore
if TYPE_CHECKING:
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -24,7 +22,7 @@ from openhands.core.logger import openhands_logger as logger
# NOTE: these details are specific to the MCP protocol
class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
@staticmethod
def create_default_mcp_server_config(
async def create_default_mcp_server_config(
host: str, config: 'OpenHandsConfig', user_id: str | None = None
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
"""
@@ -36,13 +34,16 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
Returns:
A tuple containing the default SSE server configuration and a list of MCP stdio server configurations
"""
from storage.api_key_store import ApiKeyStore
api_key_store = ApiKeyStore.get_instance()
if user_id:
api_key = api_key_store.retrieve_mcp_api_key(user_id)
api_key = await api_key_store.retrieve_mcp_api_key(user_id)
if not api_key:
api_key = api_key_store.create_api_key(user_id, 'MCP_API_KEY', None)
api_key = await api_key_store.create_api_key(
user_id, 'MCP_API_KEY', None
)
if not api_key:
logger.error(f'Could not provision MCP API Key for user: {user_id}')

View File

@@ -1,43 +0,0 @@
from typing import Callable
from prometheus_client import Gauge, make_asgi_app
from server.clustered_conversation_manager import ClusteredConversationManager
from openhands.server.shared import (
conversation_manager,
)
RUNNING_AGENT_LOOPS_GAUGE = Gauge(
'saas_running_agent_loops',
'Count of running agent loops, aggregate by session_id to dedupe',
['session_id'],
)
async def _update_metrics():
"""Update any prometheus metrics that are not updated during normal operation."""
if isinstance(conversation_manager, ClusteredConversationManager):
running_agent_loops = (
await conversation_manager.get_running_agent_loops_locally()
)
# Clear so we don't keep counting old sessions.
# This is theoretically a race condition but this is scraped on a regular interval.
RUNNING_AGENT_LOOPS_GAUGE.clear()
# running_agent_loops shouldn't be None, but can be.
if running_agent_loops is not None:
for sid in running_agent_loops:
RUNNING_AGENT_LOOPS_GAUGE.labels(session_id=sid).set(1)
def metrics_app() -> Callable:
metrics_callable = make_asgi_app()
async def wrapped_handler(scope, receive, send):
"""
Call _update_metrics before serving Prometheus metrics endpoint.
Not wrapped in a `try`, failing would make metrics endpoint unavailable.
"""
await _update_metrics()
await metrics_callable(scope, receive, send)
return wrapped_handler

View File

@@ -103,7 +103,7 @@ class SetAuthCookieMiddleware:
keycloak_auth_cookie = request.cookies.get('keycloak_auth')
auth_header = request.headers.get('Authorization')
mcp_auth_header = request.headers.get('X-Session-API-Key')
accepted_tos = False
accepted_tos: bool | None = False
if (
keycloak_auth_cookie is None
and (auth_header is None or not auth_header.startswith('Bearer '))
@@ -144,7 +144,7 @@ class SetAuthCookieMiddleware:
# "if accepted_tos is not None" as there should not be any users with
# accepted_tos equal to "None"
if accepted_tos is False and request.url.path != '/api/accept_tos':
logger.error('User has not accepted the terms of service')
logger.warning('User has not accepted the terms of service')
raise TosNotAcceptedError
def _should_attach(self, request: Request) -> bool:
@@ -162,10 +162,18 @@ class SetAuthCookieMiddleware:
'/api/email/resend',
'/oauth/device/authorize',
'/oauth/device/token',
'/api/v1/web-client/config',
'/api/v1/webhooks/secrets',
)
if path in ignore_paths:
return False
# Allow public access to shared conversations and events
if path.startswith('/api/shared-conversations') or path.startswith(
'/api/shared-events'
):
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp

View File

@@ -1,113 +1,86 @@
from datetime import UTC, datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from server.config import get_config
from server.constants import (
BYOR_KEY_VERIFICATION_TIMEOUT,
LITE_LLM_API_KEY,
LITE_LLM_API_URL,
)
from storage.api_key_store import ApiKeyStore
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.http_session import httpx_verify_option
# Helper functions for BYOR API key management
async def get_byor_key_from_db(user_id: str) -> str | None:
"""Get the BYOR key from the database for a user."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
user = await UserStore.get_user_by_id_async(user_id)
if not user:
return None
user_db_settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if user_db_settings and user_db_settings.llm_api_key_for_byor:
return user_db_settings.llm_api_key_for_byor
current_org_id = user.current_org_id
current_org_member: OrgMember = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
if current_org_member.llm_api_key_for_byor:
return current_org_member.llm_api_key_for_byor.get_secret_value()
return None
async def store_byor_key_in_db(user_id: str, key: str) -> None:
"""Store the BYOR key in the database for a user."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
user = await UserStore.get_user_by_id_async(user_id)
if not user:
return None
def _update_user_settings():
with session_maker() as session:
user_db_settings = settings_store.get_user_settings_by_keycloak_id(
user_id, session
)
if user_db_settings:
user_db_settings.llm_api_key_for_byor = key
session.commit()
logger.info(
'Successfully stored BYOR key in user settings',
extra={'user_id': user_id},
)
else:
logger.warning(
'User settings not found when trying to store BYOR key',
extra={'user_id': user_id},
)
await call_sync_from_async(_update_user_settings)
current_org_id = user.current_org_id
current_org_member: OrgMember = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
current_org_member.llm_api_key_for_byor = key
OrgMemberStore.update_org_member(current_org_member)
async def generate_byor_key(user_id: str) -> str | None:
"""Generate a new BYOR key for a user."""
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
logger.warning(
'LiteLLM API configuration not found', extra={'user_id': user_id}
)
return None
try:
async with httpx.AsyncClient(
verify=httpx_verify_option(),
headers={
'x-goog-api-key': LITE_LLM_API_KEY,
},
) as client:
response = await client.post(
f'{LITE_LLM_API_URL}/key/generate',
json={
user = await UserStore.get_user_by_id_async(user_id)
if not user:
return None
current_org_id = str(user.current_org_id)
key = await LiteLlmManager.generate_key(
user_id,
current_org_id,
f'BYOR Key - user {user_id}, org {current_org_id}',
{'type': 'byor'},
)
if key:
logger.info(
'Successfully generated new BYOR key',
extra={
'user_id': user_id,
'metadata': {'type': 'byor'},
'key_alias': f'BYOR Key - user {user_id}',
'key_length': len(key) if key else 0,
'key_prefix': key[:10] + '...' if key and len(key) > 10 else key,
},
)
response.raise_for_status()
response_json = response.json()
key = response_json.get('key')
if key:
logger.info(
'Successfully generated new BYOR key',
extra={
'user_id': user_id,
'key_length': len(key) if key else 0,
'key_prefix': key[:10] + '...'
if key and len(key) > 10
else key,
},
)
return key
else:
logger.error(
'Failed to generate BYOR LLM API key - no key in response',
extra={'user_id': user_id, 'response_json': response_json},
)
return None
return key
else:
logger.error(
'Failed to generate BYOR LLM API key - no key in response',
extra={'user_id': user_id},
)
return None
except Exception as e:
logger.exception(
'Error generating BYOR key',
@@ -116,96 +89,25 @@ async def generate_byor_key(user_id: str) -> str | None:
return None
async def verify_byor_key_in_litellm(byor_key: str, user_id: str) -> bool:
"""Verify that a BYOR key is valid in LiteLLM by making a lightweight API call.
Args:
byor_key: The BYOR key to verify
user_id: The user ID for logging purposes
Returns:
True if the key is verified as valid, False if verification fails or key is invalid.
Returns False on network errors/timeouts to ensure we don't return potentially invalid keys.
"""
if not (LITE_LLM_API_URL and byor_key):
return False
try:
async with httpx.AsyncClient(
verify=httpx_verify_option(),
timeout=BYOR_KEY_VERIFICATION_TIMEOUT,
) as client:
# Make a lightweight request to verify the key
# Using /v1/models endpoint as it's lightweight and requires authentication
response = await client.get(
f'{LITE_LLM_API_URL}/v1/models',
headers={
'Authorization': f'Bearer {byor_key}',
},
)
# Only 200 status code indicates valid key
if response.status_code == 200:
logger.debug(
'BYOR key verification successful',
extra={'user_id': user_id},
)
return True
# All other status codes (401, 403, 500, etc.) are treated as invalid
# This includes authentication errors and server errors
logger.warning(
'BYOR key verification failed - treating as invalid',
extra={
'user_id': user_id,
'status_code': response.status_code,
'key_prefix': byor_key[:10] + '...'
if len(byor_key) > 10
else byor_key,
},
)
return False
except (httpx.TimeoutException, Exception) as e:
# Any exception (timeout, network error, etc.) means we can't verify
# Return False to trigger regeneration rather than returning potentially invalid key
logger.warning(
'BYOR key verification error - treating as invalid to ensure key validity',
extra={
'user_id': user_id,
'error': str(e),
'error_type': type(e).__name__,
},
)
return False
async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool:
"""Delete the BYOR key from LiteLLM using the key directly."""
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
logger.warning(
'LiteLLM API configuration not found', extra={'user_id': user_id}
)
return False
"""Delete the BYOR key from LiteLLM using the key directly.
Also attempts to delete by key alias if the key is not found,
to clean up orphaned aliases that could block key regeneration.
"""
try:
async with httpx.AsyncClient(
verify=httpx_verify_option(),
headers={
'x-goog-api-key': LITE_LLM_API_KEY,
},
) as client:
# Delete the key directly using the key value
delete_url = f'{LITE_LLM_API_URL}/key/delete'
delete_payload = {'keys': [byor_key]}
# Get user to construct the key alias
user = await UserStore.get_user_by_id_async(user_id)
key_alias = None
if user and user.current_org_id:
key_alias = f'BYOR Key - user {user_id}, org {user.current_org_id}'
delete_response = await client.post(delete_url, json=delete_payload)
delete_response.raise_for_status()
logger.info(
'Successfully deleted BYOR key from LiteLLM',
extra={'user_id': user_id},
)
return True
await LiteLlmManager.delete_key(byor_key, key_alias=key_alias)
logger.info(
'Successfully deleted BYOR key from LiteLLM',
extra={'user_id': user_id},
)
return True
except Exception as e:
logger.exception(
'Error deleting BYOR key from LiteLLM',
@@ -246,15 +148,35 @@ class LlmApiKeyResponse(BaseModel):
key: str | None
class ByorPermittedResponse(BaseModel):
permitted: bool
@api_router.get('/llm/byor/permitted', response_model=ByorPermittedResponse)
async def check_byor_permitted(user_id: str = Depends(get_user_id)):
"""Check if BYOR key export is permitted for the user's current org."""
try:
permitted = await OrgService.check_byor_export_enabled(user_id)
return {'permitted': permitted}
except Exception as e:
logger.exception(
'Error checking BYOR export permission', extra={'error': str(e)}
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to check BYOR export permission',
)
@api_router.post('', response_model=ApiKeyCreateResponse)
async def create_api_key(key_data: ApiKeyCreate, user_id: str = Depends(get_user_id)):
"""Create a new API key for the authenticated user."""
try:
api_key = api_key_store.create_api_key(
api_key = await api_key_store.create_api_key(
user_id, key_data.name, key_data.expires_at
)
# Get the created key details
keys = api_key_store.list_api_keys(user_id)
keys = await api_key_store.list_api_keys(user_id)
for key in keys:
if key['name'] == key_data.name:
return {
@@ -282,7 +204,7 @@ async def create_api_key(key_data: ApiKeyCreate, user_id: str = Depends(get_user
async def list_api_keys(user_id: str = Depends(get_user_id)):
"""List all API keys for the authenticated user."""
try:
keys = api_key_store.list_api_keys(user_id)
keys = await api_key_store.list_api_keys(user_id)
return [
{
**key,
@@ -311,7 +233,7 @@ async def delete_api_key(key_id: int, user_id: str = Depends(get_user_id)):
"""Delete an API key."""
try:
# First, verify the key belongs to the user
keys = api_key_store.list_api_keys(user_id)
keys = await api_key_store.list_api_keys(user_id)
key_to_delete = None
for key in keys:
@@ -351,13 +273,22 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
This endpoint validates that the key exists in LiteLLM before returning it.
If validation fails, it automatically generates a new key to ensure users
always receive a working key.
Returns 402 Payment Required if BYOR export is not enabled for the user's org.
"""
try:
# Check if BYOR export is enabled for the user's org
if not await OrgService.check_byor_export_enabled(user_id):
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
)
# Check if the BYOR key exists in the database
byor_key = await get_byor_key_from_db(user_id)
if byor_key:
# Validate that the key is actually registered in LiteLLM
is_valid = await verify_byor_key_in_litellm(byor_key, user_id)
is_valid = await LiteLlmManager.verify_key(byor_key, user_id)
if is_valid:
return {'key': byor_key}
else:
@@ -408,17 +339,18 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
@api_router.post('/llm/byor/refresh', response_model=LlmApiKeyResponse)
async def refresh_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user."""
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user.
Returns 402 Payment Required if BYOR export is not enabled for the user's org.
"""
logger.info('Starting BYOR LLM API key refresh', extra={'user_id': user_id})
try:
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
logger.warning(
'LiteLLM API configuration not found', extra={'user_id': user_id}
)
# Check if BYOR export is enabled for the user's org
if not await OrgService.check_byor_export_enabled(user_id):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='LiteLLM API configuration not found',
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
)
# Get the existing BYOR key from the database

View File

@@ -1,3 +1,6 @@
import base64
import json
import uuid
import warnings
from datetime import datetime, timezone
from typing import Annotated, Literal, Optional
@@ -12,18 +15,20 @@ from server.auth.constants import (
KEYCLOAK_CLIENT_ID,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL_EXT,
RECAPTCHA_SITE_KEY,
ROLE_CHECK_ENABLED,
)
from server.auth.domain_blocker import domain_blocker
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.recaptcha_service import recaptcha_service
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.config import get_config, sign_token
from server.config import sign_token
from server.constants import IS_FEATURE_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
from storage.user import User
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
@@ -83,7 +88,8 @@ def get_cookie_domain(request: Request) -> str | None:
# for now just use the full hostname except for staging stacks.
return (
None
if (request.url.hostname or '').endswith('staging.all-hand.dev')
if not request.url.hostname
or request.url.hostname.endswith('staging.all-hands.dev')
else request.url.hostname
)
@@ -98,6 +104,24 @@ def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
)
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
"""Extract redirect URL and reCAPTCHA token from OAuth state.
Returns:
Tuple of (redirect_url, recaptcha_token). Token may be None.
"""
if not state:
return '', None
try:
# Try to decode as JSON (new format with reCAPTCHA)
state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
return state_data.get('redirect_url', ''), state_data.get('recaptcha_token')
except Exception:
# Old format - state is just the redirect URL
return state, None
@oauth_router.get('/keycloak/callback')
async def keycloak_callback(
request: Request,
@@ -106,7 +130,11 @@ async def keycloak_callback(
error: Optional[str] = None,
error_description: Optional[str] = None,
):
redirect_url: str = state if state else str(request.base_url)
# Extract redirect URL and reCAPTCHA token from state
redirect_url, recaptcha_token = _extract_recaptcha_state(state)
if not redirect_url:
redirect_url = str(request.base_url)
if not code:
# check if this is a forward from the account linking page
if (
@@ -148,10 +176,74 @@ async def keycloak_callback(
email = user_info.get('email')
user_id = user_info['sub']
user = await UserStore.get_user_by_id_async(user_id)
if not user:
user = await UserStore.create_user(user_id, user_info)
else:
# Existing user — gradually backfill contact_name if it still has a username-style value
await UserStore.backfill_contact_name(user_id, user_info)
if not user:
logger.error(f'Failed to authenticate user {user_info["preferred_username"]}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
'error': f'Failed to authenticate user {user_info["preferred_username"]}'
},
)
logger.info(f'Logging in user {str(user.id)} in org {user.current_org_id}')
# reCAPTCHA verification with Account Defender
if RECAPTCHA_SITE_KEY:
if not recaptcha_token:
logger.warning(
'recaptcha_token_missing',
extra={
'user_id': user_id,
'email': email,
},
)
error_url = f'{request.base_url}login?recaptcha_blocked=true'
return RedirectResponse(error_url, status_code=302)
user_ip = request.client.host if request.client else 'unknown'
user_agent = request.headers.get('User-Agent', '')
# Handle X-Forwarded-For for proxied requests
forwarded_for = request.headers.get('X-Forwarded-For')
if forwarded_for:
user_ip = forwarded_for.split(',')[0].strip()
try:
result = recaptcha_service.create_assessment(
token=recaptcha_token,
action='LOGIN',
user_ip=user_ip,
user_agent=user_agent,
email=email,
user_id=user_id,
)
if not result.allowed:
logger.warning(
'recaptcha_blocked_at_callback',
extra={
'user_ip': user_ip,
'score': result.score,
'user_id': user_id,
},
)
# Redirect to home with error parameter
error_url = f'{request.base_url}login?recaptcha_blocked=true'
return RedirectResponse(error_url, status_code=302)
except Exception as e:
logger.exception(f'reCAPTCHA verification error at callback: {e}')
# Fail open - continue with login if reCAPTCHA service unavailable
# Check if email domain is blocked
email = user_info.get('email')
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
if email and domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'
)
@@ -193,7 +285,7 @@ async def keycloak_callback(
)
# Redirect to home page with query parameter indicating the issue
home_url = f'{request.base_url}?duplicated_email=true'
home_url = f'{request.base_url}/login?duplicated_email=true'
return RedirectResponse(home_url, status_code=302)
except Exception as e:
# Log error but allow signup to proceed (fail open)
@@ -210,9 +302,7 @@ async def keycloak_callback(
from server.routes.email import verify_email
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
redirect_url = (
f'{request.base_url}?email_verification_required=true&user_id={user_id}'
)
redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
response = RedirectResponse(redirect_url, status_code=302)
return response
@@ -290,15 +380,7 @@ async def keycloak_callback(
f'&state={state}'
)
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
user_settings = settings_store.get_user_settings_by_keycloak_id(user_id)
has_accepted_tos = (
user_settings is not None and user_settings.accepted_tos is not None
)
has_accepted_tos = user.accepted_tos is not None
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:
encoded_redirect_url = quote(redirect_url, safe='')
@@ -416,28 +498,20 @@ async def accept_tos(request: Request):
redirect_url = body.get('redirect_url', str(request.base_url))
# Update user settings with TOS acceptance
accepted_tos: datetime = datetime.now(timezone.utc)
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
if user_settings:
user_settings.accepted_tos = datetime.now(timezone.utc)
session.merge(user_settings)
else:
# Create user settings if they don't exist
user_settings = UserSettings(
keycloak_user_id=user_id,
accepted_tos=datetime.now(timezone.utc),
user_version=0, # This will trigger a migration to the latest version on next load
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
if not user:
session.rollback()
logger.error('User for {user_id} not found.')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'User does not exist'},
)
session.add(user_settings)
user.accepted_tos = accepted_tos
session.commit()
logger.info(f'User {user_id} accepted TOS')
logger.info(f'User {user_id} accepted TOS')
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}

View File

@@ -4,63 +4,43 @@ from datetime import UTC, datetime
from decimal import Decimal
from enum import Enum
import httpx
import stripe
from dateutil.relativedelta import relativedelta # type: ignore
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.responses import RedirectResponse
from integrations import stripe_service
from pydantic import BaseModel
from server.config import get_config
from server.constants import (
LITE_LLM_API_KEY,
LITE_LLM_API_URL,
STRIPE_API_KEY,
STRIPE_WEBHOOK_SECRET,
SUBSCRIPTION_PRICE_DATA,
get_default_litellm_model,
)
from server.logger import logger
from starlette.datastructures import URL
from storage.billing_session import BillingSession
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.lite_llm_manager import LiteLlmManager
from storage.org import Org
from storage.subscription_access import SubscriptionAccess
from storage.user_store import UserStore
from openhands.app_server.config import get_global_config
from openhands.server.user_auth import get_user_id
from openhands.utils.http_session import httpx_verify_option
stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing')
# TODO: Add a new app_mode named "ON_PREM" to support self-hosted customers instead of doing this
# and members should comment out the "validate_saas_environment" function if they are developing and testing locally.
def is_all_hands_saas_environment(request: Request) -> bool:
"""Check if the current domain is an All Hands SaaS environment.
Args:
request: FastAPI Request object
Returns:
True if the current domain contains "all-hands.dev" or "openhands.dev" postfix
async def validate_billing_enabled() -> None:
"""
hostname = request.url.hostname or ''
return hostname.endswith('all-hands.dev') or hostname.endswith('openhands.dev')
def validate_saas_environment(request: Request) -> None:
"""Validate that the request is coming from an All Hands SaaS environment.
Args:
request: FastAPI Request object
Raises:
HTTPException: If the request is not from an All Hands SaaS environment
Validate that the billing feature flag is enabled
"""
if not is_all_hands_saas_environment(request):
config = get_global_config()
web_client_config = await config.web_client.get_web_client_config()
if not web_client_config.feature_flags.enable_billing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Checkout sessions are only available for All Hands SaaS environments',
detail=(
'Billing is disabled in this environment. '
'Please set OH_WEB_CLIENT_FEATURE_FLAGS_ENABLE_BILLING to enable billing.'
),
)
@@ -106,29 +86,20 @@ def calculate_credits(user_info: LiteLlmUserInfo) -> float:
return max(max_budget - spend, 0.0)
# Endpoint to retrieve user's current credit balance
# Endpoint to retrieve the current organization's credit balance
@billing_router.get('/credits')
async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse:
if not stripe_service.STRIPE_API_KEY:
return GetCreditsResponse()
try:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
user_json = await _get_litellm_user(client, user_id)
credits = calculate_credits(user_json['user_info'])
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
except httpx.HTTPStatusError as e:
logger.error(
f'litellm_get_user_failed: {type(e).__name__}: {e}',
extra={
'user_id': user_id,
'status_code': e.response.status_code,
},
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve credit balance from billing service',
)
user = await UserStore.get_user_by_id_async(user_id)
user_team_info = await LiteLlmManager.get_user_team_info(
user_id, str(user.current_org_id)
)
# Update to use calculate_credits
spend = user_team_info.get('spend', 0)
max_budget = (user_team_info.get('litellm_budget_table') or {}).get('max_budget', 0)
credits = max(max_budget - spend, 0)
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
# Endpoint to retrieve user's current subscription access
@@ -163,79 +134,7 @@ async def get_subscription_access(
async def has_payment_method(user_id: str = Depends(get_user_id)) -> bool:
if not user_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
return await stripe_service.has_payment_method(user_id)
# Endpoint to cancel user's subscription
@billing_router.post('/cancel-subscription')
async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONResponse:
"""Cancel user's active subscription at the end of the current billing period."""
if not user_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
with session_maker() as session:
# Find the user's active subscription
now = datetime.now(UTC)
subscription_access = (
session.query(SubscriptionAccess)
.filter(SubscriptionAccess.status == 'ACTIVE')
.filter(SubscriptionAccess.user_id == user_id)
.filter(SubscriptionAccess.start_at <= now)
.filter(SubscriptionAccess.end_at >= now)
.filter(SubscriptionAccess.cancelled_at.is_(None)) # Not already cancelled
.first()
)
if not subscription_access:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No active subscription found',
)
if not subscription_access.stripe_subscription_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot cancel subscription: missing Stripe subscription ID',
)
try:
# Cancel the subscription in Stripe at period end
await stripe.Subscription.modify_async(
subscription_access.stripe_subscription_id, cancel_at_period_end=True
)
# Update local database
subscription_access.cancelled_at = datetime.now(UTC)
session.merge(subscription_access)
session.commit()
logger.info(
'subscription_cancelled',
extra={
'user_id': user_id,
'stripe_subscription_id': subscription_access.stripe_subscription_id,
'subscription_access_id': subscription_access.id,
'end_at': subscription_access.end_at,
},
)
return JSONResponse(
{'status': 'success', 'message': 'Subscription cancelled successfully'}
)
except stripe.StripeError as e:
logger.error(
'stripe_cancellation_failed',
extra={
'user_id': user_id,
'stripe_subscription_id': subscription_access.stripe_subscription_id,
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to cancel subscription: {str(e)}',
)
return await stripe_service.has_payment_method_by_user_id(user_id)
# Endpoint to create a new setup intent in stripe
@@ -243,17 +142,17 @@ async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONRespon
async def create_customer_setup_session(
request: Request, user_id: str = Depends(get_user_id)
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
customer_id = await stripe_service.find_or_create_customer(user_id)
await validate_billing_enabled()
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
base_url = _get_base_url(request)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
customer=customer_info['customer_id'],
mode='setup',
payment_method_types=['card'],
success_url=f'{request.base_url}?free_credits=success',
cancel_url=f'{request.base_url}',
success_url=f'{base_url}?free_credits=success',
cancel_url=f'{base_url}',
)
return CreateBillingSessionResponse(redirect_url=checkout_session.url) # type: ignore[arg-type]
return CreateBillingSessionResponse(redirect_url=checkout_session.url)
# Endpoint to create a new Stripe checkout session for credit purchase
@@ -263,11 +162,11 @@ async def create_checkout_session(
request: Request,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
customer_id = await stripe_service.find_or_create_customer(user_id)
await validate_billing_enabled()
base_url = _get_base_url(request)
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
customer=customer_info['customer_id'],
line_items=[
{
'price_data': {
@@ -280,21 +179,22 @@ async def create_checkout_session(
'tax_behavior': 'exclusive',
},
'quantity': 1,
}
},
],
mode='payment',
payment_method_types=['card'],
saved_payment_method_options={
'payment_method_save': 'enabled',
},
success_url=f'{request.base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{request.base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
success_url=f'{base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
)
logger.info(
'created_stripe_checkout_session',
extra={
'stripe_customer_id': customer_id,
'stripe_customer_id': customer_info['customer_id'],
'user_id': user_id,
'org_id': customer_info['org_id'],
'amount': body.amount,
'checkout_session_id': checkout_session.id,
},
@@ -303,105 +203,14 @@ async def create_checkout_session(
billing_session = BillingSession(
id=checkout_session.id,
user_id=user_id,
org_id=customer_info['org_id'],
price=body.amount,
price_code='NA',
billing_session_type=BillingSessionType.DIRECT_PAYMENT.value,
)
session.add(billing_session)
session.commit()
return CreateBillingSessionResponse(redirect_url=checkout_session.url) # type: ignore[arg-type]
@billing_router.post('/subscription-checkout-session')
async def create_subscription_checkout_session(
request: Request,
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
# Prevent duplicate subscriptions for the same user
with session_maker() as session:
now = datetime.now(UTC)
existing_active_subscription = (
session.query(SubscriptionAccess)
.filter(SubscriptionAccess.status == 'ACTIVE')
.filter(SubscriptionAccess.user_id == user_id)
.filter(SubscriptionAccess.start_at <= now)
.filter(SubscriptionAccess.end_at >= now)
.filter(SubscriptionAccess.cancelled_at.is_(None)) # Not cancelled
.first()
)
if existing_active_subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot create subscription: User already has an active subscription that has not been cancelled',
)
customer_id = await stripe_service.find_or_create_customer(user_id)
subscription_price_data = SUBSCRIPTION_PRICE_DATA[billing_session_type.value]
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
line_items=[
{
'price_data': subscription_price_data,
'quantity': 1,
}
],
mode='subscription',
payment_method_types=['card'],
saved_payment_method_options={
'payment_method_save': 'enabled',
},
success_url=f'{request.base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{request.base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
subscription_data={
'metadata': {
'user_id': user_id,
'billing_session_type': billing_session_type.value,
}
},
)
logger.info(
'created_stripe_subscription_checkout_session',
extra={
'stripe_customer_id': customer_id,
'user_id': user_id,
'checkout_session_id': checkout_session.id,
'billing_session_type': billing_session_type.value,
},
)
with session_maker() as session:
billing_session = BillingSession(
id=checkout_session.id,
user_id=user_id,
price=subscription_price_data['unit_amount'],
price_code='NA',
billing_session_type=billing_session_type.value,
)
session.add(billing_session)
session.commit()
return CreateBillingSessionResponse(
redirect_url=typing.cast(str, checkout_session.url)
)
@billing_router.get('/create-subscription-checkout-session')
async def create_subscription_checkout_session_via_get(
request: Request,
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
user_id: str = Depends(get_user_id),
) -> RedirectResponse:
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)."""
validate_saas_environment(request)
response = await create_subscription_checkout_session(
request, billing_session_type, user_id
)
return RedirectResponse(response.redirect_url)
return CreateBillingSessionResponse(redirect_url=checkout_session.url)
# Callback endpoint for successful Stripe payments - updates user credits and billing session status
@@ -423,15 +232,6 @@ async def success_callback(session_id: str, request: Request):
)
raise HTTPException(status.HTTP_400_BAD_REQUEST)
# Any non direct payment (Subscription) is processed in the invoice_payment.paid by the webhook
if (
billing_session.billing_session_type
!= BillingSessionType.DIRECT_PAYMENT.value
):
return RedirectResponse(
f'{request.base_url}settings?checkout=success', status_code=302
)
stripe_session = stripe.checkout.Session.retrieve(session_id)
if stripe_session.status != 'complete':
# Hopefully this never happens - we get a redirect from stripe where the payment is not yet complete
@@ -445,34 +245,46 @@ async def success_callback(session_id: str, request: Request):
)
raise HTTPException(status.HTTP_400_BAD_REQUEST)
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
# Update max budget in litellm
user_json = await _get_litellm_user(client, billing_session.user_id)
amount_subtotal = stripe_session.amount_subtotal or 0
add_credits = amount_subtotal / 100
new_max_budget = (
(user_json.get('user_info') or {}).get('max_budget') or 0
) + add_credits
await _upsert_litellm_user(client, billing_session.user_id, new_max_budget)
user = await UserStore.get_user_by_id_async(billing_session.user_id)
user_team_info = await LiteLlmManager.get_user_team_info(
billing_session.user_id, str(user.current_org_id)
)
amount_subtotal = stripe_session.amount_subtotal or 0
add_credits = amount_subtotal / 100
max_budget = (user_team_info.get('litellm_budget_table') or {}).get(
'max_budget', 0
)
new_max_budget = max_budget + add_credits
# Store transaction status
billing_session.status = 'completed'
billing_session.price = amount_subtotal
billing_session.updated_at = datetime.now(UTC)
session.merge(billing_session)
logger.info(
'stripe_checkout_success',
extra={
'amount_subtotal': stripe_session.amount_subtotal,
'user_id': billing_session.user_id,
'checkout_session_id': billing_session.id,
'stripe_customer_id': stripe_session.customer,
},
)
session.commit()
await LiteLlmManager.update_team_and_users_budget(
str(user.current_org_id), new_max_budget
)
# Enable BYOR export for the org now that they've purchased credits
# Update within the same session to avoid nested session issues
org = session.query(Org).filter(Org.id == user.current_org_id).first()
if org:
org.byor_export_enabled = True
# Store transaction status
billing_session.status = 'completed'
billing_session.price = add_credits
billing_session.updated_at = datetime.now(UTC)
session.merge(billing_session)
logger.info(
'stripe_checkout_success',
extra={
'amount_subtotal': stripe_session.amount_subtotal,
'user_id': billing_session.user_id,
'org_id': str(user.current_org_id),
'checkout_session_id': billing_session.id,
'stripe_customer_id': stripe_session.customer,
},
)
session.commit()
return RedirectResponse(
f'{request.base_url}settings/billing?checkout=success', status_code=302
f'{_get_base_url(request)}settings/billing?checkout=success', status_code=302
)
@@ -499,206 +311,14 @@ async def cancel_callback(session_id: str, request: Request):
session.merge(billing_session)
session.commit()
# Redirect credit purchases to billing screen, subscriptions to LLM settings
if (
billing_session.billing_session_type
== BillingSessionType.DIRECT_PAYMENT.value
):
return RedirectResponse(
f'{request.base_url}settings/billing?checkout=cancel',
status_code=302,
)
else:
return RedirectResponse(
f'{request.base_url}settings?checkout=cancel', status_code=302
)
# If no billing session found, default to LLM settings (subscription flow)
return RedirectResponse(
f'{request.base_url}settings?checkout=cancel', status_code=302
f'{_get_base_url(request)}settings/billing?checkout=cancel', status_code=302
)
@billing_router.post('/stripe-webhook')
async def stripe_webhook(request: Request) -> JSONResponse:
"""Endpoint for stripe webhooks."""
payload = await request.body()
sig_header = request.headers.get('stripe-signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except ValueError as e:
# Invalid payload
raise HTTPException(status_code=400, detail=f'Invalid payload: {e}')
except stripe.SignatureVerificationError as e:
# Invalid signature
raise HTTPException(status_code=400, detail=f'Invalid signature: {e}')
# Handle the event
logger.info('stripe_webhook_event', extra={'event': event})
event_type = event['type']
if event_type == 'invoice.paid':
invoice = event['data']['object']
amount_paid = invoice.amount_paid
metadata = invoice.parent.subscription_details.metadata # type: ignore
billing_session_type = metadata.billing_session_type
assert (
amount_paid == SUBSCRIPTION_PRICE_DATA[billing_session_type]['unit_amount']
)
user_id = metadata.user_id
start_at = datetime.now(UTC)
if billing_session_type == BillingSessionType.MONTHLY_SUBSCRIPTION.value:
end_at = start_at + relativedelta(months=1)
else:
raise ValueError(f'unknown_billing_session_type:{billing_session_type}')
with session_maker() as session:
subscription_access = SubscriptionAccess(
status='ACTIVE',
user_id=user_id,
start_at=start_at,
end_at=end_at,
amount_paid=amount_paid,
stripe_invoice_payment_id=invoice.payment_intent,
stripe_subscription_id=invoice.subscription, # Store Stripe subscription ID
)
session.add(subscription_access)
session.commit()
elif event_type == 'customer.subscription.updated':
subscription = event['data']['object']
subscription_id = subscription['id']
# Handle subscription cancellation
if subscription.get('cancel_at_period_end') is True:
with session_maker() as session:
subscription_access = (
session.query(SubscriptionAccess)
.filter(
SubscriptionAccess.stripe_subscription_id == subscription_id
)
.filter(SubscriptionAccess.status == 'ACTIVE')
.first()
)
if subscription_access and not subscription_access.cancelled_at:
subscription_access.cancelled_at = datetime.now(UTC)
session.merge(subscription_access)
session.commit()
logger.info(
'subscription_cancelled_via_webhook',
extra={
'stripe_subscription_id': subscription_id,
'user_id': subscription_access.user_id,
'subscription_access_id': subscription_access.id,
},
)
elif event_type == 'customer.subscription.deleted':
subscription = event['data']['object']
subscription_id = subscription['id']
with session_maker() as session:
subscription_access = (
session.query(SubscriptionAccess)
.filter(SubscriptionAccess.stripe_subscription_id == subscription_id)
.filter(SubscriptionAccess.status == 'ACTIVE')
.first()
)
if subscription_access:
subscription_access.status = 'DISABLED'
subscription_access.updated_at = datetime.now(UTC)
session.merge(subscription_access)
session.commit()
# Reset user settings to free tier defaults
reset_user_to_free_tier_settings(subscription_access.user_id)
logger.info(
'subscription_expired_reset_to_free_tier',
extra={
'stripe_subscription_id': subscription_id,
'user_id': subscription_access.user_id,
'subscription_access_id': subscription_access.id,
},
)
else:
logger.info('stripe_webhook_unhandled_event_type', extra={'type': event_type})
return JSONResponse({'status': 'success'})
def reset_user_to_free_tier_settings(user_id: str) -> None:
"""Reset user settings to free tier defaults when subscription ends."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
with session_maker() as session:
user_settings = settings_store.get_user_settings_by_keycloak_id(
user_id, session
)
if user_settings:
user_settings.llm_model = get_default_litellm_model()
user_settings.llm_api_key = None
user_settings.llm_api_key_for_byor = None
user_settings.llm_base_url = LITE_LLM_API_URL
user_settings.max_budget_per_task = None
user_settings.confirmation_mode = False
user_settings.enable_solvability_analysis = False
user_settings.security_analyzer = 'llm'
user_settings.agent = 'CodeActAgent'
user_settings.language = 'en'
user_settings.enable_default_condenser = True
user_settings.enable_sound_notifications = False
user_settings.enable_proactive_conversation_starters = True
user_settings.user_consents_to_analytics = False
session.merge(user_settings)
session.commit()
logger.info(
'user_settings_reset_to_free_tier',
extra={
'user_id': user_id,
'reset_timestamp': datetime.now(UTC).isoformat(),
},
)
async def _get_litellm_user(client: httpx.AsyncClient, user_id: str) -> dict:
"""Get a user from litellm with the id matching that given.
If no such user exists, returns a dummy user in the format:
`{'user_id': '<USER_ID>', 'user_info': {'spend': 0}, 'keys': [], 'teams': []}`
"""
response = await client.get(
f'{LITE_LLM_API_URL}/user/info?user_id={user_id}',
headers={
'x-goog-api-key': LITE_LLM_API_KEY,
},
)
response.raise_for_status()
return response.json()
async def _upsert_litellm_user(
client: httpx.AsyncClient, user_id: str, max_budget: float
):
"""Insert / Update a user in litellm."""
response = await client.post(
f'{LITE_LLM_API_URL}/user/update',
headers={
'x-goog-api-key': LITE_LLM_API_KEY,
},
json={
'user_id': user_id,
'max_budget': max_budget,
},
)
response.raise_for_status()
def _get_base_url(request: Request) -> URL:
# Never send any part of the credit card process over a non secure connection
base_url = request.base_url
if base_url.hostname != 'localhost':
base_url = base_url.replace(scheme='https')
return base_url

View File

@@ -5,8 +5,8 @@ from threading import Thread
from fastapi import APIRouter, FastAPI
from sqlalchemy import func, select
from storage.database import a_session_maker, engine, session_maker
from storage.user_settings import UserSettings
from storage.database import a_session_maker, get_engine, session_maker
from storage.user import User
from openhands.core.logger import openhands_logger as logger
from openhands.utils.async_utils import wait_all
@@ -47,6 +47,7 @@ def add_debugging_routes(api: FastAPI):
- checked_out: Number of connections currently in use
- overflow: Number of overflow connections created beyond pool_size
"""
engine = get_engine()
return {
'checked_in': engine.pool.checkedin(),
'checked_out': engine.pool.checkedout(),
@@ -127,8 +128,9 @@ def _db_check(delay: int):
delay: Number of seconds to hold the database connection
"""
with session_maker() as session:
num_users = session.query(UserSettings).count()
num_users = session.query(User).count()
time.sleep(delay)
engine = get_engine()
logger.info(
'check',
extra={
@@ -155,7 +157,7 @@ async def _a_db_check(delay: int):
delay: Number of seconds to hold the database connection
"""
async with a_session_maker() as a_session:
stmt = select(func.count(UserSettings.id))
stmt = select(func.count(User.id))
num_users = await a_session.execute(stmt)
await asyncio.sleep(delay)
logger.info(f'a_num_users:{num_users.scalar_one()}')

View File

@@ -166,7 +166,7 @@ async def verify_email(request: Request, user_id: str, is_auth_flow: bool = Fals
keycloak_admin = get_keycloak_admin()
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
if is_auth_flow:
redirect_uri = f'{scheme}://{request.url.netloc}?email_verified=true'
redirect_uri = f'{scheme}://{request.url.netloc}/login?email_verified=true'
else:
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
logger.info(f'Redirect URI: {redirect_uri}')

View File

@@ -21,7 +21,7 @@ from server.utils.conversation_callback_utils import (
update_conversation_stats,
)
from storage.database import session_maker
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.server.shared import conversation_manager
@@ -226,12 +226,12 @@ def _parse_conversation_id_and_subpath(path: str) -> Tuple[str, str]:
def _get_user_id(conversation_id: str) -> str:
with session_maker() as session:
conversation_metadata = (
session.query(StoredConversationMetadata)
.filter(StoredConversationMetadata.conversation_id == conversation_id)
conversation_metadata_saas = (
session.query(StoredConversationMetadataSaas)
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
.first()
)
return conversation_metadata.user_id
return str(conversation_metadata_saas.user_id)
async def _get_session_api_key(user_id: str, conversation_id: str) -> str | None:

View File

@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.future import select
from storage.database import session_maker
from storage.feedback import ConversationFeedback
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.events.event_store import EventStore
from openhands.server.shared import file_store
@@ -33,10 +33,10 @@ async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:
def _verify_conversation():
with session_maker() as session:
metadata = (
session.query(StoredConversationMetadata)
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadata.conversation_id == conversation_id,
StoredConversationMetadata.user_id == user_id,
StoredConversationMetadataSaas.conversation_id == conversation_id,
StoredConversationMetadataSaas.user_id == user_id,
)
.first()
)

View File

@@ -1,6 +1,7 @@
import hashlib
import json
import os
import zlib
from base64 import b64decode, b64encode
from urllib.parse import parse_qs, urlencode, urlparse
@@ -51,7 +52,11 @@ def add_github_proxy_routes(app: FastAPI):
state_payload = json.dumps(
[query_params['state'][0], query_params['redirect_uri'][0]]
)
state = b64encode(_fernet().encrypt(state_payload.encode())).decode()
# Compress before encrypting to reduce URL length
# This is critical for feature deployments where reCAPTCHA tokens in state
# can cause "URL too long" errors from GitHub
compressed_payload = zlib.compress(state_payload.encode())
state = b64encode(_fernet().encrypt(compressed_payload)).decode()
query_params['state'] = [state]
query_params['redirect_uri'] = [
f'https://{request.url.netloc}/github-proxy/callback'
@@ -67,7 +72,9 @@ def add_github_proxy_routes(app: FastAPI):
parsed_url = urlparse(str(request.url))
query_params = parse_qs(parsed_url.query)
state = query_params['state'][0]
decrypted_state = _fernet().decrypt(b64decode(state.encode())).decode()
# Decrypt and decompress (reverse of github_proxy_start)
decrypted_payload = _fernet().decrypt(b64decode(state.encode()))
decrypted_state = zlib.decompress(decrypted_payload).decode()
# Build query Params
state, redirect_uri = json.loads(decrypted_state)

View File

@@ -1,3 +1,5 @@
import hashlib
import hmac
import json
import os
import re
@@ -5,15 +7,16 @@ import uuid
from urllib.parse import urlparse
import requests
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from integrations.jira.jira_manager import JiraManager
from integrations.models import Message, SourceType
from integrations.utils import HOST_URL
from pydantic import BaseModel, Field, field_validator
from server.auth.constants import JIRA_CLIENT_ID, JIRA_CLIENT_SECRET
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.constants import WEB_HOST
from storage.jira_workspace import JiraWorkspace
from storage.redis import create_redis_client
from openhands.core.logger import openhands_logger as logger
@@ -24,7 +27,7 @@ JIRA_WEBHOOKS_ENABLED = os.environ.get('JIRA_WEBHOOKS_ENABLED', '0') in (
'1',
'true',
)
JIRA_REDIRECT_URI = f'https://{WEB_HOST}/integration/jira/callback'
JIRA_REDIRECT_URI = f'{HOST_URL}/integration/jira/callback'
JIRA_SCOPES = 'read:me read:jira-user read:jira-work'
JIRA_AUTH_URL = 'https://auth.atlassian.com/authorize'
JIRA_TOKEN_URL = 'https://auth.atlassian.com/oauth/token'
@@ -122,6 +125,63 @@ jira_manager = JiraManager(token_manager)
redis_client = create_redis_client()
async def verify_jira_signature(body: bytes, signature: str, payload: dict):
"""
Verify Jira webhook signature.
Args:
body: Raw request body bytes
signature: Signature from x-hub-signature header (format: "sha256=<hash>")
payload: Parsed JSON payload from webhook
Raises:
HTTPException: 403 if signature verification fails or workspace is invalid
Returns:
None (raises exception on failure)
"""
if not signature:
raise HTTPException(
status_code=403, detail='x-hub-signature header is missing!'
)
workspace_name = jira_manager.get_workspace_name_from_payload(payload)
if workspace_name is None:
logger.warning('[Jira] No workspace name found in webhook payload')
raise HTTPException(
status_code=403, detail='Workspace name not found in payload'
)
workspace: (
JiraWorkspace | None
) = await jira_manager.integration_store.get_workspace_by_name(workspace_name)
if workspace is None:
logger.warning(f'[Jira] Could not identify workspace {workspace_name}')
raise HTTPException(status_code=403, detail='Unidentified workspace')
if workspace.status != 'active':
logger.warning(
'[Jira] Workspace is inactive',
extra={
'jira_workspace_id': workspace.id,
'parsed_workspace_name': workspace.name,
'status': workspace.status,
},
)
raise HTTPException(status_code=403, detail='Workspace is inactive')
webhook_secret = token_manager.decrypt_text(workspace.webhook_secret)
expected_signature = hmac.new(
webhook_secret.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_signature, signature):
raise HTTPException(status_code=403, detail="Request signatures didn't match!")
async def _handle_workspace_link_creation(
user_id: str, jira_user_id: str, target_workspace: str
):
@@ -216,6 +276,7 @@ async def _validate_workspace_update_permissions(user_id: str, target_workspace:
async def jira_events(
request: Request,
background_tasks: BackgroundTasks,
x_hub_signature: str = Header(None),
):
"""Handle Jira webhook events."""
# Check if Jira webhooks are enabled
@@ -227,13 +288,15 @@ async def jira_events(
)
try:
signature_valid, signature, payload = await jira_manager.validate_request(
request
)
parts = x_hub_signature.split('=', 1)
if not (len(parts) == 2 and parts[1]):
raise HTTPException(status_code=403, detail='Malformed x-hub-signature!')
if not signature_valid:
logger.warning('[Jira] Invalid webhook signature')
raise HTTPException(status_code=403, detail='Invalid webhook signature!')
signature = parts[1]
body = await request.body()
payload = await request.json()
await verify_jira_signature(body, signature, payload)
# Check for duplicate requests using Redis
key = f'jira:{signature}'

View File

@@ -15,7 +15,6 @@ from integrations.slack.slack_manager import SlackManager
from integrations.utils import (
HOST_URL,
)
from pydantic import SecretStr
from server.auth.constants import (
KEYCLOAK_CLIENT_ID,
KEYCLOAK_REALM_NAME,
@@ -35,6 +34,7 @@ from slack_sdk.web.async_client import AsyncWebClient
from storage.database import session_maker
from storage.slack_team_store import SlackTeamStore
from storage.slack_user import SlackUser
from storage.user_store import UserStore
from openhands.integrations.service_types import ProviderType
from openhands.server.shared import config, sio
@@ -79,6 +79,14 @@ async def install_callback(
status_code=400,
)
if not config.jwt_secret:
logger.error('slack_install_callback_error JWT not configured.')
return _html_response(
title='Error',
description=html.escape('JWT not configured'),
status_code=500,
)
try:
client = AsyncWebClient() # no prepared token needed for this
# Complete the installation by calling oauth.v2.access API method
@@ -94,20 +102,21 @@ async def install_callback(
# Create a state variable for keycloak oauth
payload = {}
jwt_secret: SecretStr = config.jwt_secret # type: ignore[assignment]
if state:
payload = jwt.decode(
state, jwt_secret.get_secret_value(), algorithms=['HS256']
state, config.jwt_secret.get_secret_value(), algorithms=['HS256']
)
payload['slack_user_id'] = authed_user.get('id')
payload['bot_access_token'] = bot_access_token
payload['team_id'] = team_id
state = jwt.encode(payload, jwt_secret.get_secret_value(), algorithm='HS256')
state = jwt.encode(
payload, config.jwt_secret.get_secret_value(), algorithm='HS256'
)
# Redirect into keycloak
scope = quote('openid email profile offline_access')
redirect_uri = quote(f'{HOST_URL}/slack/keycloak-callback')
redirect_uri = f'{HOST_URL}/slack/keycloak-callback'
auth_url = (
f'{KEYCLOAK_SERVER_URL_EXT}/realms/{KEYCLOAK_REALM_NAME}/protocol/openid-connect/auth'
f'?client_id={KEYCLOAK_CLIENT_ID}&response_type=code'
@@ -149,16 +158,23 @@ async def keycloak_callback(
status_code=400,
)
jwt_secret: SecretStr = config.jwt_secret # type: ignore[assignment]
if not config.jwt_secret:
logger.error('problem_retrieving_keycloak_tokens JWT not configured.')
return _html_response(
title='Error',
description=html.escape('JWT not configured'),
status_code=500,
)
payload: dict[str, str] = jwt.decode(
state, jwt_secret.get_secret_value(), algorithms=['HS256']
state, config.jwt_secret.get_secret_value(), algorithms=['HS256']
)
slack_user_id = payload['slack_user_id']
bot_access_token = payload['bot_access_token']
team_id = payload['team_id']
# Retrieve the keycloak_user_id
redirect_uri = f'https://{request.url.netloc}{request.url.path}'
redirect_uri = f'{HOST_URL}{request.url.path}'
(
keycloak_access_token,
keycloak_refresh_token,
@@ -180,6 +196,13 @@ async def keycloak_callback(
user_info = await token_manager.get_user_info(keycloak_access_token)
keycloak_user_id = user_info['sub']
user = await UserStore.get_user_by_id_async(keycloak_user_id)
if not user:
return _html_response(
title='Failed to authenticate.',
description=f'Please re-login into <a href="{HOST_URL}" style="color:#ecedee;text-decoration:underline;">OpenHands Cloud</a>. Then try <a href="https://docs.all-hands.dev/usage/cloud/slack-installation" style="color:#ecedee;text-decoration:underline;">installing the OpenHands Slack App</a> again',
status_code=400,
)
# These tokens are offline access tokens - store them!
await token_manager.store_offline_token(keycloak_user_id, keycloak_refresh_token)
@@ -211,6 +234,7 @@ async def keycloak_callback(
slack_display_name = slack_user_info.data['user']['profile']['display_name']
slack_user = SlackUser(
keycloak_user_id=keycloak_user_id,
org_id=user.current_org_id,
slack_user_id=slack_user_id,
slack_display_name=slack_display_name,
)
@@ -305,7 +329,7 @@ async def on_form_interaction(request: Request, background_tasks: BackgroundTask
body = await request.body()
form = await request.form()
payload = json.loads(form.get('payload')) # type: ignore[arg-type]
payload = json.loads(form.get('payload'))
logger.info('slack_on_form_interaction', extra={'payload': payload})

View File

@@ -21,7 +21,7 @@ DEVICE_CODE_EXPIRES_IN = 600 # 10 minutes
DEVICE_TOKEN_POLL_INTERVAL = 5 # seconds
API_KEY_NAME = 'Device Link Access Key'
KEY_EXPIRATION_TIME = timedelta(days=1) # Key expires in 24 hours
KEY_EXPIRATION_TIME = timedelta(days=7) # Key expires in a week
# ---------------------------------------------------------------------------
# Models
@@ -272,7 +272,7 @@ async def device_verification_authenticated(
try:
# Create a unique API key for this device using user_code in the name
device_key_name = f'{API_KEY_NAME} ({user_code})'
api_key_store.create_api_key(
await api_key_store.create_api_key(
user_id,
name=device_key_name,
expires_at=datetime.now(UTC) + KEY_EXPIRATION_TIME,

View File

@@ -0,0 +1,327 @@
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field, SecretStr, StringConstraints
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
class OrgCreationError(Exception):
"""Base exception for organization creation errors."""
pass
class OrgNameExistsError(OrgCreationError):
"""Raised when an organization name already exists."""
def __init__(self, name: str):
self.name = name
super().__init__(f'Organization with name "{name}" already exists')
class LiteLLMIntegrationError(OrgCreationError):
"""Raised when LiteLLM integration fails."""
pass
class OrgDatabaseError(OrgCreationError):
"""Raised when database operations fail."""
pass
class OrgDeletionError(Exception):
"""Base exception for organization deletion errors."""
pass
class OrgAuthorizationError(OrgDeletionError):
"""Raised when user is not authorized to delete organization."""
def __init__(self, message: str = 'Not authorized to delete organization'):
super().__init__(message)
class OrphanedUserError(OrgDeletionError):
"""Raised when deleting an org would leave users without any organization."""
def __init__(self, user_ids: list[str]):
self.user_ids = user_ids
super().__init__(
f'Cannot delete organization: {len(user_ids)} user(s) would have no remaining organization'
)
class OrgNotFoundError(Exception):
"""Raised when organization is not found or user doesn't have access."""
def __init__(self, org_id: str):
self.org_id = org_id
super().__init__(f'Organization with id "{org_id}" not found')
class OrgMemberNotFoundError(Exception):
"""Raised when a member is not found in an organization."""
def __init__(self, org_id: str, user_id: str):
self.org_id = org_id
self.user_id = user_id
super().__init__(f'Member "{user_id}" not found in organization "{org_id}"')
class RoleNotFoundError(Exception):
"""Raised when a role is not found."""
def __init__(self, role_id: int):
self.role_id = role_id
super().__init__(f'Role with id "{role_id}" not found')
class InvalidRoleError(Exception):
"""Raised when an invalid role name is specified."""
def __init__(self, role_name: str):
self.role_name = role_name
super().__init__(f'Invalid role: "{role_name}"')
class InsufficientPermissionError(Exception):
"""Raised when user lacks permission to perform an operation."""
def __init__(self, message: str = 'Insufficient permission'):
super().__init__(message)
class CannotModifySelfError(Exception):
"""Raised when user attempts to modify their own membership."""
def __init__(self, action: str = 'modify'):
self.action = action
super().__init__(f'Cannot {action} your own membership')
class LastOwnerError(Exception):
"""Raised when attempting to remove or demote the last owner."""
def __init__(self, action: str = 'remove'):
self.action = action
super().__init__(f'Cannot {action} the last owner of an organization')
class MemberUpdateError(Exception):
"""Raised when member update operation fails."""
def __init__(self, message: str = 'Failed to update member'):
super().__init__(message)
class OrgCreate(BaseModel):
"""Request model for creating a new organization."""
# Required fields
name: Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)
]
contact_name: str
contact_email: EmailStr
class OrgResponse(BaseModel):
"""Response model for organization."""
id: str
name: str
contact_name: str
contact_email: str
conversation_expiration: int | None = None
agent: str | None = None
default_max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
enable_default_condenser: bool = True
billing_margin: float | None = None
enable_proactive_conversation_starters: bool = True
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
org_version: int = 0
mcp_config: dict | None = None
search_api_key: str | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = None
enable_solvability_analysis: bool | None = None
v1_enabled: bool | None = None
credits: float | None = None
is_personal: bool = False
@classmethod
def from_org(
cls, org: Org, credits: float | None = None, user_id: str | None = None
) -> 'OrgResponse':
"""Create an OrgResponse from an Org entity.
Args:
org: The organization entity to convert
credits: Optional credits value (defaults to None)
user_id: Optional user ID to determine if org is personal (defaults to None)
Returns:
OrgResponse: The response model instance
"""
return cls(
id=str(org.id),
name=org.name,
contact_name=org.contact_name,
contact_email=org.contact_email,
conversation_expiration=org.conversation_expiration,
agent=org.agent,
default_max_iterations=org.default_max_iterations,
security_analyzer=org.security_analyzer,
confirmation_mode=org.confirmation_mode,
default_llm_model=org.default_llm_model,
default_llm_api_key_for_byor=None,
default_llm_base_url=org.default_llm_base_url,
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
billing_margin=org.billing_margin,
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
if org.enable_proactive_conversation_starters is not None
else True,
sandbox_base_container_image=org.sandbox_base_container_image,
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
org_version=org.org_version if org.org_version is not None else 0,
mcp_config=org.mcp_config,
search_api_key=None,
sandbox_api_key=None,
max_budget_per_task=org.max_budget_per_task,
enable_solvability_analysis=org.enable_solvability_analysis,
v1_enabled=org.v1_enabled,
credits=credits,
is_personal=str(org.id) == user_id if user_id else False,
)
class OrgPage(BaseModel):
"""Paginated response model for organization list."""
items: list[OrgResponse]
next_page_id: str | None = None
class OrgUpdate(BaseModel):
"""Request model for updating an organization."""
# Basic organization information (any authenticated user can update)
name: Annotated[
str | None,
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
] = None
contact_name: str | None = None
contact_email: EmailStr | None = None
conversation_expiration: int | None = None
default_max_iterations: int | None = Field(default=None, gt=0)
remote_runtime_resource_factor: int | None = Field(default=None, gt=0)
billing_margin: float | None = Field(default=None, ge=0, le=1)
enable_proactive_conversation_starters: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
mcp_config: dict | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = Field(default=None, gt=0)
enable_solvability_analysis: bool | None = None
v1_enabled: bool | None = None
# LLM settings (require admin/owner role)
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
search_api_key: str | None = None
security_analyzer: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
class OrgMemberResponse(BaseModel):
"""Response model for a single organization member."""
user_id: str
email: str | None
role_id: int
role_name: str
role_rank: int
status: str | None
class OrgMemberPage(BaseModel):
"""Paginated response for organization members."""
items: list[OrgMemberResponse]
next_page_id: str | None = None
class OrgMemberUpdate(BaseModel):
"""Request model for updating an organization member."""
role: str | None = None # Role name: 'owner', 'admin', or 'member'
class MeResponse(BaseModel):
"""Response model for the current user's membership in an organization."""
org_id: str
user_id: str
email: str
role: str
llm_api_key: str
max_iterations: int | None = None
llm_model: str | None = None
llm_api_key_for_byor: str | None = None
llm_base_url: str | None = None
status: str | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return ''
raw = secret.get_secret_value()
if not raw:
return ''
if len(raw) <= 4:
return '****'
return '****' + raw[-4:]
@classmethod
def from_org_member(cls, member: OrgMember, role: Role, email: str) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email.
Args:
member: The OrgMember entity
role: The Role entity (provides role name)
email: The user's email address
Returns:
MeResponse with masked API keys
"""
return cls(
org_id=str(member.org_id),
user_id=str(member.user_id),
email=email,
role=role.name,
llm_api_key=cls._mask_key(member.llm_api_key),
max_iterations=member.max_iterations,
llm_model=member.llm_model,
llm_api_key_for_byor=cls._mask_key(member.llm_api_key_for_byor) or None,
llm_base_url=member.llm_base_url,
status=member.status,
)

View File

@@ -0,0 +1,795 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from server.auth.authorization import (
require_org_admin,
require_org_owner,
require_org_user,
)
from server.email_validation import get_admin_user_id
from server.routes.org_models import (
CannotModifySelfError,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
LiteLLMIntegrationError,
MemberUpdateError,
MeResponse,
OrgAuthorizationError,
OrgCreate,
OrgDatabaseError,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
OrgMemberUpdate,
OrgNameExistsError,
OrgNotFoundError,
OrgPage,
OrgResponse,
OrgUpdate,
OrphanedUserError,
RoleNotFoundError,
)
from server.services.org_member_service import OrgMemberService
from storage.org_service import OrgService
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
# Initialize API router
org_router = APIRouter(prefix='/api/organizations')
@org_router.get('', response_model=OrgPage)
async def list_user_orgs(
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
] = 100,
user_id: str = Depends(get_user_id),
) -> OrgPage:
"""List organizations for the authenticated user.
This endpoint returns a paginated list of all organizations that the
authenticated user is a member of.
Args:
page_id: Optional page ID (offset) for pagination
limit: Maximum number of organizations to return (1-100, default 100)
user_id: Authenticated user ID (injected by dependency)
Returns:
OrgPage: Paginated list of organizations
Raises:
HTTPException: 500 if retrieval fails
"""
logger.info(
'Listing organizations for user',
extra={
'user_id': user_id,
'page_id': page_id,
'limit': limit,
},
)
try:
# Fetch organizations from service layer
orgs, next_page_id = OrgService.get_user_orgs_paginated(
user_id=user_id,
page_id=page_id,
limit=limit,
)
# Convert Org entities to OrgResponse objects
org_responses = [
OrgResponse.from_org(org, credits=None, user_id=user_id) for org in orgs
]
logger.info(
'Successfully retrieved organizations',
extra={
'user_id': user_id,
'org_count': len(org_responses),
'has_more': next_page_id is not None,
},
)
return OrgPage(items=org_responses, next_page_id=next_page_id)
except Exception as e:
logger.exception(
'Unexpected error listing organizations',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve organizations',
)
@org_router.post('', response_model=OrgResponse, status_code=status.HTTP_201_CREATED)
async def create_org(
org_data: OrgCreate,
user_id: str = Depends(get_admin_user_id),
) -> OrgResponse:
"""Create a new organization.
This endpoint allows authenticated users with @openhands.dev email to create
a new organization. The user who creates the organization automatically becomes
its owner.
Args:
org_data: Organization creation data
user_id: Authenticated user ID (injected by dependency)
Returns:
OrgResponse: The created organization details
Raises:
HTTPException: 403 if user email domain is not @openhands.dev
HTTPException: 409 if organization name already exists
HTTPException: 500 if creation fails
"""
logger.info(
'Creating new organization',
extra={
'user_id': user_id,
'org_name': org_data.name,
},
)
try:
# Use service layer to create organization
org = await OrgService.create_org_with_owner(
name=org_data.name,
contact_name=org_data.contact_name,
contact_email=org_data.contact_email,
user_id=user_id,
)
# Retrieve credits from LiteLLM
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNameExistsError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
except LiteLLMIntegrationError as e:
logger.error(
'LiteLLM integration failed',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create LiteLLM integration',
)
except OrgDatabaseError as e:
logger.error(
'Database operation failed',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create organization',
)
except Exception as e:
logger.exception(
'Unexpected error creating organization',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
async def get_org(
org_id: UUID,
user_id: str = Depends(require_org_user),
) -> OrgResponse:
"""Get organization details by ID.
This endpoint allows authenticated users who are members of an organization
to retrieve its details. Only members of the organization can access this endpoint.
Requires user, admin, or owner role.
Args:
org_id: Organization ID (UUID)
user_id: Authenticated user ID (injected by dependency, requires org membership)
Returns:
OrgResponse: The organization details
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user is not a member of the organization
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
HTTPException: 404 if organization not found
HTTPException: 500 if retrieval fails
"""
logger.info(
'Retrieving organization details',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
try:
# Use service layer to get organization with membership validation
org = await OrgService.get_org_by_id(
org_id=org_id,
user_id=user_id,
)
# Retrieve credits from LiteLLM
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
logger.exception(
'Unexpected error retrieving organization',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.get('/{org_id}/me', response_model=MeResponse)
async def get_me(
org_id: UUID,
user_id: str = Depends(get_user_id),
) -> MeResponse:
"""Get the current user's membership record for an organization.
Returns the authenticated user's role, status, email, and LLM override
fields (with masked API keys) within the specified organization.
Args:
org_id: Organization ID (UUID)
user_id: Authenticated user ID (injected by dependency)
Returns:
MeResponse: The user's membership data
Raises:
HTTPException: 404 if user is not a member or org doesn't exist
HTTPException: 500 if retrieval fails
"""
logger.info(
'Retrieving current member details',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
try:
user_uuid = UUID(user_id)
return OrgMemberService.get_me(org_id, user_uuid)
except OrgMemberNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Organization with id "{org_id}" not found',
)
except RoleNotFoundError as e:
logger.exception(
'Role not found for org member',
extra={
'user_id': user_id,
'org_id': str(org_id),
'role_id': e.role_id,
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
except Exception as e:
logger.exception(
'Unexpected error retrieving member details',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.delete('/{org_id}', status_code=status.HTTP_200_OK)
async def delete_org(
org_id: UUID,
user_id: str = Depends(require_org_owner),
) -> dict:
"""Delete an organization.
This endpoint allows authenticated organization owners to delete their organization.
All associated data including organization members, conversations, billing data,
and external LiteLLM team resources will be permanently removed.
Requires owner role.
Args:
org_id: Organization ID to delete
user_id: Authenticated user ID (injected by dependency, requires owner role)
Returns:
dict: Confirmation message with deleted organization details
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user is not an owner of the organization
HTTPException: 404 if organization not found
HTTPException: 500 if deletion fails
"""
logger.info(
'Organization deletion requested',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
try:
# Use service layer to delete organization with cleanup
deleted_org = await OrgService.delete_org_with_cleanup(
user_id=user_id,
org_id=org_id,
)
logger.info(
'Organization deletion completed successfully',
extra={
'user_id': user_id,
'org_id': str(org_id),
'org_name': deleted_org.name,
},
)
return {
'message': 'Organization deleted successfully',
'organization': {
'id': str(deleted_org.id),
'name': deleted_org.name,
'contact_name': deleted_org.contact_name,
'contact_email': deleted_org.contact_email,
},
}
except OrgNotFoundError as e:
logger.warning(
'Organization not found for deletion',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgAuthorizationError as e:
logger.warning(
'User not authorized to delete organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except OrphanedUserError as e:
logger.warning(
'Cannot delete organization: users would be orphaned',
extra={
'user_id': user_id,
'org_id': str(org_id),
'orphaned_users': e.user_ids,
},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database error during organization deletion',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete organization',
)
except Exception as e:
logger.exception(
'Unexpected error during organization deletion',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.patch('/{org_id}', response_model=OrgResponse)
async def update_org(
org_id: UUID,
update_data: OrgUpdate,
user_id: str = Depends(require_org_admin),
) -> OrgResponse:
"""Update an existing organization.
This endpoint allows authenticated admins and owners to update organization settings.
Requires admin or owner role in the organization.
Args:
org_id: Organization ID to update (UUID validated by FastAPI)
update_data: Organization update data
user_id: Authenticated user ID (injected by dependency, requires admin role)
Returns:
OrgResponse: The updated organization details
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user is not an admin or owner of the organization
HTTPException: 404 if organization not found
HTTPException: 422 if validation errors occur (handled by FastAPI)
HTTPException: 500 if update fails
"""
logger.info(
'Updating organization',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
try:
# Use service layer to update organization with permission checks
updated_org = await OrgService.update_org_with_permissions(
org_id=org_id,
update_data=update_data,
user_id=user_id,
)
# Retrieve credits from LiteLLM (following same pattern as create endpoint)
credits = await OrgService.get_org_credits(user_id, updated_org.id)
return OrgResponse.from_org(updated_org, credits=credits, user_id=user_id)
except ValueError as e:
# Organization not found
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgNameExistsError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
except PermissionError as e:
# User lacks permission for LLM settings
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database operation failed',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update organization',
)
except Exception as e:
logger.exception(
'Unexpected error updating organization',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.get('/{org_id}/members')
async def get_org_members(
org_id: str,
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(
title='The max number of results in the page',
gt=0,
lte=100,
),
] = 100,
current_user_id: str = Depends(get_user_id),
) -> OrgMemberPage:
"""Get all members of an organization with cursor-based pagination."""
try:
success, error_code, data = await OrgMemberService.get_org_members(
org_id=UUID(org_id),
current_user_id=UUID(current_user_id),
page_id=page_id,
limit=limit,
)
if not success:
error_map = {
'not_a_member': (
status.HTTP_403_FORBIDDEN,
'You are not a member of this organization',
),
'invalid_page_id': (
status.HTTP_400_BAD_REQUEST,
'Invalid page_id format',
),
}
status_code, detail = error_map.get(
error_code, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
)
raise HTTPException(status_code=status_code, detail=detail)
if data is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve members',
)
return data
except HTTPException:
raise
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization ID format',
)
except Exception:
logger.exception('Error retrieving organization members')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve members',
)
@org_router.delete('/{org_id}/members/{user_id}')
async def remove_org_member(
org_id: str,
user_id: str,
current_user_id: str = Depends(get_user_id),
):
"""Remove a member from an organization.
Only owners and admins can remove members:
- Owners can remove admins and regular users
- Admins can only remove regular users
Users cannot remove themselves. The last owner cannot be removed.
"""
try:
success, error = await OrgMemberService.remove_org_member(
org_id=UUID(org_id),
target_user_id=UUID(user_id),
current_user_id=UUID(current_user_id),
)
if not success:
error_map = {
'not_a_member': (
status.HTTP_403_FORBIDDEN,
'You are not a member of this organization',
),
'cannot_remove_self': (
status.HTTP_403_FORBIDDEN,
'Cannot remove yourself from an organization',
),
'member_not_found': (
status.HTTP_404_NOT_FOUND,
'Member not found in this organization',
),
'insufficient_permission': (
status.HTTP_403_FORBIDDEN,
'You do not have permission to remove this member',
),
'cannot_remove_last_owner': (
status.HTTP_400_BAD_REQUEST,
'Cannot remove the last owner of an organization',
),
'removal_failed': (
status.HTTP_500_INTERNAL_SERVER_ERROR,
'Failed to remove member',
),
}
status_code, detail = error_map.get(
error, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
)
raise HTTPException(status_code=status_code, detail=detail)
return {'message': 'Member removed successfully'}
except HTTPException:
raise
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization or user ID format',
)
except Exception:
logger.exception('Error removing organization member')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to remove member',
)
@org_router.post(
'/{org_id}/switch', response_model=OrgResponse, status_code=status.HTTP_200_OK
)
async def switch_org(
org_id: UUID,
user_id: str = Depends(get_user_id),
) -> OrgResponse:
"""Switch to a different organization.
This endpoint allows authenticated users to switch their current active
organization. The user must be a member of the target organization.
Args:
org_id: Organization ID to switch to (UUID)
user_id: Authenticated user ID (injected by dependency)
Returns:
OrgResponse: The organization details that was switched to
Raises:
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
HTTPException: 403 if user is not a member of the organization
HTTPException: 404 if organization not found
HTTPException: 500 if switch fails
"""
logger.info(
'Switching organization',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
try:
# Use service layer to switch organization with membership validation
org = await OrgService.switch_org(
user_id=user_id,
org_id=org_id,
)
# Retrieve credits from LiteLLM for the new current org
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgAuthorizationError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database operation failed during organization switch',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to switch organization',
)
except Exception as e:
logger.exception(
'Unexpected error switching organization',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.patch('/{org_id}/members/{user_id}', response_model=OrgMemberResponse)
async def update_org_member(
org_id: str,
user_id: str,
update_data: OrgMemberUpdate,
current_user_id: str = Depends(get_user_id),
) -> OrgMemberResponse:
"""Update a member's role in an organization.
Permission rules:
- Admins can change roles of regular members to Admin or Member
- Admins cannot modify other Admins or Owners
- Owners can change roles of Admins and Members to any role (Owner, Admin, Member)
- Owners cannot modify other Owners
Members cannot modify their own role. The last owner cannot be demoted.
"""
try:
return await OrgMemberService.update_org_member(
org_id=UUID(org_id),
target_user_id=UUID(user_id),
current_user_id=UUID(current_user_id),
update_data=update_data,
)
except OrgMemberNotFoundError as e:
# Distinguish between requester not being a member vs target not found
if str(current_user_id) in str(e):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You are not a member of this organization',
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Member not found in this organization',
)
except CannotModifySelfError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot modify your own role',
)
except RoleNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Role configuration error',
)
except InvalidRoleError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid role specified',
)
except InsufficientPermissionError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You do not have permission to modify this member',
)
except LastOwnerError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot demote the last owner of an organization',
)
except MemberUpdateError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization or user ID format',
)
except Exception:
logger.exception('Error updating organization member')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from utils.identity import resolve_display_name
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
@@ -121,6 +122,8 @@ async def saas_get_user(
login=(user_info.get('preferred_username') if user_info else '') or '',
avatar_url='',
email=user_info.get('email') if user_info else None,
name=resolve_display_name(user_info) if user_info else None,
company=user_info.get('company') if user_info else None,
),
user_info=user_info,
)

View File

@@ -1,4 +1,3 @@
from prometheus_client import Counter, Histogram
from server.logger import logger
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -9,45 +8,27 @@ from openhands.events.observation import (
)
from openhands.server.monitoring import MonitoringListener
AGENT_STATUS_ERROR_COUNT = Counter(
'saas_agent_status_errors', 'Agent Status change events to status error'
)
CREATE_CONVERSATION_COUNT = Counter(
'saas_create_conversation', 'Create conversation attempts'
)
AGENT_SESSION_START_HISTOGRAM = Histogram(
'saas_agent_session_start',
'AgentSession starts with success and duration',
labelnames=['success'],
)
class SaaSMonitoringListener(MonitoringListener):
"""
Forward app signals to Prometheus.
"""
"""Forward app signals to structured logging for GCP native monitoring."""
def on_session_event(self, event: Event) -> None:
"""
Track metrics about events being added to a Session's EventStream.
"""
"""Track metrics about events being added to a Session's EventStream."""
if (
isinstance(event, AgentStateChangedObservation)
and event.agent_state == AgentState.ERROR
):
AGENT_STATUS_ERROR_COUNT.inc()
logger.info(
'Tracking agent status error',
extra={'signal': 'saas_agent_status_errors'},
)
def on_agent_session_start(self, success: bool, duration: float) -> None:
"""
Track an agent session start.
"""Track an agent session start.
Success is true if startup completed without error.
Duration is start time in seconds observed by AgentSession.
"""
AGENT_SESSION_START_HISTOGRAM.labels(success=success).observe(duration)
logger.info(
'Tracking agent session start',
extra={
@@ -58,11 +39,10 @@ class SaaSMonitoringListener(MonitoringListener):
)
def on_create_conversation(self) -> None:
"""
Track the beginning of conversation creation.
"""Track the beginning of conversation creation.
Does not currently capture whether it succeed.
"""
CREATE_CONVERSATION_COUNT.inc()
logger.info(
'Tracking create conversation', extra={'signal': 'saas_create_conversation'}
)

View File

@@ -23,6 +23,7 @@ from sqlalchemy import orm
from storage.api_key_store import ApiKeyStore
from storage.database import session_maker
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.controller.agent import Agent
from openhands.core.config import LLMConfig, OpenHandsConfig
@@ -515,11 +516,13 @@ class SaasNestedConversationManager(ConversationManager):
)
raise
def _get_mcp_config(self, user_id: str) -> MCPConfig | None:
async def _get_mcp_config(self, user_id: str) -> MCPConfig | None:
api_key_store = ApiKeyStore.get_instance()
mcp_api_key = api_key_store.retrieve_mcp_api_key(user_id)
mcp_api_key = await api_key_store.retrieve_mcp_api_key(user_id)
if not mcp_api_key:
mcp_api_key = api_key_store.create_api_key(user_id, 'MCP_API_KEY', None)
mcp_api_key = await api_key_store.create_api_key(
user_id, 'MCP_API_KEY', None
)
if not mcp_api_key:
return None
web_host = os.environ.get('WEB_HOST', 'app.all-hands.dev')
@@ -546,7 +549,7 @@ class SaasNestedConversationManager(ConversationManager):
'conversation_id': sid,
}
mcp_config = self._get_mcp_config(user_id)
mcp_config = await self._get_mcp_config(user_id)
if mcp_config:
# Merge with any MCP config from settings
if settings.mcp_config:
@@ -646,16 +649,18 @@ class SaasNestedConversationManager(ConversationManager):
"""
with session_maker() as session:
conversation_metadata = (
session.query(StoredConversationMetadata)
.filter(StoredConversationMetadata.conversation_id == conversation_id)
conversation_metadata_saas = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadataSaas.conversation_id == conversation_id
)
.first()
)
if not conversation_metadata:
if not conversation_metadata_saas:
raise ValueError(f'No conversation found {conversation_id}')
return conversation_metadata.user_id
return str(conversation_metadata_saas.user_id)
async def _get_runtime_status_from_nested_runtime(
self, session_api_key: Any | None, nested_url: str, conversation_id: str
@@ -994,9 +999,17 @@ class SaasNestedConversationManager(ConversationManager):
with session_maker() as session:
# Only include conversations updated in the past week
one_week_ago = datetime.now(UTC) - timedelta(days=7)
query = session.query(StoredConversationMetadata.conversation_id).filter(
StoredConversationMetadata.user_id == user_id,
StoredConversationMetadata.last_updated_at >= one_week_ago,
query = (
session.query(StoredConversationMetadata.conversation_id)
.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.filter(
StoredConversationMetadataSaas.user_id == user_id,
StoredConversationMetadata.last_updated_at >= one_week_ago,
)
)
user_conversation_ids = set(query)
return user_conversation_ids
@@ -1070,11 +1083,16 @@ class SaasNestedConversationManager(ConversationManager):
.filter(StoredConversationMetadata.conversation_id == conversation_id)
.first()
)
if conversation_metadata is None:
conversation_metadata_saas = (
session.query(StoredConversationMetadataSaas)
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
.first()
)
if conversation_metadata is None or conversation_metadata_saas is None:
# Conversation is running in different server
return
user_id = conversation_metadata.user_id
user_id = conversation_metadata_saas.user_id
# Get the id of the next event which is not present
events_dir = get_conversation_events_dir(
@@ -1121,6 +1139,71 @@ class SaasNestedConversationManager(ConversationManager):
}
update_conversation_metadata(conversation_id, metadata_content)
async def list_files(self, sid: str, path: str | None = None) -> list[str]:
"""List files in the workspace for a conversation.
Delegates to the nested container's list-files endpoint.
Args:
sid: The session/conversation ID.
path: Optional path to list files from. If None, lists from workspace root.
Returns:
A list of file paths.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_list_files_from_nested(
sid, nested_url, session_api_key, path
)
async def select_file(self, sid: str, file: str) -> tuple[str | None, str | None]:
"""Read a file from the workspace via nested container.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_select_file_from_nested(
sid, nested_url, session_api_key, file
)
async def upload_files(
self, sid: str, files: list[tuple[str, bytes]]
) -> tuple[list[str], list[dict[str, str]]]:
"""Upload files to the workspace via nested container.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_upload_files_to_nested(
sid, nested_url, session_api_key, files
)
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
last_updated_at = conversation.last_updated_at

View File

@@ -0,0 +1,342 @@
"""Service for managing organization members."""
from uuid import UUID
from server.constants import ROLE_ADMIN, ROLE_MEMBER, ROLE_OWNER
from server.routes.org_models import (
CannotModifySelfError,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
MemberUpdateError,
MeResponse,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
OrgMemberUpdate,
RoleNotFoundError,
)
from storage.org_member_store import OrgMemberStore
from storage.role_store import RoleStore
from storage.user_store import UserStore
from openhands.utils.async_utils import call_sync_from_async
class OrgMemberService:
"""Service for organization member operations."""
@staticmethod
def get_me(org_id: UUID, user_id: UUID) -> MeResponse:
"""Get the current user's membership record for an organization.
Retrieves the authenticated user's role, status, email, and LLM override
fields (with masked API keys) within the specified organization.
Args:
org_id: Organization ID (UUID)
user_id: User ID (UUID)
Returns:
MeResponse: The user's membership data with masked API keys
Raises:
OrgMemberNotFoundError: If user is not a member of the organization
RoleNotFoundError: If the role associated with the member is not found
"""
# Look up the user's membership in this org
org_member = OrgMemberStore.get_org_member(org_id, user_id)
if org_member is None:
raise OrgMemberNotFoundError(str(org_id), str(user_id))
# Resolve role name from role_id
role = RoleStore.get_role_by_id(org_member.role_id)
if role is None:
raise RoleNotFoundError(org_member.role_id)
# Get user email
user = UserStore.get_user_by_id(str(user_id))
email = user.email if user and user.email else ''
return MeResponse.from_org_member(org_member, role, email)
@staticmethod
async def get_org_members(
org_id: UUID,
current_user_id: UUID,
page_id: str | None = None,
limit: int = 100,
) -> tuple[bool, str | None, OrgMemberPage | None]:
"""Get organization members with authorization check.
Returns:
Tuple of (success, error_code, data). If success is True, error_code is None.
"""
# Verify current user is a member of the organization
requester_membership = OrgMemberStore.get_org_member(org_id, current_user_id)
if not requester_membership:
return False, 'not_a_member', None
# Parse page_id to get offset (page_id is offset encoded as string)
offset = 0
if page_id is not None:
try:
offset = int(page_id)
if offset < 0:
return False, 'invalid_page_id', None
except ValueError:
return False, 'invalid_page_id', None
# Call store to get paginated members
members, has_more = await OrgMemberStore.get_org_members_paginated(
org_id=org_id, offset=offset, limit=limit
)
# Transform data to response format
items = []
for member in members:
# Access user and role relationships (eagerly loaded)
user = member.user
role = member.role
items.append(
OrgMemberResponse(
user_id=str(member.user_id),
email=user.email if user else None,
role_id=member.role_id,
role_name=role.name if role else '',
role_rank=role.rank if role else 0,
status=member.status,
)
)
# Calculate next_page_id
next_page_id = None
if has_more:
next_page_id = str(offset + limit)
return True, None, OrgMemberPage(items=items, next_page_id=next_page_id)
@staticmethod
async def remove_org_member(
org_id: UUID,
target_user_id: UUID,
current_user_id: UUID,
) -> tuple[bool, str | None]:
"""Remove a member from an organization.
Returns:
Tuple of (success, error_message). If success is True, error_message is None.
"""
def _remove_member():
# Get current user's membership in the org
requester_membership = OrgMemberStore.get_org_member(
org_id, current_user_id
)
if not requester_membership:
return False, 'not_a_member'
# Check if trying to remove self
if str(current_user_id) == str(target_user_id):
return False, 'cannot_remove_self'
# Get target user's membership
target_membership = OrgMemberStore.get_org_member(org_id, target_user_id)
if not target_membership:
return False, 'member_not_found'
requester_role = RoleStore.get_role_by_id(requester_membership.role_id)
target_role = RoleStore.get_role_by_id(target_membership.role_id)
if not requester_role or not target_role:
return False, 'role_not_found'
# Check permission based on roles
if not OrgMemberService._can_remove_member(
requester_role.name, target_role.name
):
return False, 'insufficient_permission'
# Check if removing the last owner
if target_role.name == ROLE_OWNER:
if OrgMemberService._is_last_owner(org_id, target_user_id):
return False, 'cannot_remove_last_owner'
# Perform the removal
success = OrgMemberStore.remove_user_from_org(org_id, target_user_id)
if not success:
return False, 'removal_failed'
return True, None
return await call_sync_from_async(_remove_member)
@staticmethod
async def update_org_member(
org_id: UUID,
target_user_id: UUID,
current_user_id: UUID,
update_data: OrgMemberUpdate,
) -> OrgMemberResponse:
"""Update a member's role in an organization.
Permission rules:
- Admins can change roles of users (rank > ADMIN_RANK) to Admin or User
- Admins cannot modify other Admins or Owners
- Owners can change roles of non-owners (rank > OWNER_RANK) to any role
- Owners cannot modify other Owners
Args:
org_id: Organization ID
target_user_id: User ID of the member to update
current_user_id: User ID of the requester
update_data: Update data containing fields to modify
Returns:
OrgMemberResponse: The updated member data
Raises:
OrgMemberNotFoundError: If requester or target is not a member
CannotModifySelfError: If trying to modify self
RoleNotFoundError: If role configuration is invalid
InvalidRoleError: If new_role_name is not a valid role
InsufficientPermissionError: If requester lacks permission
LastOwnerError: If trying to demote the last owner
MemberUpdateError: If update operation fails
"""
new_role_name = update_data.role
def _update_member():
# Get current user's membership in the org
requester_membership = OrgMemberStore.get_org_member(
org_id, current_user_id
)
if not requester_membership:
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
# Check if trying to modify self
if str(current_user_id) == str(target_user_id):
raise CannotModifySelfError('modify')
# Get target user's membership
target_membership = OrgMemberStore.get_org_member(org_id, target_user_id)
if not target_membership:
raise OrgMemberNotFoundError(str(org_id), str(target_user_id))
# Get roles
requester_role = RoleStore.get_role_by_id(requester_membership.role_id)
target_role = RoleStore.get_role_by_id(target_membership.role_id)
if not requester_role:
raise RoleNotFoundError(requester_membership.role_id)
if not target_role:
raise RoleNotFoundError(target_membership.role_id)
# If no role change requested, return current state
if new_role_name is None:
user = UserStore.get_user_by_id(str(target_user_id))
return OrgMemberResponse(
user_id=str(target_membership.user_id),
email=user.email if user else None,
role_id=target_membership.role_id,
role_name=target_role.name,
role_rank=target_role.rank,
status=target_membership.status,
)
# Validate new role exists
new_role = RoleStore.get_role_by_name(new_role_name.lower())
if not new_role:
raise InvalidRoleError(new_role_name)
# Check permission to modify target
if not OrgMemberService._can_update_member_role(
requester_role.name, target_role.name, new_role.name
):
raise InsufficientPermissionError(
'You do not have permission to modify this member'
)
# Check if demoting the last owner
if (
target_role.name == ROLE_OWNER
and new_role.name != ROLE_OWNER
and OrgMemberService._is_last_owner(org_id, target_user_id)
):
raise LastOwnerError('demote')
# Perform the update
updated_member = OrgMemberStore.update_user_role_in_org(
org_id, target_user_id, new_role.id
)
if not updated_member:
raise MemberUpdateError('Failed to update member')
# Get user email for response
user = UserStore.get_user_by_id(str(target_user_id))
return OrgMemberResponse(
user_id=str(updated_member.user_id),
email=user.email if user else None,
role_id=updated_member.role_id,
role_name=new_role.name,
role_rank=new_role.rank,
status=updated_member.status,
)
return await call_sync_from_async(_update_member)
@staticmethod
def _can_update_member_role(
requester_role_name: str, target_role_name: str, new_role_name: str
) -> bool:
"""Check if requester can change target's role to new_role.
Permission rules:
- Owners can modify admins and users, can set any role
- Owners cannot modify other owners
- Admins can only modify users
- Admins can only set admin or user roles (not owner)
"""
is_requester_owner = requester_role_name == ROLE_OWNER
is_requester_admin = requester_role_name == ROLE_ADMIN
is_target_owner = target_role_name == ROLE_OWNER
is_target_admin = target_role_name == ROLE_ADMIN
is_new_role_owner = new_role_name == ROLE_OWNER
if is_requester_owner:
# Owners cannot modify other owners
if is_target_owner:
return False
# Owners can set any role (owner, admin, user)
return True
elif is_requester_admin:
# Admins cannot modify owners or other admins
if is_target_owner or is_target_admin:
return False
# Admins can only set admin or user roles (not owner)
return not is_new_role_owner
return False
@staticmethod
def _can_remove_member(requester_role_name: str, target_role_name: str) -> bool:
"""Check if requester can remove target based on roles."""
if requester_role_name == ROLE_OWNER:
return True
elif requester_role_name == ROLE_ADMIN:
# Admins can only remove members (not owners or other admins)
return target_role_name == ROLE_MEMBER
return False
@staticmethod
def _is_last_owner(org_id: UUID, user_id: UUID) -> bool:
"""Check if user is the last owner of the organization."""
members = OrgMemberStore.get_org_members(org_id)
owners = []
for m in members:
# Use role_id (column) instead of role (relationship) to avoid DetachedInstanceError
role = RoleStore.get_role_by_id(m.role_id)
if role and role.name == ROLE_OWNER:
owners.append(m)
return len(owners) == 1 and str(owners[0].user_id) == str(user_id)

View File

@@ -9,12 +9,18 @@ This implementation provides read-only access to events from shared conversation
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from google.cloud import storage
from google.cloud.storage.bucket import Bucket
from google.cloud.storage.client import Client
from pydantic import Field
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
@@ -28,6 +34,9 @@ from server.sharing.sql_shared_conversation_info_service import (
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event.google_cloud_event_service import (
GoogleCloudEventService,
)
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
@@ -36,17 +45,13 @@ logger = logging.getLogger(__name__)
@dataclass
class SharedEventServiceImpl(SharedEventService):
class GoogleCloudSharedEventService(SharedEventService):
"""Implementation of SharedEventService that validates shared access."""
shared_conversation_info_service: SharedConversationInfoService
event_service: EventService
bucket: Bucket
async def get_shared_event(
self, conversation_id: UUID, event_id: str
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
@@ -55,8 +60,25 @@ class SharedEventServiceImpl(SharedEventService):
if shared_conversation_info is None:
return None
return GoogleCloudEventService(
bucket=self.bucket,
prefix=Path('users'),
user_id=shared_conversation_info.created_by_user_id,
app_conversation_info_service=None,
app_conversation_info_load_tasks={},
)
async def get_shared_event(
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
return None
# If conversation is shared, get the event
return await self.event_service.get_event(event_id)
return await event_service.get_event(conversation_id, event_id)
async def search_shared_events(
self,
@@ -70,18 +92,14 @@ class SharedEventServiceImpl(SharedEventService):
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await self.event_service.search_events(
conversation_id__eq=conversation_id,
return await event_service.search_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
@@ -96,47 +114,45 @@ class SharedEventServiceImpl(SharedEventService):
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return 0
# If conversation is shared, count events for this conversation
return await self.event_service.count_events(
conversation_id__eq=conversation_id,
return await event_service.count_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
)
class SharedEventServiceImplInjector(SharedEventServiceInjector):
class GoogleCloudSharedEventServiceInjector(SharedEventServiceInjector):
bucket_name: str | None = Field(
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import (
get_db_session,
get_event_service,
)
from openhands.app_server.config import get_db_session
async with (
get_db_session(state, request) as db_session,
get_event_service(state, request) as event_service,
):
async with get_db_session(state, request) as db_session:
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
service = SharedEventServiceImpl(
bucket_name = self.bucket_name
storage_client: Client = storage.Client()
bucket: Bucket = storage_client.bucket(bucket_name)
service = GoogleCloudSharedEventService(
shared_conversation_info_service=shared_conversation_info_service,
event_service=event_service,
bucket=bucket,
)
yield service

View File

@@ -5,8 +5,8 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from server.sharing.filesystem_shared_event_service import (
SharedEventServiceImplInjector,
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventServiceInjector,
)
from server.sharing.shared_event_service import SharedEventService
@@ -15,7 +15,9 @@ from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.sdk import Event
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
shared_event_service_dependency = Depends(SharedEventServiceImplInjector().depends)
shared_event_service_dependency = Depends(
GoogleCloudSharedEventServiceInjector().depends
)
# Read methods
@@ -85,10 +87,6 @@ async def count_shared_events(
datetime | None,
Query(title='Optional filter by timestamp less than'),
] = None,
sort_order: Annotated[
EventSortOrder,
Query(title='Sort order for results'),
] = EventSortOrder.TIMESTAMP,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> int:
"""Count events for a shared conversation matching the given filters."""
@@ -97,14 +95,13 @@ async def count_shared_events(
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
)
@router.get('')
async def batch_get_shared_events(
conversation_id: Annotated[
UUID,
str,
Query(title='Conversation ID to get events for'),
],
id: Annotated[list[str], Query()],
@@ -112,15 +109,20 @@ async def batch_get_shared_events(
) -> list[Event | None]:
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
assert len(id) <= 100
events = await shared_event_service.batch_get_shared_events(conversation_id, id)
event_ids = [UUID(id_) for id_ in id]
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids
)
return events
@router.get('/{conversation_id}/{event_id}')
async def get_shared_event(
conversation_id: UUID,
conversation_id: str,
event_id: str,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> Event | None:
"""Get a single event from a shared conversation by conversation_id and event_id."""
return await shared_event_service.get_shared_event(conversation_id, event_id)
return await shared_event_service.get_shared_event(
UUID(conversation_id), UUID(event_id)
)

View File

@@ -18,7 +18,7 @@ class SharedEventService(ABC):
@abstractmethod
async def get_shared_event(
self, conversation_id: UUID, event_id: str
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
@@ -42,12 +42,11 @@ class SharedEventService(ABC):
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
) -> int:
"""Count events for a specific shared conversation."""
async def batch_get_shared_events(
self, conversation_id: UUID, event_ids: list[str]
self, conversation_id: UUID, event_ids: list[UUID]
) -> list[Event | None]:
"""Given a conversation_id and list of event_ids, get events if the conversation is shared."""
return await asyncio.gather(

View File

@@ -26,6 +26,7 @@ from server.sharing.shared_conversation_models import (
)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
StoredConversationMetadata,
@@ -57,7 +58,7 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
include_sub_conversations: bool = False,
) -> SharedConversationPage:
"""Search for shared conversations."""
query = self._public_select()
query = self._public_select_with_saas_metadata()
# Conditionally exclude sub-conversations based on the parameter
if not include_sub_conversations:
@@ -104,14 +105,17 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
query = query.limit(limit + 1)
result = await self.db_session.execute(query)
rows = result.scalars().all()
rows = result.all()
# Check if there are more results
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
items = [self._to_shared_conversation(row) for row in rows]
items = [
self._to_shared_conversation(stored, saas_metadata=saas_metadata)
for stored, saas_metadata in rows
]
# Calculate next page ID
next_page_id = None
@@ -152,17 +156,18 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
self, conversation_id: UUID
) -> SharedConversation | None:
"""Get a single public conversation info, returning None if missing or not shared."""
query = self._public_select().where(
query = self._public_select_with_saas_metadata().where(
StoredConversationMetadata.conversation_id == str(conversation_id)
)
result = await self.db_session.execute(query)
stored = result.scalar_one_or_none()
row = result.first()
if stored is None:
if row is None:
return None
return self._to_shared_conversation(stored)
stored, saas_metadata = row
return self._to_shared_conversation(stored, saas_metadata=saas_metadata)
def _public_select(self):
"""Create a select query that only returns public conversations."""
@@ -173,6 +178,25 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
return query
def _public_select_with_saas_metadata(self):
"""Create a select query that returns public conversations with SAAS metadata.
This joins with conversation_metadata_saas to retrieve the user_id needed
for constructing the correct event storage path. Uses LEFT OUTER JOIN to
support conversations that may not have SAAS metadata (e.g., in tests).
"""
query = (
select(StoredConversationMetadata, StoredConversationMetadataSaas)
.outerjoin(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.where(StoredConversationMetadata.conversation_version == 'V1')
.where(StoredConversationMetadata.public == True) # noqa: E712
)
return query
def _apply_filters(
self,
query,
@@ -211,9 +235,16 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
def _to_shared_conversation(
self,
stored: StoredConversationMetadata,
saas_metadata: StoredConversationMetadataSaas | None = None,
sub_conversation_ids: list[UUID] | None = None,
) -> SharedConversation:
"""Convert StoredConversationMetadata to SharedConversation."""
"""Convert StoredConversationMetadata to SharedConversation.
Args:
stored: The base conversation metadata from conversation_metadata table.
saas_metadata: Optional SAAS metadata containing user_id and org_id.
sub_conversation_ids: Optional list of sub-conversation IDs.
"""
# V1 conversations should always have a sandbox_id
sandbox_id = stored.sandbox_id
assert sandbox_id is not None
@@ -239,9 +270,16 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
created_at = self._fix_timezone(stored.created_at)
updated_at = self._fix_timezone(stored.last_updated_at)
# Get user_id from SAAS metadata if available
created_by_user_id = (
str(saas_metadata.user_id)
if saas_metadata and saas_metadata.user_id
else None
)
return SharedConversation(
id=UUID(stored.conversation_id),
created_by_user_id=stored.user_id if stored.user_id else None,
created_by_user_id=created_by_user_id,
sandbox_id=stored.sandbox_id,
selected_repository=stored.selected_repository,
selected_branch=stored.selected_branch,

View File

@@ -0,0 +1,365 @@
"""Enterprise injector for SQLAppConversationInfoService with SAAS filtering."""
from datetime import datetime
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from sqlalchemy import func, select
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from storage.user import User
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
AppConversationInfoServiceInjector,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
AppConversationInfoPage,
AppConversationSortOrder,
)
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
SQLAppConversationInfoService,
)
from openhands.app_server.services.injector import InjectorState
class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
"""Extended SQLAppConversationInfoService with user-based filtering and SAAS metadata handling."""
async def _secure_select(self):
query = (
select(StoredConversationMetadata)
.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.where(StoredConversationMetadata.conversation_version == 'V1')
)
user_id_str = await self.user_context.get_user_id()
if user_id_str:
user_id_uuid = UUID(user_id_str)
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
return query
async def _secure_select_with_saas_metadata(self):
"""Select query that includes SAAS metadata for retrieving user_id."""
query = (
select(StoredConversationMetadata, StoredConversationMetadataSaas)
.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.where(StoredConversationMetadata.conversation_version == 'V1')
)
user_id_str = await self.user_context.get_user_id()
if user_id_str:
user_id_uuid = UUID(user_id_str)
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
return query
async def search_app_conversation_info(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
include_sub_conversations: bool = False,
) -> AppConversationInfoPage:
"""Search for conversations with user_id from SAAS metadata."""
query = await self._secure_select_with_saas_metadata()
# Conditionally exclude sub-conversations based on the parameter
if not include_sub_conversations:
# Exclude sub-conversations (only include top-level conversations)
query = query.where(
StoredConversationMetadata.parent_conversation_id.is_(None)
)
query = self._apply_filters_with_saas_metadata(
query=query,
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
)
# Add sort order
if sort_order == AppConversationSortOrder.CREATED_AT:
query = query.order_by(StoredConversationMetadata.created_at)
elif sort_order == AppConversationSortOrder.CREATED_AT_DESC:
query = query.order_by(StoredConversationMetadata.created_at.desc())
elif sort_order == AppConversationSortOrder.UPDATED_AT:
query = query.order_by(StoredConversationMetadata.last_updated_at)
elif sort_order == AppConversationSortOrder.UPDATED_AT_DESC:
query = query.order_by(StoredConversationMetadata.last_updated_at.desc())
elif sort_order == AppConversationSortOrder.TITLE:
query = query.order_by(StoredConversationMetadata.title)
elif sort_order == AppConversationSortOrder.TITLE_DESC:
query = query.order_by(StoredConversationMetadata.title.desc())
# Apply pagination
if page_id is not None:
try:
offset = int(page_id)
query = query.offset(offset)
except ValueError:
# If page_id is not a valid integer, start from beginning
offset = 0
else:
offset = 0
# Apply limit and get one extra to check if there are more results
query = query.limit(limit + 1)
result = await self.db_session.execute(query)
rows = result.all()
# Check if there are more results
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
items = [
self._to_info_with_user_id(stored_metadata, saas_metadata)
for stored_metadata, saas_metadata in rows
]
# Calculate next page ID
next_page_id = None
if has_more:
next_page_id = str(offset + limit)
return AppConversationInfoPage(items=items, next_page_id=next_page_id)
async def count_app_conversation_info(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
) -> int:
"""Count conversations matching the given filters with SAAS metadata."""
query = (
select(func.count(StoredConversationMetadata.conversation_id))
.select_from(
StoredConversationMetadata.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
)
.where(StoredConversationMetadata.conversation_version == 'V1')
)
# Apply user filtering
user_id_str = await self.user_context.get_user_id()
if user_id_str:
user_id_uuid = UUID(user_id_str)
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
query = self._apply_filters_with_saas_metadata(
query=query,
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
)
result = await self.db_session.execute(query)
count = result.scalar()
return count or 0
def _apply_filters_with_saas_metadata(
self,
query,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
):
"""Apply filters to query that includes SAAS metadata."""
# Apply the same filters as the base class
conditions = []
if title__contains is not None:
conditions.append(
StoredConversationMetadata.title.like(f'%{title__contains}%')
)
if created_at__gte is not None:
conditions.append(StoredConversationMetadata.created_at >= created_at__gte)
if created_at__lt is not None:
conditions.append(StoredConversationMetadata.created_at < created_at__lt)
if updated_at__gte is not None:
conditions.append(
StoredConversationMetadata.last_updated_at >= updated_at__gte
)
if updated_at__lt is not None:
conditions.append(
StoredConversationMetadata.last_updated_at < updated_at__lt
)
if conditions:
query = query.where(*conditions)
return query
async def get_app_conversation_info(
self, conversation_id: UUID
) -> AppConversationInfo | None:
"""Get conversation info with user_id from SAAS metadata."""
query = await self._secure_select_with_saas_metadata()
query = query.where(
StoredConversationMetadata.conversation_id == str(conversation_id)
)
result_set = await self.db_session.execute(query)
result = result_set.first()
if result:
stored_metadata, saas_metadata = result
# Fetch sub-conversation IDs
sub_conversation_ids = await self.get_sub_conversation_ids(conversation_id)
return self._to_info_with_user_id(
stored_metadata,
saas_metadata,
sub_conversation_ids=sub_conversation_ids,
)
return None
async def batch_get_app_conversation_info(
self, conversation_ids: list[UUID]
) -> list[AppConversationInfo | None]:
"""Batch get conversation info with user_id from SAAS metadata."""
conversation_id_strs = [
str(conversation_id) for conversation_id in conversation_ids
]
query = await self._secure_select_with_saas_metadata()
query = query.where(
StoredConversationMetadata.conversation_id.in_(conversation_id_strs)
)
result = await self.db_session.execute(query)
rows = result.all()
# Create a mapping of conversation_id to (metadata, saas_metadata)
info_by_id = {}
for stored_metadata, saas_metadata in rows:
info_by_id[stored_metadata.conversation_id] = (
stored_metadata,
saas_metadata,
)
results: list[AppConversationInfo | None] = []
for conversation_id in conversation_id_strs:
if conversation_id in info_by_id:
stored_metadata, saas_metadata = info_by_id[conversation_id]
# Fetch sub-conversation IDs for each conversation
sub_conversation_ids = await self.get_sub_conversation_ids(
UUID(conversation_id)
)
results.append(
self._to_info_with_user_id(
stored_metadata,
saas_metadata,
sub_conversation_ids=sub_conversation_ids,
)
)
else:
results.append(None)
return results
async def save_app_conversation_info(
self, info: AppConversationInfo
) -> AppConversationInfo:
"""Save conversation info and create/update SAAS metadata with user_id and org_id."""
# Save the base conversation metadata
await super().save_app_conversation_info(info)
# Get current user_id for SAAS metadata
user_id_str = await self.user_context.get_user_id()
if user_id_str:
# Convert string user_id to UUID
user_id_uuid = UUID(user_id_str)
user_query = select(User).where(User.id == user_id_uuid)
result = await self.db_session.execute(user_query)
user = result.scalar_one_or_none()
assert user
# Check if SAAS metadata already exists
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)
)
result = await self.db_session.execute(saas_query)
existing_saas_metadata = result.scalar_one_or_none()
assert existing_saas_metadata is None or (
existing_saas_metadata.user_id == user_id_uuid
and existing_saas_metadata.org_id == user.current_org_id
)
if not existing_saas_metadata:
# Create new SAAS metadata
# Set org_id to user_id as specified in requirements
saas_metadata = StoredConversationMetadataSaas(
conversation_id=str(info.id),
user_id=user_id_uuid,
org_id=user.current_org_id,
)
self.db_session.add(saas_metadata)
await self.db_session.commit()
return info
def _to_info_with_user_id(
self,
stored: StoredConversationMetadata,
saas_metadata: StoredConversationMetadataSaas,
sub_conversation_ids: list[UUID] | None = None,
) -> AppConversationInfo:
"""Convert stored metadata to AppConversationInfo with user_id from SAAS metadata."""
# Use the base _to_info method to get the basic info
info = self._to_info(stored, sub_conversation_ids=sub_conversation_ids)
# Override the created_by_user_id with the user_id from SAAS metadata
info.created_by_user_id = (
str(saas_metadata.user_id) if saas_metadata.user_id else None
)
return info
class SaasAppConversationInfoServiceInjector(AppConversationInfoServiceInjector):
"""Enterprise injector for SQLAppConversationInfoService with SAAS filtering."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[AppConversationInfoService, None]:
from openhands.app_server.config import (
get_db_session,
get_user_context,
)
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
service = SaasSQLAppConversationInfoService(
db_session=db_session, user_context=user_context
)
yield service

View File

@@ -0,0 +1,85 @@
from storage.api_key import ApiKey
from storage.auth_tokens import AuthTokens
from storage.billing_session import BillingSession
from storage.billing_session_type import BillingSessionType
from storage.conversation_callback import CallbackStatus, ConversationCallback
from storage.conversation_work import ConversationWork
from storage.experiment_assignment import ExperimentAssignment
from storage.feedback import ConversationFeedback, Feedback
from storage.github_app_installation import GithubAppInstallation
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
from storage.jira_conversation import JiraConversation
from storage.jira_dc_conversation import JiraDcConversation
from storage.jira_dc_user import JiraDcUser
from storage.jira_dc_workspace import JiraDcWorkspace
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from storage.linear_conversation import LinearConversation
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from storage.openhands_pr import OpenhandsPR
from storage.org import Org
from storage.org_member import OrgMember
from storage.proactive_convos import ProactiveConversation
from storage.role import Role
from storage.slack_conversation import SlackConversation
from storage.slack_team import SlackTeam
from storage.slack_user import SlackUser
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from storage.stored_custom_secrets import StoredCustomSecrets
from storage.stored_offline_token import StoredOfflineToken
from storage.stored_repository import StoredRepository
from storage.stripe_customer import StripeCustomer
from storage.subscription_access import SubscriptionAccess
from storage.subscription_access_status import SubscriptionAccessStatus
from storage.user import User
from storage.user_repo_map import UserRepositoryMap
from storage.user_settings import UserSettings
__all__ = [
'ApiKey',
'AuthTokens',
'BillingSession',
'BillingSessionType',
'CallbackStatus',
'ConversationCallback',
'ConversationFeedback',
'StoredConversationMetadataSaas',
'ConversationWork',
'ExperimentAssignment',
'Feedback',
'GithubAppInstallation',
'GitlabWebhook',
'JiraConversation',
'JiraDcConversation',
'JiraDcUser',
'JiraDcWorkspace',
'JiraUser',
'JiraWorkspace',
'LinearConversation',
'LinearUser',
'LinearWorkspace',
'MaintenanceTask',
'MaintenanceTaskStatus',
'OpenhandsPR',
'Org',
'OrgMember',
'ProactiveConversation',
'Role',
'SlackConversation',
'SlackTeam',
'SlackUser',
'StoredConversationMetadata',
'StoredOfflineToken',
'StoredRepository',
'StoredCustomSecrets',
'StripeCustomer',
'SubscriptionAccess',
'SubscriptionAccessStatus',
'User',
'UserRepositoryMap',
'UserSettings',
'WebhookStatus',
]

View File

@@ -1,4 +1,6 @@
from sqlalchemy import Column, DateTime, Integer, String, text
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from storage.base import Base
@@ -11,9 +13,13 @@ class ApiKey(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(255), nullable=False, unique=True, index=True)
user_id = Column(String(255), nullable=False, index=True)
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
name = Column(String(255), nullable=True)
created_at = Column(
DateTime, server_default=text('CURRENT_TIMESTAMP'), nullable=False
)
last_used_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
# Relationships
org = relationship('Org', back_populates='api_keys')

View File

@@ -9,8 +9,10 @@ from sqlalchemy import update
from sqlalchemy.orm import sessionmaker
from storage.api_key import ApiKey
from storage.database import session_maker
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.utils.async_utils import call_sync_from_async
@dataclass
@@ -25,7 +27,7 @@ class ApiKeyStore:
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
return f'{self.API_KEY_PREFIX}{random_part}'
def create_api_key(
async def create_api_key(
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
) -> str:
"""Create a new API key for a user.
@@ -39,16 +41,34 @@ class ApiKeyStore:
The generated API key
"""
api_key = self.generate_api_key()
user = await UserStore.get_user_by_id_async(user_id)
org_id = user.current_org_id
await call_sync_from_async(
self._store_api_key, user_id, org_id, api_key, name, expires_at
)
return api_key
def _store_api_key(
self,
user_id: str,
org_id: str,
api_key: str,
name: str | None,
expires_at: datetime | None = None,
) -> None:
"""Store an existing API key in the database."""
with self.session_maker() as session:
key_record = ApiKey(
key=api_key, user_id=user_id, name=name, expires_at=expires_at
key=api_key,
user_id=user_id,
org_id=org_id,
name=name,
expires_at=expires_at,
)
session.add(key_record)
session.commit()
return api_key
def validate_api_key(self, api_key: str) -> str | None:
"""Validate an API key and return the associated user_id if valid."""
now = datetime.now(UTC)
@@ -106,10 +126,20 @@ class ApiKeyStore:
return True
def list_api_keys(self, user_id: str) -> list[dict]:
async def list_api_keys(self, user_id: str) -> list[dict]:
"""List all API keys for a user."""
user = await UserStore.get_user_by_id_async(user_id)
org_id = user.current_org_id
return await call_sync_from_async(self._list_api_keys_from_db, user_id, org_id)
def _list_api_keys_from_db(self, user_id: str, org_id: str) -> list[ApiKey]:
with self.session_maker() as session:
keys = session.query(ApiKey).filter(ApiKey.user_id == user_id).all()
keys = (
session.query(ApiKey)
.filter(ApiKey.user_id == user_id)
.filter(ApiKey.org_id == org_id)
.all()
)
return [
{
@@ -123,10 +153,20 @@ class ApiKeyStore:
if 'MCP_API_KEY' != key.name
]
def retrieve_mcp_api_key(self, user_id: str) -> str | None:
async def retrieve_mcp_api_key(self, user_id: str) -> str | None:
user = await UserStore.get_user_by_id_async(user_id)
org_id = user.current_org_id
return await call_sync_from_async(
self._retrieve_mcp_api_key_from_db, user_id, org_id
)
def _retrieve_mcp_api_key_from_db(self, user_id: str, org_id: str) -> str | None:
with self.session_maker() as session:
keys: list[ApiKey] = (
session.query(ApiKey).filter(ApiKey.user_id == user_id).all()
session.query(ApiKey)
.filter(ApiKey.user_id == user_id)
.filter(ApiKey.org_id == org_id)
.all()
)
for key in keys:
if key.name == 'MCP_API_KEY':

View File

@@ -1,6 +1,8 @@
from datetime import UTC, datetime
from sqlalchemy import DECIMAL, Column, DateTime, Enum, String
from sqlalchemy import DECIMAL, Column, DateTime, Enum, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from storage.base import Base
@@ -11,9 +13,9 @@ class BillingSession(Base): # type: ignore
"""
__tablename__ = 'billing_sessions'
id = Column(String, primary_key=True)
user_id = Column(String, nullable=False)
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
status = Column(
Enum(
'in_progress',
@@ -24,15 +26,6 @@ class BillingSession(Base): # type: ignore
),
default='in_progress',
)
billing_session_type = Column(
Enum(
'DIRECT_PAYMENT',
'MONTHLY_SUBSCRIPTION',
name='billing_session_type_enum',
),
nullable=False,
default='DIRECT_PAYMENT',
)
price = Column(DECIMAL(19, 4), nullable=False)
price_code = Column(String, nullable=False)
created_at = Column(
@@ -43,3 +36,6 @@ class BillingSession(Base): # type: ignore
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
)
# Relationships
org = relationship('Org', back_populates='billing_sessions')

View File

@@ -0,0 +1,30 @@
from datetime import UTC, datetime
from sqlalchemy import Column, DateTime, Identity, Integer, String
from storage.base import Base
class BlockedEmailDomain(Base): # type: ignore
"""Stores blocked email domain patterns.
Supports blocking:
- Exact domains: 'example.com' blocks 'user@example.com'
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
"""
__tablename__ = 'blocked_email_domains'
id = Column(Integer, Identity(), primary_key=True)
domain = Column(String, nullable=False, unique=True)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)

Some files were not shown because too many files have changed in this diff Show More