Compare commits

...

177 Commits

Author SHA1 Message Date
HeyItsChloe
0fb5b985e0 Merge branch 'main' into APP-1167/complete-posthog 2026-04-17 14:45:21 -07:00
dependabot[bot]
a5a7a86600 chore(deps): bump actions/github-script from 7 to 9 (#13958)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 23:06:40 +02:00
HeyItsChloe
28fcfaae25 Merge branch 'APP-1167/complete-posthog' of https://github.com/OpenHands/OpenHands into APP-1167/complete-posthog 2026-04-17 12:26:49 -07:00
HeyItsChloe
57bcc69f64 added error tracking 2026-04-17 12:26:27 -07:00
aivong-openhands
5c8d7c4c2d Fix CVE-2026-40347: Update python-multipart to 0.0.26 (#13965)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-17 12:32:15 -05:00
Xingyao Wang
2068694ea0 fix(enterprise): Apply deployment LLM proxy URL override in /api/v1/users/me (#13980)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-17 12:23:42 +07:00
Tim O'Farrell
385122e260 refactor: Replace load_custom_secrets_names with search_custom_secrets (#13985)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 18:50:38 -06:00
Ash Clarke
97343ebe9a Fix V1 resumed conversation status sync (#13989)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 15:49:44 -06:00
Joe Laverty
926f25a74b bugfix(enterprise): Remove shared conversation enumerator endpoints (#13976) 2026-04-16 18:11:44 +00:00
openhands
1406937961 fix: format test file to pass ruff-format
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 18:00:55 +00:00
openhands
5d07387b4e fix: add git provider connected analytics and fix failing test
- Add git provider connected analytics tracking to V1 secrets router
- Fix test_keycloak_callback_redirects_to_keycloak_when_offline_token_invalid
  by adding missing OrgStore.get_org_by_id mock

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 17:52:54 +00:00
Tim O'Farrell
52c4d0d9d9 Fix get_latest_token to return str instead of SecretStr (#13974)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 11:41:43 -06:00
openhands
d1637cbc3c fix: resolve CI failures from merge conflict resolution
- Remove unused get_user_id import from secrets.py (ruff lint)
- Update test mock from posthog to get_analytics_service in test_auth_routes.py

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 16:49:50 +00:00
openhands
0eab81f89b Merge remote-tracking branch 'origin/main' into APP-1167/complete-posthog
# Conflicts:
#	enterprise/tests/unit/test_auth_routes.py
#	frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx
#	frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx
#	frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx
#	frontend/__tests__/components/features/user/user-context-menu.test.tsx
#	frontend/__tests__/hooks/mutation/use-create-conversation.test.tsx
#	frontend/__tests__/posthog-tracking.test.tsx
#	frontend/__tests__/routes/billing.test.tsx
#	frontend/src/components/features/chat/chat-interface.tsx
#	frontend/src/components/features/device-verify/enterprise-banner.tsx
#	frontend/src/components/shared/modals/settings/settings-form.tsx
#	frontend/src/context/ws-client-provider.tsx
#	frontend/src/contexts/conversation-websocket-context.tsx
#	frontend/src/hooks/mutation/use-create-conversation.ts
#	frontend/src/hooks/mutation/use-save-settings.ts
#	frontend/src/hooks/mutation/use-submit-onboarding.ts
#	frontend/src/hooks/use-conversation-name-context-menu.ts
#	frontend/src/routes/billing.tsx
#	frontend/src/routes/onboarding-form.tsx
#	openhands/app_server/event_callback/webhook_router.py
#	openhands/server/routes/secrets.py
#	openhands/server/services/conversation_service.py
2026-04-16 16:35:51 +00:00
Hiep Le
f1ff98b2fc fix: add flat SDK compat fields to /api/v1/users/me response (#13957) 2026-04-16 23:11:51 +07:00
dependabot[bot]
26c43d1955 chore(deps): bump pillow from 12.1.1 to 12.2.0 (#13933)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 10:47:35 -05:00
Graham Neubig
d81c2bc0a6 Fix duplicate enterprise migration revision (#13950)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 11:02:08 -04:00
Tim O'Farrell
fdf5c398fd frontend: Remove deprecated V0 conversation APIs (#13963)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 08:57:25 -06:00
HeyItsChloe
c78b923468 feat(frontend): Remove onboarding feature flags (#13947)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-16 12:28:27 +07:00
Tim O'Farrell
db78925d77 Fix merge error in migrations (#13951) 2026-04-16 00:40:19 +00:00
Graham Neubig
b4da0e1c69 settings: expose SDK settings schema to OpenHands (#13306)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-04-15 17:00:35 -06:00
Vasco Schiavo
d548665bcf feat(frontend): add /btw side-channel command for asking the agent mid-task (#13918)
Co-authored-by: Juan Michelini <juan@juan.com.uy>
2026-04-15 18:47:44 -03:00
Engel Nyst
eb940ea5e7 Use SDK package public exports where available (#13402)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-15 22:05:08 +02:00
HeyItsChloe
22b91976fd feat(frontend): removed PROJ_USER_JOURNEY feature flag (#13940)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-16 01:35:19 +07:00
Hiep Le
dcf044f8c3 feat(frontend): add automations button to sidebar (#13941) 2026-04-16 01:34:55 +07:00
OpenHands Bot
d58106b29b fix(security): extend RedactURLParamsFilter to cover all uvicorn log formats (#13914)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com>
2026-04-15 15:24:24 -03:00
OpenHands Bot
e11faa6dd1 fix(security): redact MCP config secrets before logging (#13913)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com>
2026-04-15 15:22:50 -03:00
HeyItsChloe
b4b77fbc31 feat: Add DEPLOYMENT_MODE detection for conditional onboarding (#13675)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-16 00:57:57 +07:00
Tim O'Farrell
ef452b6544 Fix concurrent GET /api/v1/web-client/config calls (#13905)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-14 15:02:45 -06:00
Tim O'Farrell
0eafa9fd15 APP-1170: Jira : add V1 conversation support and multiple bug fixes (#13909)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-14 14:39:00 -06:00
Joe Laverty
ab64a65f25 feat(enterprise): Build ARM64 image for enterprise, use native runners for all images (#13921) 2026-04-14 16:28:18 -04:00
Engel Nyst
4cdf88d480 Update LICENSE (#13924) 2026-04-14 19:03:41 +00:00
Xingyao Wang
eab9d9e3c7 fix: paginate through backend pages when filtering shared events (#13910)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-14 14:20:33 +01:00
Hiep Le
58df84e16c feat: add manage automations permission and broadcast org selection (#13908) 2026-04-14 18:05:02 +07:00
Vasco Schiavo
3cd74d3bac fix(jira): use markdown_to_jira_markup for proper wiki markup rendering (#13879)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-13 15:40:01 -06:00
simonrosenberg
20018842a4 fix(security): suppress SQLAlchemy DEBUG logging that leaks credentials (#13719)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:49:25 -03:00
Ricardo-M-L
cce2080ae0 fix: correct wrong variable in max_size validation error message in condensers (#13816) 2026-04-13 09:44:41 -06:00
Ricardo-M-L
a0304b9e4c fix: correct logger format args and -0 slice bug (#13817) 2026-04-13 09:43:30 -06:00
Tim O'Farrell
de492b792f refactor(llm-settings): Use V1 providers/models endpoints, lazy-load models per provider (#13892)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-12 22:42:57 -06:00
gpothier
7a6eb7e07c fix: fallback to LLM_BASE_URL for openhands_provider_base_url (#13880)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-11 16:49:13 -06:00
dependabot[bot]
c92178ac6b chore(deps): bump docker/metadata-action from 5 to 6 (#13702)
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-04-12 00:48:47 +02:00
dependabot[bot]
5400fea1e4 chore(deps): bump actions/upload-artifact from 5 to 7 (#13699)
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-04-12 00:47:48 +02:00
dependabot[bot]
635b090065 chore(deps): bump actions/checkout from 4 to 6 (#13703)
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-04-12 00:46:37 +02:00
dependabot[bot]
f3815a769f chore(deps): bump docker/login-action from 3 to 4 (#13701)
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-04-12 00:45:13 +02:00
Graham Neubig
4f81d2ae7a Filter out ConversationStateUpdateEvent from shared-events endpoints (#13888)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-11 16:03:02 -05:00
Tim O'Farrell
a06b9ccffa Remove dead frontend exports (#13891)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-11 12:13:04 -06:00
Tim O'Farrell
8406dcb82f Remove dead frontend exports (#13889)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-11 11:40:12 -06:00
Tim O'Farrell
6c0a92c2cd Update frontend to use V1 settings endpoints (#13887)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-11 09:27:53 -06:00
Tim O'Farrell
7f25348506 Migrate git-service API to V1 endpoints (#13877)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-11 08:11:58 -06:00
aivong-openhands
e9067237f2 Fix CVE-2025-64340: Update fastmcp to 3.2.0 (#13685)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 20:08:57 +00:00
Tim O'Farrell
cae7d36522 Remove unused startConversation method and dead code (#13876)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 13:24:42 -06:00
Tim O'Farrell
27a2d59c23 Update getUser() to use V1 API endpoint /api/v1/users/git-info (#13875)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 12:23:00 -06:00
Tim O'Farrell
d3d916745a Update Suggestions Service API to use new V1 endpoint with pagination (#13872)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 11:36:15 -06:00
Tim O'Farrell
50f1d332cc Remove V1 enabled flag and agents from frontend (#13871)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 11:14:25 -06:00
Tim O'Farrell
de53245d1b refactor(frontend): Remove unused API methods from conversation-service.api.ts (#13870)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 09:43:33 -06:00
Vasco Schiavo
8c2661638e fix(slack): use markdown_text parameter for proper Markdown rendering in V1 (#13869)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 11:37:20 -04:00
Tim O'Farrell
bdbaba0c34 Remove unused searchEventsV0 method from EventService (#13865)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 09:20:58 -06:00
Tim O'Farrell
d866d735d9 refactor(frontend): Remove V0 conversation creation path (#13823)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 06:58:51 -06:00
Tim O'Farrell
39f3b293f5 Fix: Use container StartedAt for Docker sandbox status grace period calculation (#13841)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 06:58:26 -06:00
Rohit Malhotra
fa4afa9412 fix(enterprise): migrate device_code model to SQLAlchemy 2.0 [2/13] (#13848)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 05:13:31 +00:00
Rohit Malhotra
f274d5e90f fix(enterprise): migrate simple storage models to SQLAlchemy 2.0 [1/13] (#13847)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 01:04:05 -04:00
Rohit Malhotra
dd5eb69c65 fix(enterprise): enable SQLAlchemy 2.0 type checking foundation (#13846)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 00:42:17 -04:00
OpenHands Bot
21d86b6b5e fix: redact MCP server secrets from log output (#13840)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com>
2026-04-09 19:02:28 -03:00
OpenHands Bot
2c2e37902f fix: redact session_api_key from uvicorn WebSocket access logs (#13839)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com>
2026-04-09 19:02:23 -03:00
Tim O'Farrell
f7f029ec1a Removed the path for creating V0 conversations in the API. (#13837)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 15:10:27 -06:00
Graham Neubig
3e9017bb6e Remove CODEOWNERS file (#13833)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 16:55:58 -04:00
Tim O'Farrell
78e48ace2d Remove microagent management UI (#13835)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 13:18:24 -06:00
chuckbutkus
60ece6d7c2 feat: Add organization/authorization info to /api/v1/users/me endpoint (#13822)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-04-09 14:37:13 -04:00
Vasco Schiavo
738e7a9834 feat(frontend): render GFM tables with visible borders in chat messages (#13825) 2026-04-09 16:16:13 +07:00
aivong-openhands
8b4a1f9763 Fix CVE-2026-34591: Update poetry to 2.3.3 (#13711)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 00:07:42 +02:00
Tim O'Farrell
0804abec80 Remove V0-only feedback functionality (#13821)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 13:48:41 -06:00
Tim O'Farrell
06c3d9c17b Remove microagent functionality from frontend code (#13820)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 12:19:44 -06:00
Tim O'Farrell
754a96e7f3 chore(frontend): remove unused hooks and code (#13810)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 13:10:19 -06:00
Tim O'Farrell
211b73a088 Refactor conversation list to use V1 API (#13803)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 12:35:11 -06:00
Hiep Le
54041dd093 feat: remove ENABLE_ORG_CLAIMS_RESOLVER_ROUTING feature flag (#13809) 2026-04-08 00:55:36 +07:00
Hiep Le
f271346724 feat(backend): route Jira resolver conversations to claimed org workspaces (#13805) 2026-04-07 23:58:52 +07:00
Hiep Le
d6a0dd7fe4 feat(backend): route Linear resolver conversations to claimed org workspaces (#13804) 2026-04-07 23:22:48 +07:00
Tim O'Farrell
e46bcfa82f Add V1 API endpoints for git search and branches (#13794)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 06:52:56 -06:00
Tim O'Farrell
2eefa5edfd Deprecate /api/options/models, add /api/v1/config/models/search endpoint (#13799)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 06:51:49 -06:00
Ray Myers
54858c0fc0 ci: retire Blacksmith from all GitHub Actions workflows (#13795)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 16:51:09 -05:00
Rohit Malhotra
384c324652 fix(slack): immediately display 'No Repository' option (#13791)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 14:21:59 -04:00
Tim O'Farrell
4e68f57807 Add V1 git routes with pagination for installations and repositories (#13790)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 12:01:22 -06:00
Jamie Chicago
649ebc4078 Succinct pr template (#13779)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 19:05:24 +02:00
Tim O'Farrell
e3246c27d4 Added new v1 endpoint for user git info and deprecated old endpoint (#13787)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 09:54:24 -06:00
Ray Myers
72194f19db chore: Add sdk to mypy checking and fix the resulting errors (#13637)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-04-06 11:43:31 -04:00
gpothier
0c5e30ab33 Add KVM device passthrough support for hardware virtualization (#13618)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-04-06 14:57:58 +00:00
simonrosenberg
b8f2932b02 fix(security): redact credentials from MCP config logging (#13720)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 08:46:42 -06:00
dependabot[bot]
62673c028a chore(deps): bump the version-all group across 1 directory with 7 updates (#13774)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-04-06 08:39:09 -06:00
Hiep Le
7af2285fe6 fix(backend): custom API key overwritten when using non-OpenHands provider in basic view (#13785) 2026-04-06 21:14:14 +07:00
Hiep Le
69d281c6be fix(frontend): prevent budget/credit error banner from disappearing immediately (#13786) 2026-04-06 21:13:30 +07:00
Jamie Chicago
8ce3089a68 Add contributors section to README (#13696)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-04 01:27:45 +02:00
Tim O'Farrell
b9b10ebf5e APP-1197 Mark conversation endpoints as deprecated with updated docs (#13775)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 14:45:32 -06:00
Tim O'Farrell
ce6d5b77c4 Add more endpoints as deprecated (microagent repository endpoints) (#13776)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 20:45:14 +00:00
simonrosenberg
a458c9b785 Fix credential leak in callback event logging (#13718)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:29:26 +00:00
Hiep Le
a65ddc3db6 feat(backend): route Slack resolver conversations to claimed org workspaces (#13758) 2026-04-04 03:09:21 +07:00
Tim O'Farrell
732a1c1991 APP-1197 Migrate secrets endpoints to V1 API (#13770)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 14:06:51 -06:00
Hiep Le
d058323a87 feat(backend): route gitlab resolver conversations to claimed org workspaces (#13755) 2026-04-04 02:27:46 +07:00
aivong-openhands
7d04cffe4e Fix CVE-2026-25645: Update requests to 2.33.1 (#13692)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-03 13:55:31 -05:00
Hiep Le
6ad27b77bb feat(backend): route resolver conversations to claimed org workspaces (#13713) 2026-04-04 01:32:43 +07:00
aivong-openhands
2739fc8fbe Fix CVE-2026-22815: Update aiohttp to 3.13.5 (#13705)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-03 13:21:05 -05:00
dependabot[bot]
38b7e10252 chore(deps): bump the security-all group across 1 directory with 2 updates (#13764)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 11:46:46 -05:00
mamoodi
7b7d1c0c55 Update CODEOWNERS (#13762) 2026-04-03 12:01:58 -04:00
Tim O'Farrell
e38eda4ac9 APP-1197 Migrate settings endpoints to V1 API (/api/v1/settings) (#13759)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 09:38:24 -06:00
aivong-openhands
99c19b6ef0 enterprise lock update openhands aci to version already in openhands (#13704)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 09:57:14 -04:00
sp.wack
bd4a094eaf Merge branch 'main' into feat/revise-posthog 2026-03-27 18:45:53 +04:00
sp.wack
3ce4f629d6 Apply suggestion from @amanape 2026-03-27 18:45:38 +04:00
amanape
78e8a6c986 feat: add enterprise lead-gen events and useClientAnalytics hook for UI-only tracking 2026-03-27 18:24:19 +04:00
amanape
bacbbad32a Merge main into feat/revise-posthog - resolve conflicts, remove useTracking from new main files 2026-03-27 18:03:13 +04:00
openhands
d077b48a19 fix: remove extra blank line in auth.py
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 15:33:42 +00:00
amanape
5e96574730 Merge remote-tracking branch 'origin/main' into feat/revise-posthog
# Conflicts:
#	enterprise/server/routes/auth.py
2026-03-11 17:54:27 +04:00
amanape
589d12b5bd fix: resolve lint errors and update test assertions for analytics migration 2026-03-07 01:14:41 +04:00
sp.wack
cf48f4c91b Delete scripts/posthog-frontend-health-dashboard.py 2026-03-06 23:05:16 +04:00
amanape
63b93b6dc3 chore: remove .planning/ from git tracking 2026-03-06 22:04:30 +04:00
amanape
6f0ee09629 Merge remote-tracking branch 'origin/main' into feat/revise-posthog
# Conflicts:
#	enterprise/server/routes/auth.py
2026-03-06 21:44:41 +04:00
amanape
ed461b3ec1 feat(07-02): add PostHog Frontend Health dashboard creation script with 6 tiles (LCP, FCP, INP, CLS, error rate, top errors) 2026-03-06 20:43:11 +04:00
amanape
155da8dfd1 test(07-01): add test verifying PostHog health monitoring config options 2026-03-06 20:39:32 +04:00
amanape
29aa4f26d8 feat(07-01): add web vitals, error tracking, and network timing config to PostHogProvider 2026-03-06 20:39:03 +04:00
amanape
5409004c8d docs(07): create phase plan for frontend health monitoring 2026-03-06 20:35:02 +04:00
amanape
918f366a76 feat(06-01): migrate oauth_device.py analytics to Foundation patterns (resolve_context, identify_user, typed methods) 2026-03-06 20:18:54 +04:00
amanape
9b02f06400 feat(06-02): migrate billing.py, onboarding.py, and orgs.py to typed event methods + resolve_context 2026-03-06 20:18:22 +04:00
amanape
331c513042 feat(06-01): migrate auth.py analytics to Foundation patterns (resolve_context, identify_user, typed methods) 2026-03-06 20:18:17 +04:00
amanape
61af4662f1 feat(06-02): migrate conversation_callback_utils.py to resolve_context + typed event methods 2026-03-06 20:17:29 +04:00
amanape
4b77beaaa5 docs(06-migration): create phase plan for analytics call site migration 2026-03-06 20:13:14 +04:00
amanape
c0f08a33c3 feat(05-02): add 10 typed event methods to AnalyticsService 2026-03-06 19:55:08 +04:00
amanape
ddf2713483 test(05-02): add failing tests for 10 typed event methods on AnalyticsService 2026-03-06 19:54:14 +04:00
amanape
d39de5998a feat(05-01): add identify_user method to AnalyticsService consolidating person+group identify 2026-03-06 19:50:17 +04:00
amanape
782817c1c1 test(05-01): add failing tests for identify_user method on AnalyticsService 2026-03-06 19:49:41 +04:00
amanape
463777581e feat(05-01): add AnalyticsContext dataclass and resolve_context factory with full test coverage 2026-03-06 19:49:01 +04:00
amanape
5c42ee7a6c test(05-01): add failing tests for AnalyticsContext dataclass and resolve_context factory 2026-03-06 19:47:06 +04:00
amanape
aa9aed7016 docs(05-foundation): create phase plan (2 plans, 2 waves) 2026-03-06 19:41:22 +04:00
amanape
894d0eb439 fix: use correct import paths for enterprise modules in container runtime
The Docker build copies enterprise/ contents flat into /app/, so
`from enterprise.storage.X` doesn't exist at runtime — must use
`from storage.X` to match the actual module layout.
2026-03-06 00:37:34 +04:00
amanape
7c8e0b1eec fix(q6): add missing await on OrgStore.get_org_by_id in oauth_device.py 2026-03-05 22:51:19 +04:00
chuckbutkus
1a5d024c47 Merge branch 'main' into feat/revise-posthog 2026-03-05 12:33:30 -05:00
Chuck Butkus
0738e75dcf Fix async call 2026-03-05 12:30:49 -05:00
openhands
54766b4aeb fix: update PostHog tests and add missing await in auth.py
- Update test_auth_routes.py to use get_analytics_service instead of posthog
- Add OrgStore.get_org_by_id mocks to all tests using analytics
- Fix missing await for OrgStore.get_org_by_id call in auth.py
- Update assertions from .set to .set_person_properties

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-05 14:27:41 +00:00
amanape
4f65eae750 Merge remote-tracking branch 'origin/main' into feat/revise-posthog
# Conflicts:
#	frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx
#	frontend/src/components/features/context-menu/account-settings-context-menu.tsx
2026-03-05 17:45:34 +04:00
amanape
fdb6369476 reset 9 more formatting-only files to origin/main 2026-03-05 17:42:25 +04:00
amanape
77d672c68d revert unrelated ruff formatting changes from 23 files 2026-03-05 17:39:27 +04:00
Tim O'Farrell
21ac2a77ff Merge branch 'main' into feat/revise-posthog 2026-03-04 11:35:00 -07:00
openhands
b1c61c1534 fix: apply ruff formatting to webhook_router.py
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-04 14:24:22 +00:00
amanape
f450c407b5 Merge branch 'main' into feat/revise-posthog 2026-03-04 17:51:55 +04:00
openhands
500ed84d01 fix: apply enterprise linting fixes
- Fix import order to follow conventions
- Reformat assert statements for better readability
- Remove unnecessary blank lines

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 20:54:43 +00:00
amanape
999c18e072 fix: run ruff format on all enterprise files and fix remaining test mocks 2026-03-04 00:38:00 +04:00
amanape
a2e16d4819 fix: resolve ruff lint/format errors and update test mocks for analytics service 2026-03-04 00:23:30 +04:00
sp.wack
2f5147836f Merge branch 'main' into feat/revise-posthog 2026-03-03 23:24:28 +04:00
amanape
ed0f104645 fix(quick-3): remove stale experiments package from enterprise/pyproject.toml 2026-03-03 23:22:59 +04:00
amanape
192cfd5d91 chore(quick-2): regenerate enterprise/poetry.lock for posthog ^7.0.0 and pillow ^12.1.1
- Sync lock file after pyproject.toml bumped posthog ^6.0.0 -> ^7.0.0 and pillow ^12.1.0 -> ^12.1.1
2026-03-03 22:22:54 +04:00
amanape
8c2d3d1b9d fix(quick-2): fix ruff lint and format errors in webhook_router.py and secrets.py
- Fix I001 import ordering in webhook_router.py (top-level block: analytics before app_server/config; inline block: stdlib uuid before datetime)
- Apply ruff-format to both files (line length, quote style, indentation)
2026-03-03 22:22:32 +04:00
amanape
d3a274bbfa Merge remote-tracking branch 'origin/main' into feat/revise-posthog
# Conflicts:
#	enterprise/poetry.lock
#	frontend/src/components/features/auth/login-content.tsx
2026-03-03 22:10:39 +04:00
amanape
b9107ea3ad chore: remove .planning/ from tracking and add to .gitignore 2026-03-03 18:03:24 +04:00
amanape
8d66a58943 docs(04-01): complete activation events plan summary and state update 2026-03-03 17:24:13 +04:00
amanape
7864e9a8e3 docs(04-02): complete onboarding submission endpoint plan 2026-03-03 17:22:33 +04:00
amanape
f0b7e36bab feat(04-01): add GIT_PROVIDER_CONNECTED event capture in store_provider_tokens 2026-03-03 17:21:58 +04:00
amanape
53e87a7c27 feat(04-01): add USER_ACTIVATED event capture for first finished conversation 2026-03-03 17:21:19 +04:00
amanape
926ebf6906 feat(04-02): wire frontend onboarding hook to real API endpoint 2026-03-03 17:20:33 +04:00
amanape
7f25e9cad8 feat(04-02): create backend onboarding endpoint with analytics capture 2026-03-03 17:20:16 +04:00
amanape
2689768c95 docs(04-activation-and-dashboards): create phase plan 2026-03-03 05:00:34 +04:00
amanape
2f467558ed docs(04): research phase 4 activation and dashboards 2026-03-03 04:54:12 +04:00
amanape
b42ab23e1f feat(03-02): delete posthog-tracking test and remove useTracking mocks from 5 test files 2026-03-03 04:00:50 +04:00
amanape
3c5c307930 feat(03-02): remove all direct posthog.capture() calls from source files 2026-03-03 03:57:52 +04:00
amanape
d62d32af74 feat(03-01): add PostHog tracing headers and remove useTracking hook 2026-03-03 03:55:52 +04:00
amanape
f6201dd0de feat(02-03): add V0 best-effort terminal state analytics in conversation_callback_utils 2026-03-03 03:32:42 +04:00
amanape
ed4e2efd50 feat(02-03): add V1 terminal state analytics in on_event webhook 2026-03-03 03:32:42 +04:00
amanape
9b00b66efd feat(02-02): capture 'conversation created' event in V0 conversation_service 2026-03-03 03:16:22 +04:00
amanape
d78d9c4d99 feat(02-01): capture 'credit purchased' event in success_callback 2026-03-03 03:16:14 +04:00
amanape
04577c6448 feat(02-02): capture 'conversation created' event in V1 webhook_router 2026-03-03 03:15:55 +04:00
amanape
f8a0533f91 feat(02-01): capture 'user signed up' event in keycloak_callback 2026-03-03 03:15:49 +04:00
amanape
893a0db754 feat(01-02): wire SaasAppLifespanService and PostHogSessionMiddleware into saas_server.py 2026-03-03 02:42:51 +04:00
amanape
1f2bef34e3 feat(01-03): add analytics identity to device auth and org switch flows 2026-03-03 02:42:51 +04:00
amanape
62ed9e47cf feat(01-03): replace posthog.set() with full analytics identity in keycloak_callback 2026-03-03 02:42:51 +04:00
amanape
7b87237d3e feat(01-02): implement SaasAppLifespanService and PostHogSessionMiddleware 2026-03-03 02:42:51 +04:00
amanape
af74146f80 test(01-02): add failing tests for SaasAppLifespanService and PostHogSessionMiddleware 2026-03-03 02:42:51 +04:00
amanape
7760aba8e7 feat(01-03): set user_consents_to_analytics=True for new SaaS users in UserStore.create_user() 2026-03-03 02:42:51 +04:00
amanape
8550c91d0d chore(01-04): remove enterprise/experiments/ directory 2026-03-03 02:42:51 +04:00
amanape
d8db62b85b chore(01-01): bump PostHog SDK constraint to ^7.0.0 in enterprise/pyproject.toml 2026-03-03 02:42:51 +04:00
amanape
677f9bdd81 feat(01-01): implement AnalyticsService, constants, and OSS install ID module 2026-03-03 02:42:51 +04:00
amanape
9b1ce6d330 test(01-01): add failing tests for AnalyticsService, OSS install ID, and constants 2026-03-03 02:42:51 +04:00
628 changed files with 32017 additions and 23915 deletions

8
.github/CODEOWNERS vendored
View File

@@ -1,8 +0,0 @@
# CODEOWNERS file for OpenHands repository
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
/frontend/ @amanape @hieptl
/openhands-ui/ @amanape @hieptl
/openhands/ @tofarr @malhotra5 @hieptl
/enterprise/ @chuckbutkus @tofarr @malhotra5
/evaluation/ @xingyaoww @neubig

View File

@@ -1,38 +1,46 @@
<!-- 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 -->
<!-- Keep this PR as draft until it is ready for review. -->
## Summary of PR
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
<!-- Summarize what the PR does -->
- [ ] A human has tested these changes.
## 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. -->
## Why
## Change Type
<!-- Describe problem, motivation, etc.-->
<!-- Choose the types that apply to your PR -->
## Summary
<!-- 1-3 bullets describing what changed. -->
-
## Issue Number
<!-- Required if there is a relevant issue to this PR. -->
## How to Test
<!--
Required. Share the steps for the reviewer to be able to test your PR. e.g. You can test by running `npm install` then `npm build dev`.
If you could not test this, say why.
-->
## Video/Screenshots
<!--
Provide a video or screenshots of testing your PR. e.g. you added a new feature to the gui, show us the video of you testing it successfully.
-->
## Type
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Feature
- [ ] Refactor
- [ ] Other (dependency update, docs, typo fixes, etc.)
- [ ] Breaking change
- [ ] Docs / chore
## Checklist
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
## Notes
- [ ] I have read and reviewed the code and I understand what the code is doing.
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
## Fixes
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
Resolves #(issue)
## Release Notes
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
end-user friendly description for your change below the checkbox. -->
- [ ] Include this change in the Release Notes.
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -192,7 +192,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: tests/e2e/test-results/
@@ -200,7 +200,7 @@ jobs:
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: openhands-logs
path: |

View File

@@ -17,7 +17,7 @@ concurrency:
jobs:
fe-e2e-test:
name: FE E2E Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [22]
@@ -26,9 +26,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
@@ -39,7 +41,7 @@ jobs:
working-directory: ./frontend
run: npx playwright test --project=chromium
- name: Upload Playwright report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report

View File

@@ -21,7 +21,7 @@ jobs:
# Run frontend unit tests
fe-test:
name: FE Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [22]
@@ -30,9 +30,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci

View File

@@ -30,50 +30,51 @@ env:
jobs:
define-matrix:
runs-on: blacksmith
runs-on: ubuntu-latest
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
platforms: ${{ steps.define-base-images.outputs.platforms }}
architectures: ${{ steps.define-base-images.outputs.architectures }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
architectures='["amd64", "arm64"]'
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
platforms="linux/amd64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" }
]')
else
platforms="linux/amd64,linux/arm64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
echo "architectures=$architectures" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images
# Builds the OpenHands Docker images (one per architecture, natively)
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
name: Build App Image (${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
needs: define-matrix
outputs:
# All arch variants produce the same base tags, so any entry works
base_tags: ${{ steps.build.outputs.base_tags }}
permissions:
contents: read
packages: write
strategy:
matrix:
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -85,33 +86,79 @@ jobs:
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Build and push app image
id: build
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push -p ${{ needs.define-matrix.outputs.platforms }}
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push --arch ${{ matrix.arch }}
# Builds the runtime Docker images
# Output base tags (without arch suffix) for the merge job
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --arch ${{ matrix.arch }} --dry
BASE_TAGS=$(jq -r '.base_tags | join("\n")' docker-build-dry.json)
echo "base_tags<<EOF" >> "$GITHUB_OUTPUT"
echo "$BASE_TAGS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
# Merges per-architecture app images into a multi-arch manifest
ghcr_build_app_merge:
name: Merge App Multi-Arch Manifest
runs-on: ubuntu-22.04
needs: [define-matrix, ghcr_build_app]
if: github.event.pull_request.head.repo.fork != true
permissions:
packages: write
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Merge multi-arch manifest
run: |
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
TAGS="${{ needs.ghcr_build_app.outputs.base_tags }}"
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
sources=""
for arch in $ARCHS; do
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
echo "::error::Missing image ${tag}-${arch}"
exit 1
fi
sources+=" ${tag}-${arch}"
done
echo "Creating manifest for $tag from:$sources"
docker buildx imagetools create -t "$tag" $sources
done <<< "$TAGS"
# Builds the runtime Docker images (one per architecture x base_image, natively)
ghcr_build_runtime:
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
name: Build Runtime Image (${{ matrix.base_image.tag }}, ${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
needs: define-matrix
outputs:
# Keyed by base_image tag so the merge job can access per-variant tags.
# Matrix outputs from different entries with the same key overwrite each other,
# but all arch variants of the same base_image produce identical base tags.
base_tags_nikolaik: ${{ steps.params.outputs.base_tags_nikolaik }}
base_tags_ubuntu: ${{ steps.params.outputs.base_tags_ubuntu }}
strategy:
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -122,7 +169,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: poetry
@@ -137,52 +184,103 @@ jobs:
run: |
echo SHORT_SHA=$(git rev-parse --short "$RELEVANT_SHA") >> $GITHUB_ENV
- name: Determine docker build params
id: params
if: github.event.pull_request.head.repo.fork != true
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --arch ${{ matrix.arch }} --dry
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
# Output base tags (without arch suffix) keyed by base_image tag for the merge job
BASE_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.base_tags | join("\n")')
echo "base_tags_${{ matrix.base_image.tag }}<<EOF" >> "$GITHUB_OUTPUT"
echo "$BASE_TAGS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
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
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}-${{ matrix.arch }}
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}-${{ matrix.arch }},mode=max
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
provenance: false
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: runtime-src-${{ matrix.base_image.tag }}
name: runtime-src-${{ matrix.base_image.tag }}-${{ matrix.arch }}
path: containers/runtime
# Merges per-architecture runtime images into multi-arch manifests
ghcr_build_runtime_merge:
name: Merge Runtime Multi-Arch Manifest
runs-on: ubuntu-22.04
needs: [define-matrix, ghcr_build_runtime]
if: github.event.pull_request.head.repo.fork != true
permissions:
packages: write
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Merge multi-arch manifests
run: |
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
# Merge all runtime base_image variants
for variant_tags in \
"${{ needs.ghcr_build_runtime.outputs.base_tags_nikolaik }}" \
"${{ needs.ghcr_build_runtime.outputs.base_tags_ubuntu }}"; do
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
sources=""
for arch in $ARCHS; do
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
echo "::error::Missing image ${tag}-${arch}"
exit 1
fi
sources+=" ${tag}-${arch}"
done
echo "Creating manifest for $tag from:$sources"
docker buildx imagetools create -t "$tag" $sources
done <<< "$variant_tags"
done
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
name: Push Enterprise Image (${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
permissions:
contents: read
packages: write
needs: [define-matrix, ghcr_build_app]
needs: [define-matrix, ghcr_build_app_merge]
# Do not build enterprise in forks
if: github.event.pull_request.head.repo.fork != true
outputs:
# Tags without arch suffix, for the merge job
base_tags: ${{ steps.meta_base.outputs.tags }}
strategy:
matrix:
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -196,7 +294,7 @@ jobs:
driver-opts: network=host
- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -204,6 +302,28 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/openhands/enterprise-server
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=sha,format=long
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=match,pattern=cloud-\d+\.\d+\.\d+
flavor: |
latest=auto
prefix=
suffix=-${{ matrix.arch }}
env:
DOCKER_METADATA_PR_HEAD_SHA: true
# Also compute base tags (no arch suffix) for the merge job output
- name: Extract base metadata for merge
id: meta_base
uses: docker/metadata-action@v5
with:
images: ghcr.io/openhands/enterprise-server
@@ -222,6 +342,7 @@ jobs:
suffix=
env:
DOCKER_METADATA_PR_HEAD_SHA: true
- name: Determine app image tag
shell: bash
run: |
@@ -229,7 +350,7 @@ jobs:
# rather than a mutable branch tag like "main" which can serve stale cached layers.
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
context: .
file: enterprise/Dockerfile
@@ -238,17 +359,54 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
platforms: linux/amd64
platforms: linux/${{ matrix.arch }}
# Add build provenance
provenance: true
# Add build attestations for better security
sbom: true
# Merges per-architecture enterprise images into a multi-arch manifest
ghcr_build_enterprise_merge:
name: Merge Enterprise Multi-Arch Manifest
runs-on: ubuntu-22.04
permissions:
packages: write
needs: [define-matrix, ghcr_build_enterprise]
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Merge multi-arch manifest
run: |
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
TAGS="${{ needs.ghcr_build_enterprise.outputs.base_tags }}"
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
sources=""
for arch in $ARCHS; do
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
echo "::error::Missing image ${tag}-${arch}"
exit 1
fi
sources+=" ${tag}-${arch}"
done
echo "Creating manifest for $tag from:$sources"
docker buildx imagetools create -t "$tag" $sources
done <<< "$TAGS"
# "All Runtime Tests Passed" is a required job for PRs to merge
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -256,8 +414,8 @@ jobs:
update_pr_description:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
needs: [ghcr_build_runtime]
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_runtime_merge]
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -9,7 +9,7 @@ jobs:
lint-fix-frontend:
if: github.event.label.name == 'lint-fix'
name: Fix frontend linting issues
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: write
pull-requests: write
@@ -22,13 +22,14 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: |
cd frontend
npm install --frozen-lockfile
working-directory: ./frontend
run: npm ci
- name: Generate i18n and route types
run: |
cd frontend
@@ -58,7 +59,7 @@ jobs:
lint-fix-python:
if: github.event.label.name == 'lint-fix'
name: Fix Python linting issues
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: write
pull-requests: write
@@ -71,7 +72,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"

View File

@@ -19,34 +19,35 @@ jobs:
# Run lint on the frontend code
lint-frontend:
name: Lint frontend
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: |
cd frontend
npm install --frozen-lockfile
working-directory: ./frontend
run: npm ci
- name: Lint, TypeScript compilation, and translation checks
run: |
cd frontend
npm run lint
npm run make-i18n && tsc
npm run make-i18n && npx tsc
npm run check-translation-completeness
# Run lint on the python code
lint-python:
name: Lint python
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"
@@ -57,13 +58,13 @@ jobs:
lint-enterprise-python:
name: Lint enterprise python
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"

View File

@@ -18,7 +18,7 @@ concurrency:
jobs:
check-version:
name: Check if version has changed
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
defaults:
run:
shell: bash
@@ -55,7 +55,7 @@ jobs:
publish:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:

View File

@@ -192,7 +192,7 @@ jobs:
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
- name: Comment on issue with start message
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
@@ -206,7 +206,7 @@ jobs:
- name: Install OpenHands
id: install_openhands
uses: actions/github-script@v7
uses: actions/github-script@v9
env:
COMMENT_BODY: ${{ github.event.comment.body || '' }}
REVIEW_BODY: ${{ github.event.review.body || '' }}
@@ -269,7 +269,7 @@ jobs:
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always() # Upload even if the previous steps fail
with:
name: resolver-output
@@ -305,7 +305,7 @@ jobs:
# Step leaves comment for when agent is invoked on PR
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
uses: actions/github-script@v7
uses: actions/github-script@v9
if: always()
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
@@ -341,7 +341,7 @@ jobs:
# Step leaves comment for when agent is invoked on issue
- name: Comment on issue # Comment link to either PR or branch created by agent
uses: actions/github-script@v7
uses: actions/github-script@v9
if: always() # Comment on issue even if the previous steps fail
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
@@ -416,7 +416,7 @@ jobs:
# Leave error comment when both PR/Issue comment handling fail
- name: Fallback Error Comment
uses: actions/github-script@v7
uses: actions/github-script@v9
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
env:
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}

View File

@@ -31,7 +31,7 @@ jobs:
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@v5
- uses: actions/checkout@v6
if: steps.check-fork.outputs.is_fork == 'false'
with:
ref: ${{ github.event.pull_request.head.ref }}
@@ -59,7 +59,7 @@ jobs:
- name: Update PR comment after cleanup
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
@@ -93,7 +93,7 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Check for .pr/ directory
id: check
@@ -107,7 +107,7 @@ jobs:
- name: Post or update PR comment
if: steps.check.outputs.exists == 'true'
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';

View File

@@ -51,7 +51,7 @@ jobs:
# Always checkout main branch for security - cannot test script changes in PRs
- name: Checkout extensions repository
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: OpenHands/extensions
path: extensions
@@ -77,7 +77,7 @@ jobs:
--trace-file trace-info/laminar_trace_info.json
- name: Upload evaluation logs
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
if: always() && steps.check-trace.outputs.trace_exists == 'true'
with:
name: pr-review-evaluation-${{ github.event.pull_request.number }}

View File

@@ -19,7 +19,7 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
@@ -37,13 +37,15 @@ jobs:
- name: Install tmux
run: sudo apt-get update && sudo apt-get install -y tmux
- name: Setup Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -63,7 +65,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: coverage-openhands
path: |
@@ -73,7 +75,7 @@ jobs:
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.12"]
@@ -82,7 +84,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -95,7 +97,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"

View File

@@ -17,14 +17,14 @@ on:
jobs:
release:
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli' and don't start with 'cloud-'
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
steps:
- uses: actions/checkout@v6
- uses: useblacksmith/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install Poetry

View File

@@ -8,7 +8,7 @@ on:
jobs:
stale:
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v10

View File

@@ -19,7 +19,7 @@ concurrency:
jobs:
ui-build:
name: Build openhands-ui
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check if welcome comment already exists
id: check_comment
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
result-encoding: string
script: |
@@ -33,7 +33,7 @@ jobs:
- name: Leave welcome comment
if: steps.check_comment.outputs.result == 'false'
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;

View File

@@ -86,8 +86,19 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
<hr>
### Thank You to Our Contributors
<div align="center">
[![OpenHands Contributors](https://assets.openhands.dev/readme/openhands-openhands-contributors.svg)](https://github.com/OpenHands/OpenHands/graphs/contributors)
</div>
<hr>
### Trusted by Engineers at
<div align="center">
<strong>Trusted by engineers at</strong>
<br/><br/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
@@ -138,3 +149,5 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
</picture>
</div>
</div>

View File

@@ -20,9 +20,11 @@ ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# Pin Poetry version to match the version used to generate poetry.lock
ARG POETRY_VERSION=2.3.3
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential jq gettext \
&& python3 -m pip install poetry --break-system-packages
&& python3 -m pip install "poetry==${POETRY_VERSION}" --break-system-packages
COPY pyproject.toml poetry.lock ./
RUN touch README.md

View File

@@ -8,18 +8,18 @@ push=0
load=0
tag_suffix=""
dry_run=0
platform_override=""
arch_suffix=""
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--dry]"
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry] [--arch <arch>]"
echo " -i: Image name (required)"
echo " -o: Organization name"
echo " --push: Push the image"
echo " --load: Load the image"
echo " -t: Tag suffix"
echo " -p: Platform(s) to build for (e.g. linux/amd64 or linux/amd64,linux/arm64)"
echo " --dry: Don't build, only create build-args.json"
echo " --arch: Architecture suffix (e.g. amd64 or arm64). Appends -<arch> to tags and forces single-platform build"
exit 1
}
@@ -31,8 +31,8 @@ while [[ $# -gt 0 ]]; do
--push) push=1; shift ;;
--load) load=1; shift ;;
-t) tag_suffix="$2"; shift 2 ;;
-p) platform_override="$2"; shift 2 ;;
--dry) dry_run=1; shift ;;
--arch) arch_suffix="$2"; shift 2 ;;
*) usage ;;
esac
done
@@ -78,7 +78,7 @@ if [[ -n $tag_suffix ]]; then
done
fi
echo "Tags: ${tags[@]}"
echo "Tags (before arch suffix): ${tags[@]}"
if [[ "$image_name" == "openhands" ]]; then
dir="./containers/app"
@@ -113,10 +113,21 @@ if [[ -n "$DOCKER_IMAGE_TAG" ]]; then
tags+=("$DOCKER_IMAGE_TAG")
fi
# Apply architecture suffix for split-arch builds (after all tags are collected)
if [[ -n "$arch_suffix" ]]; then
cache_tag+="-${arch_suffix}"
for i in "${!tags[@]}"; do
tags[$i]="${tags[$i]}-${arch_suffix}"
done
# Force single-platform build for this architecture
arch_platform="linux/${arch_suffix}"
fi
DOCKER_REPOSITORY="$DOCKER_REGISTRY/$DOCKER_ORG/$DOCKER_IMAGE"
DOCKER_REPOSITORY=${DOCKER_REPOSITORY,,} # lowercase
echo "Repo: $DOCKER_REPOSITORY"
echo "Base dir: $DOCKER_BASE_DIR"
echo "Tags: ${tags[@]}"
args=""
full_tags=()
@@ -125,7 +136,6 @@ for tag in "${tags[@]}"; do
full_tags+=("$DOCKER_REPOSITORY:$tag")
done
if [[ $push -eq 1 ]]; then
args+=" --push"
args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max"
@@ -138,8 +148,8 @@ fi
echo "Args: $args"
# Determine the platform(s) to build for
if [[ -n "$platform_override" ]]; then
platform="$platform_override"
if [[ -n "$arch_platform" ]]; then
platform="$arch_platform"
elif [[ $load -eq 1 ]]; then
# When loading, build only for the current platform
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
@@ -149,13 +159,24 @@ else
fi
if [[ $dry_run -eq 1 ]]; then
echo "Dry Run is enabled. Writing build config to docker-build-dry.json"
# Compute base tags (arch suffix stripped) for use by merge jobs
base_tags=()
for ftag in "${full_tags[@]}"; do
if [[ -n "$arch_suffix" ]]; then
base_tags+=("${ftag%-${arch_suffix}}")
else
base_tags+=("$ftag")
fi
done
jq -n \
--argjson tags "$(printf '%s\n' "${full_tags[@]}" | jq -R . | jq -s .)" \
--argjson base_tags "$(printf '%s\n' "${base_tags[@]}" | jq -R . | jq -s .)" \
--arg platform "$platform" \
--arg openhands_build_version "$OPENHANDS_BUILD_VERSION" \
--arg dockerfile "$dir/Dockerfile" \
'{
tags: $tags,
base_tags: $base_tags,
platform: $platform,
build_args: [
"OPENHANDS_BUILD_VERSION=" + $openhands_build_version
@@ -174,7 +195,7 @@ docker buildx build \
$args \
--build-arg OPENHANDS_BUILD_VERSION="$OPENHANDS_BUILD_VERSION" \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag_base-main \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:${cache_tag_base}-main${arch_suffix:+-${arch_suffix}} \
--platform $platform \
--provenance=false \
-f "$dir/Dockerfile" \

View File

@@ -58,6 +58,8 @@ repos:
types-Markdown,
pydantic,
lxml,
"openhands-sdk==1.17.0",
"openhands-tools==1.17.0",
]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/

View File

@@ -14,3 +14,11 @@ exclude = (third_party/|enterprise/)
[mypy-openhands.memory.condenser.impl.*]
disable_error_code = override
[mypy-openai.*]
follow_imports = skip
ignore_missing_imports = True
[mypy-litellm.*]
follow_imports = skip
ignore_missing_imports = True

View File

@@ -1,5 +1,7 @@
# PolyForm Free Trial License 1.0.0
Copyright (c) 2026 All Hands AI
## Acceptance
In order to get any license under these terms, you must agree

View File

@@ -59,7 +59,7 @@ handlers = console
qualname =
[logger_sqlalchemy]
level = DEBUG
level = WARNING
handlers =
qualname = sqlalchemy.engine

View File

@@ -106,16 +106,18 @@ async def summarize_issue_solvability(
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
)
if user_settings.llm_api_key is None:
agent_settings = user_settings.agent_settings
llm_settings = agent_settings.llm
if llm_settings.api_key is None:
raise ValueError(
f'[Solvability] No LLM API key found for user {github_view.user_info.user_id}'
)
try:
llm_config = LLMConfig(
model=user_settings.llm_model,
api_key=user_settings.llm_api_key.get_secret_value(),
base_url=user_settings.llm_base_url,
model=llm_settings.model,
api_key=llm_settings.api_key.get_secret_value(),
base_url=llm_settings.base_url,
)
except ValidationError as e:
raise ValueError(

View File

@@ -10,6 +10,7 @@ from integrations.github.github_types import (
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
@@ -26,6 +27,7 @@ from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.org_store import OrgStore
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -41,16 +43,14 @@ from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
@@ -154,12 +154,17 @@ class GithubIssue(ResolverViewInterface):
return user_secrets.custom_secrets if user_secrets else None
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
self.v1_enabled = await is_v1_enabled_for_github_resolver(
self.user_info.keycloak_user_id
)
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='github',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
@@ -173,16 +178,28 @@ class GithubIssue(ResolverViewInterface):
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._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
self.conversation_id = conversation_metadata.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=self.user_info.keycloak_user_id,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITHUB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -294,7 +311,10 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
github_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@@ -322,7 +342,7 @@ class GithubIssue(ResolverViewInterface):
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
},
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)
@@ -476,7 +496,7 @@ class GithubInlinePRComment(GithubPRComment):
'comment_id': self.comment_id,
},
inline_pr_comment=True,
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)

View File

@@ -3,6 +3,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_V1_GITLAB_RESOLVER,
@@ -14,6 +15,7 @@ from integrations.utils import (
from jinja2 import Environment
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -29,15 +31,13 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
CONFIDENTIAL_NOTE = 'confidential_note'
@@ -118,6 +118,14 @@ class GitlabIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# v1_enabled is already set at construction time in the factory method
# This is the source of truth for the conversation type
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='gitlab',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
if self.v1_enabled:
# Create dummy conversation metadata
# Don't save to conversation store
@@ -128,16 +136,28 @@ class GitlabIssue(ResolverViewInterface):
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._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITLAB,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
self.conversation_id = conversation_metadata.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=self.user_info.keycloak_user_id,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITLAB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -228,7 +248,10 @@ class GitlabIssue(ResolverViewInterface):
)
# Set up the GitLab user context for the V1 system
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
gitlab_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
async with get_app_conversation_service(
@@ -260,7 +283,7 @@ class GitlabIssue(ResolverViewInterface):
'is_mr': self.is_mr,
'discussion_id': getattr(self, 'discussion_id', None),
},
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)

View File

@@ -24,20 +24,20 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
from integrations.jira.jira_view import JiraFactory
from integrations.manager import Manager
from integrations.models import Message
from integrations.utils import (
HOST,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
format_jira_comment_body,
get_oh_labels,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
@@ -259,11 +259,6 @@ class JiraManager(Manager[JiraViewInterface]):
async def start_job(self, view: JiraViewInterface) -> None:
"""Start a Jira job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_callback_processor import (
JiraCallbackProcessor,
)
try:
logger.info(
'[Jira] Starting job',
@@ -285,19 +280,7 @@ class JiraManager(Manager[JiraViewInterface]):
},
)
# Register callback processor for updates
if isinstance(view, JiraNewConversationView):
processor = JiraCallbackProcessor(
issue_key=view.payload.issue_key,
workspace_name=view.jira_workspace.name,
)
register_callback_processor(conversation_id, processor)
logger.info(
'[Jira] Callback processor registered',
extra={'conversation_id': conversation_id},
)
# Send success response
# Create success message
msg_info = view.get_response_msg()
except MissingSettingsError as e:
@@ -359,7 +342,7 @@ class JiraManager(Manager[JiraViewInterface]):
url = (
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
data = {'body': message}
data = format_jira_comment_body(message)
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
url, auth=(svc_acc_email, svc_acc_api_key), json=data

View File

@@ -136,11 +136,10 @@ class JiraPayloadParser:
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
]
labels = set()
for item in items:
if item.get('field') == 'labels' and item.get('toString'):
labels.update(item['toString'].split())
if self.oh_label not in labels:
return JiraPayloadSkipped(

View File

@@ -0,0 +1,238 @@
import logging
from uuid import UUID
import httpx
from integrations.utils import format_jira_comment_body, get_summary_instruction
from pydantic import Field
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
from openhands.utils.http_session import httpx_verify_option
_logger = logging.getLogger(__name__)
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
class JiraV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Jira V1 integrations."""
should_request_summary: bool = Field(default=True)
svc_acc_email: str
decrypted_api_key: str
issue_key: str
jira_cloud_id: str
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Jira V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
return None
_logger.info('[Jira] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info('[Jira] Should request summary: %s', self.should_request_summary)
if not self.should_request_summary:
return None
self.should_request_summary = False
try:
_logger.info(f'[Jira] Requesting summary {conversation_id}')
summary = await self._request_summary(conversation_id)
_logger.info(
f'[Jira] Posting summary {conversation_id}',
extra={'summary': summary},
)
await self._post_summary_to_jira(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(f'[Jira] Failed to post summary: {e}', stack_info=True)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
async def _request_summary(self, conversation_id: UUID) -> str:
"""Ask the agent to produce a summary of its work and return the agent response."""
# 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,
)
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:
pass
_logger.exception(
'[Jira] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
stack_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.exception(
'[Jira] Timeout error: %s. Request payload: %s',
error_detail,
payload,
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
async def _post_summary_to_jira(self, summary: str):
"""Post the summary back to the Jira issue."""
if not all(
[
self.svc_acc_email,
self.decrypted_api_key,
self.issue_key,
self.jira_cloud_id,
]
):
_logger.warning('[Jira] Missing required data for posting summary')
return
# Add a comment to the Jira issue with the summary
comment_url = (
f'{JIRA_CLOUD_API_URL}/{self.jira_cloud_id}'
f'/rest/api/2/issue/{self.issue_key}/comment'
)
message = f'OpenHands resolved this issue:\n\n{summary}'
comment_body = format_jira_comment_body(message)
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
comment_url,
auth=(self.svc_acc_email, self.decrypted_api_key),
json=comment_body,
)
response.raise_for_status()
_logger.info(f'[Jira] Posted summary to {self.issue_key}')

View File

@@ -7,6 +7,7 @@ Views are responsible for:
"""
from dataclasses import dataclass, field
from uuid import UUID, uuid4
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
@@ -15,18 +16,37 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from integrations.jira.jira_v1_callback_processor import (
JiraV1CallbackProcessor,
)
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
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
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
)
from openhands.app_server.config import get_app_conversation_service
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.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import create_new_conversation
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
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 (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
@@ -46,7 +66,7 @@ class JiraNewConversationView(JiraViewInterface):
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None = None
selected_repo: str = ''
conversation_id: str = ''
# Lazy-loaded issue details (cached after first fetch)
@@ -56,6 +76,9 @@ class JiraNewConversationView(JiraViewInterface):
# Decrypted API key (set by factory)
_decrypted_api_key: str = field(default='', repr=False)
# Resolved org ID for V1 conversations
resolved_org_id: UUID | None = None
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch issue details from Jira API (cached after first call).
@@ -161,56 +184,131 @@ class JiraNewConversationView(JiraViewInterface):
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
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)
conversation_metadata = await self._create_v1_metadata()
await self._create_v1_conversation(jinja_env, conversation_metadata)
return self.conversation_id
async def _create_v1_metadata(self) -> ConversationMetadata:
"""Create conversation metadata for V1 conversations.
The JiraConversation mapping is saved to the integration store (above), but
V1 conversation metadata is managed by the app conversation system, not
the legacy conversation store.
"""
logger.info('[Jira]: Creating V1 metadata')
# Generate a dummy conversation for V1 (not saved to store)
self.conversation_id = uuid4().hex
self.resolved_org_id = await self._get_resolved_org_id()
return ConversationMetadata(
conversation_id=self.conversation_id,
selected_repository=self.selected_repo,
)
async def _create_v1_conversation(
self,
jinja_env: Environment,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the new V1 app conversation system."""
logger.info('[Jira]: Creating V1 conversation')
initial_user_text = await self._get_v1_initial_user_message(jinja_env)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=initial_user_text)]
)
# Create the Jira V1 callback processor
jira_callback_processor = self._create_jira_v1_callback_processor()
injector_state = InjectorState()
# Create the V1 conversation start request
start_request = AppConversationStartRequest(
conversation_id=UUID(conversation_metadata.conversation_id),
system_message_suffix=None,
initial_message=initial_message,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=ProviderType.GITHUB,
title=f'Jira Issue {self.payload.issue_key}: {self._issue_title or "Unknown"}',
trigger=ConversationTrigger.JIRA,
processors=[jira_callback_processor],
)
# Set up the Jira user context for the V1 system
jira_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, jira_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}'
)
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
"""Build the initial user message for V1 resolver conversations."""
issue_title, issue_description = await self.get_issue_details()
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.payload.issue_key,
issue_title=issue_title,
issue_description=issue_description,
user_message=self.payload.user_msg,
)
return user_msg
def _create_jira_v1_callback_processor(self):
"""Create a V1 callback processor for Jira integration."""
return JiraV1CallbackProcessor(
svc_acc_email=self.jira_workspace.svc_acc_email,
decrypted_api_key=self._decrypted_api_key,
issue_key=self.payload.issue_key,
jira_cloud_id=self.jira_workspace.jira_cloud_id,
)
async def _get_resolved_org_id(self) -> UUID | None:
"""Resolve the org ID for V1 conversations."""
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = await self._get_instructions(jinja_env)
if not provider_tokens:
return None
try:
agent_loop_info = await create_new_conversation(
user_id=self.jira_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.JIRA,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
resolved_org_id = await resolve_org_for_repo(
provider=repository.git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.jira_user.keycloak_user_id,
)
self.conversation_id = agent_loop_info.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.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
return resolved_org_id
except Exception as e:
if isinstance(e, StartingConvoException):
raise
logger.error(
'[Jira] Failed to create conversation',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
exc_info=True,
logger.warning(
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
return None
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira."""

View File

@@ -20,6 +20,7 @@ from integrations.utils import (
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_session_expired_message,
markdown_to_jira_markup,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
@@ -468,7 +469,8 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
"""
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
data = {'body': message}
# Convert standard Markdown to Jira Wiki Markup for proper rendering
data = {'body': markdown_to_jira_markup(message)}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()

View File

@@ -1,25 +1,34 @@
from dataclasses import dataclass
from uuid import uuid4
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from server.config import get_config
from storage.linear_conversation import LinearConversation
from storage.linear_integration_store import LinearIntegrationStore
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from storage.saas_conversation_store import SaasConversationStore
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.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
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 (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
integration_store = LinearIntegrationStore.get_instance()
@@ -61,20 +70,70 @@ class LinearNewConversationView(LinearViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
user_id=self.linear_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.LINEAR,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
user_id = self.linear_user.keycloak_user_id
# Resolve git provider from repository
resolved_git_provider = None
if provider_tokens:
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(
self.selected_repo
)
resolved_git_provider = repository.git_provider
except Exception as e:
logger.warning(
f'[Linear] Failed to resolve git provider for {self.selected_repo}: {e}'
)
# Resolve target org based on claimed git organizations
resolved_org_id = None
if resolved_git_provider and self.selected_repo:
try:
resolved_org_id = await resolve_org_for_repo(
provider=resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=user_id,
)
except Exception as e:
logger.warning(
f'[Linear] Failed to resolve org for {self.selected_repo}: {e}'
)
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
resolved_org_id,
)
self.conversation_id = agent_loop_info.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.LINEAR,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_msg,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
)
self.conversation_id = conversation_id
logger.info(f'[Linear] Created conversation {self.conversation_id}')

View File

@@ -1,7 +1,9 @@
from uuid import UUID
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, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.integrations.service_types import ProviderType, UserGitInfo
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -12,8 +14,10 @@ class ResolverUserContext(UserContext):
def __init__(
self,
saas_user_auth: UserAuth,
resolver_org_id: UUID | None = None,
):
self.saas_user_auth = saas_user_auth
self.resolver_org_id = resolver_org_id
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
@@ -81,3 +85,6 @@ class ResolverUserContext(UserContext):
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()
async def get_user_git_info(self) -> UserGitInfo | None:
return await self.saas_user_auth.get_user_git_info()

View File

@@ -0,0 +1,68 @@
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
This module provides a reusable utility for routing resolver conversations
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
workspace based on claimed Git organizations.
"""
from uuid import UUID
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
async def resolve_org_for_repo(
provider: str,
full_repo_name: str,
keycloak_user_id: str,
) -> UUID | None:
"""Determine the OpenHands org_id for a resolver conversation.
If the repo's git organization is claimed by an OpenHands org AND the user
is a member of that org, returns the claiming org's ID. Otherwise returns
None (caller should fall back to user.current_org_id / personal workspace).
Args:
provider: Git provider name ("github", "gitlab", "bitbucket")
full_repo_name: Full repository name (e.g., "OpenHands/foo")
keycloak_user_id: The user's Keycloak UUID string
Returns:
The org_id if the repo's org is claimed and user is a member, else None
"""
git_org = full_repo_name.split('/')[0].lower()
try:
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider, git_org
)
if not claim:
logger.debug(
f'[OrgResolver] No claim found for {provider}/{git_org}',
)
return None
member = await OrgMemberStore.get_org_member(
claim.org_id, UUID(keycloak_user_id)
)
if not member:
logger.debug(
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
f'{claim.org_id} (claimed {provider}/{git_org}). '
f'Falling back to personal workspace.',
)
return None
logger.info(
f'[OrgResolver] Routing conversation to org {claim.org_id} '
f'for {provider}/{git_org} (user {keycloak_user_id})',
)
return claim.org_id
except Exception as e:
logger.error(
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
exc_info=True,
)
return None

View File

@@ -239,12 +239,14 @@ class SlackManager(Manager[SlackViewInterface]):
def _generate_repo_selection_form(
self, message_ts: str, thread_ts: str | None
) -> list[dict[str, Any]]:
"""Generate a repo selection form using external_select for dynamic loading.
"""Generate a repo selection form with immediate "No Repository" button and search dropdown.
This uses Slack's external_select element which allows:
- Type-ahead search for repositories
- Dynamic loading of options from an external endpoint
- Support for users with many repositories (no 100 option limit)
This form provides two options side-by-side:
1. A "No Repository" button - immediately clickable without any loading
2. An external_select dropdown - for searching repositories dynamically
This design ensures "No Repository" is always immediately available while
still providing full dynamic search capability for repositories.
Args:
message_ts: The message timestamp for tracking
@@ -266,12 +268,22 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': 'Type to search your repositories:',
'text': 'Select a repository or continue without one:',
},
},
{
'type': 'actions',
'elements': [
{
'type': 'button',
'action_id': f'no_repository:{message_ts}:{thread_ts}',
'text': {
'type': 'plain_text',
'text': 'No Repository',
'emoji': True,
},
'value': '-',
},
{
'type': 'external_select',
'action_id': f'repository_select:{message_ts}:{thread_ts}',
@@ -279,8 +291,8 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'plain_text',
'text': 'Search repositories...',
},
'min_query_length': 0, # Load initial options immediately
}
'min_query_length': 0,
},
],
},
]
@@ -288,8 +300,11 @@ class SlackManager(Manager[SlackViewInterface]):
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
"""Build Slack options list from repositories.
Always includes a "No Repository" option at the top, followed by up to 99
repositories (Slack has a 100 option limit for external_select).
Returns up to 100 repositories formatted as Slack options
(Slack has a 100 option limit for external_select).
Note: "No Repository" is handled by a separate button in the form,
so it's not included in the dropdown options.
Args:
repos: List of Repository objects
@@ -297,13 +312,7 @@ class SlackManager(Manager[SlackViewInterface]):
Returns:
List of Slack option objects
"""
options: list[dict[str, Any]] = [
{
'text': {'type': 'plain_text', 'text': 'No Repository'},
'value': '-',
}
]
options.extend(
return [
{
'text': {
'type': 'plain_text',
@@ -311,9 +320,8 @@ class SlackManager(Manager[SlackViewInterface]):
},
'value': repo.full_name,
}
for repo in repos[:99] # Leave room for "No Repository" option
)
return options
for repo in repos[:100]
]
async def search_repos_for_slack(
self, user_auth: UserAuth, query: str, per_page: int = 20
@@ -363,33 +371,69 @@ class SlackManager(Manager[SlackViewInterface]):
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
)
async def receive_form_interaction(self, slack_payload: dict):
"""Process a Slack form interaction (repository selection).
def _parse_form_action(self, action: dict) -> tuple[str, str | None, str] | None:
"""Parse action payload and extract message_ts, thread_ts, and selected value.
This handles the block_actions payload when a user selects a repository
from the dropdown form. It retrieves the original user message from Redis
and delegates to receive_message for processing.
This handles the different payload structures for button clicks vs dropdown
selections in the repository selection form.
Args:
action: The action object from the Slack payload
Returns:
Tuple of (message_ts, thread_ts, selected_value) if action is recognized,
None if the action_id is unknown.
"""
action_id = action['action_id']
if action_id.startswith('no_repository:'):
# Button click - value is in 'value' field
attribs = action_id.split('no_repository:')[-1]
selected_value = action.get('value', '-')
elif action_id.startswith('repository_select:'):
# Dropdown selection - value is in 'selected_option'
attribs = action_id.split('repository_select:')[-1]
selected_value = action['selected_option']['value']
else:
return None
message_ts, thread_ts = attribs.split(':')
thread_ts = None if thread_ts == 'None' else thread_ts
return message_ts, thread_ts, selected_value
async def receive_form_interaction(self, slack_payload: dict):
"""Process a Slack form interaction (repository selection or button click).
This handles the block_actions payload when a user interacts with the
repository selection form. It can handle:
- "No Repository" button click: proceeds with conversation without a repo
- Repository selection from dropdown: proceeds with the selected repo
Args:
slack_payload: The raw Slack interaction payload
"""
# Extract fields from the Slack interaction payload
selected_repository = slack_payload['actions'][0]['selected_option']['value']
if selected_repository == '-':
selected_repository = None
action = slack_payload['actions'][0]
slack_user_id = slack_payload['user']['id']
channel_id = slack_payload['container']['channel_id']
team_id = slack_payload['team']['id']
# Get original message_ts and thread_ts from action_id
attribs = slack_payload['actions'][0]['action_id'].split('repository_select:')[
-1
]
message_ts, thread_ts = attribs.split(':')
thread_ts = None if thread_ts == 'None' else thread_ts
# Parse the action to extract message_ts, thread_ts, and selected value
parsed = self._parse_form_action(action)
if parsed is None:
logger.warning(
'slack_unknown_action_id',
extra={
'action_id': action['action_id'],
'slack_user_id': slack_user_id,
},
)
return
# Build partial payload for error handling during Redis retrieval
message_ts, thread_ts, selected_value = parsed
# Build partial payload for error handling
payload = {
'team_id': team_id,
'channel_id': channel_id,
@@ -398,6 +442,9 @@ class SlackManager(Manager[SlackViewInterface]):
'thread_ts': thread_ts,
}
# Convert "-" (No Repository) to None
selected_repository = None if selected_value == '-' else selected_value
# Retrieve the original user message from Redis
try:
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)

View File

@@ -111,9 +111,11 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
try:
# Post the summary as a threaded reply
# Use markdown_text instead of text to properly render standard Markdown
# (e.g., **bold**, [link](url)) which is used throughout the codebase
response = client.chat_postMessage(
channel=channel_id,
text=summary,
markdown_text=summary,
thread_ts=thread_ts,
unfurl_links=False,
unfurl_media=False,

View File

@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.slack.slack_types import (
SlackMessageView,
SlackViewInterface,
@@ -17,7 +18,9 @@ from integrations.utils import (
get_user_v1_enabled_setting,
)
from jinja2 import Environment
from server.config import get_config
from slack_sdk import WebClient
from storage.saas_conversation_store import SaasConversationStore
from storage.slack_conversation import SlackConversation
from storage.slack_conversation_store import SlackConversationStore
from storage.slack_team_store import SlackTeamStore
@@ -36,18 +39,20 @@ 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, ProviderType
from openhands.integrations.provider import ProviderHandler
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
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 (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import GENERAL_TIMEOUT
from openhands.utils.conversation_summary import get_default_conversation_title
# =================================================
# SECTION: Slack view types
@@ -202,6 +207,22 @@ class SlackNewConversationView(SlackViewInterface):
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Determine git provider from repository (needed for both org routing and conversation creation)
self._resolved_git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
self._resolved_git_provider = repository.git_provider
# Resolve target org based on claimed git organizations
self.resolved_org_id = None
if self._resolved_git_provider and self.selected_repo:
self.resolved_org_id = await resolve_org_for_repo(
provider=self._resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
)
# 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
@@ -224,30 +245,44 @@ class SlackNewConversationView(SlackViewInterface):
jinja
)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
user_id = self.slack_to_openhands_user.keycloak_user_id
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
self.resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.SLACK,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=self._resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = agent_loop_info.conversation_id
self.conversation_id = conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
@@ -265,13 +300,8 @@ class SlackNewConversationView(SlackViewInterface):
# 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)
# Use git provider resolved in create_or_update_conversation
git_provider = self._resolved_git_provider
# Get the app conversation service and start the conversation
injector_state = InjectorState()
@@ -292,7 +322,10 @@ class SlackNewConversationView(SlackViewInterface):
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
slack_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(

View File

@@ -436,12 +436,13 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
)
matches: list[str] = []
# Use dict to preserve ordering
matches: dict[str, bool] = {}
# 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}')
matches[f'{owner}/{repo}'] = True
# Direct mentions
for owner, repo in re.findall(direct_pattern, normalized_msg):
@@ -457,9 +458,10 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
continue
if full_match not in matches:
matches.append(full_match)
matches[full_match] = True
return matches
result = list(matches)
return result
def filter_potential_repos_by_user_msg(
@@ -595,3 +597,18 @@ def markdown_to_jira_markup(markdown_text: str) -> str:
# Log the error but don't raise it - return original text as fallback
print(f'Error converting markdown to Jira markup: {str(e)}')
return markdown_text or ''
def format_jira_comment_body(message: str) -> dict:
"""Format a message as a Jira API v2 comment body.
This helper ensures consistent comment formatting across all Jira integrations.
Converts markdown to Jira Wiki Markup and wraps in the expected API structure.
Args:
message: The message content to send (may contain markdown)
Returns:
dict: The comment body in Jira API v2 format {'body': ...}
"""
return {'body': markdown_to_jira_markup(message)}

View File

@@ -6,6 +6,12 @@ from logging.config import fileConfig
# These plugin setup messages would otherwise appear before logging is configured
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
# Prevent SQLAlchemy engine from logging SQL results at DEBUG level, which can
# leak sensitive column data (e.g. API keys, tokens) into log aggregators.
# This is set before any engine is created so it takes effect immediately.
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine, text # noqa: E402
@@ -70,6 +76,12 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Re-apply SQLAlchemy engine log suppression after fileConfig, which may override
# our earlier settings from alembic.ini. This ensures DEBUG-level SQL result logging
# is always suppressed, preventing sensitive data from leaking into log aggregators.
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.

View File

@@ -6,7 +6,6 @@ Create Date: 2026-03-26
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
@@ -24,18 +23,18 @@ def upgrade() -> None:
# Migrate existing org-level MCP configs to all members in each org.
# This preserves existing configurations while transitioning to user-specific settings.
conn = op.get_bind()
orgs_with_config = conn.execute(
sa.text('SELECT id, mcp_config FROM org WHERE mcp_config IS NOT NULL')
).fetchall()
for org_id, mcp_config in orgs_with_config:
conn.execute(
sa.text(
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
),
{'config': json.dumps(mcp_config), 'org_id': str(org_id)},
# Uses server-side SQL to avoid pulling sensitive config data into the Python process.
op.execute(
sa.text(
"""
UPDATE org_member
SET mcp_config = org.mcp_config
FROM org
WHERE org_member.org_id = org.id
AND org.mcp_config IS NOT NULL
"""
)
)
def downgrade() -> None:

View File

@@ -0,0 +1,31 @@
"""Add onboarding_completed column to user table.
Tracks whether a user has completed the onboarding flow.
Used to redirect new SaaS users to /onboarding after accepting TOS.
Revision ID: 107
Revises: 106
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '107'
down_revision: Union[str, None] = '106'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'user',
sa.Column('onboarding_completed', sa.Boolean(), nullable=True, default=False),
)
def downgrade() -> None:
op.drop_column('user', 'onboarding_completed')

View File

@@ -0,0 +1,300 @@
"""Add agent_settings columns to enterprise settings tables.
Revision ID: 108
Revises: 107
Create Date: 2026-03-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '108'
down_revision: Union[str, None] = '107'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_EMPTY_JSON = sa.text("'{}'::json")
def upgrade() -> None:
op.add_column(
'user_settings',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'user_settings',
sa.Column(
'conversation_settings',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org_member',
sa.Column(
'agent_settings_diff',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org_member',
sa.Column(
'conversation_settings_diff',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'org',
sa.Column(
'conversation_settings',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column('org', sa.Column('_llm_api_key', sa.String(), nullable=True))
op.add_column(
'org_member',
sa.Column(
'has_custom_llm_api_key',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.execute(
sa.text(
"""
UPDATE user_settings
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'agent', agent,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'verification.confirmation_mode', confirmation_mode,
'verification.security_analyzer', security_analyzer,
'condenser.enabled', enable_default_condenser,
'condenser.max_size', condenser_max_size,
'max_iterations', max_iterations
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.execute(
sa.text(
"""
UPDATE org_member
SET agent_settings_diff = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'max_iterations', max_iterations,
'mcp_config', mcp_config
) || COALESCE(agent_settings_diff::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.execute(
sa.text(
"""
UPDATE org
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'agent', agent,
'llm.model', default_llm_model,
'llm.base_url', default_llm_base_url,
'verification.confirmation_mode', confirmation_mode,
'verification.security_analyzer', security_analyzer,
'condenser.enabled', enable_default_condenser,
'condenser.max_size', condenser_max_size,
'max_iterations', default_max_iterations,
'mcp_config', mcp_config
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.alter_column('user_settings', 'agent_settings', server_default=None)
op.alter_column('user_settings', 'conversation_settings', server_default=None)
op.alter_column('org_member', 'agent_settings_diff', server_default=None)
op.alter_column('org_member', 'conversation_settings_diff', server_default=None)
op.alter_column('org', 'agent_settings', server_default=None)
op.alter_column('org', 'conversation_settings', server_default=None)
op.alter_column('org_member', 'has_custom_llm_api_key', server_default=None)
op.drop_column('user_settings', 'agent')
op.drop_column('user_settings', 'max_iterations')
op.drop_column('user_settings', 'security_analyzer')
op.drop_column('user_settings', 'confirmation_mode')
op.drop_column('user_settings', 'llm_model')
op.drop_column('user_settings', 'llm_base_url')
op.drop_column('user_settings', 'enable_default_condenser')
op.drop_column('user_settings', 'condenser_max_size')
op.drop_column('org_member', 'max_iterations')
op.drop_column('org_member', 'llm_model')
op.drop_column('org_member', 'llm_base_url')
op.drop_column('org_member', 'mcp_config')
op.drop_column('org', 'agent')
op.drop_column('org', 'default_max_iterations')
op.drop_column('org', 'security_analyzer')
op.drop_column('org', 'confirmation_mode')
op.drop_column('org', 'default_llm_model')
op.drop_column('org', 'default_llm_base_url')
op.drop_column('org', 'enable_default_condenser')
op.drop_column('org', 'mcp_config')
op.drop_column('org', 'condenser_max_size')
def downgrade() -> None:
op.add_column('user_settings', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('security_analyzer', sa.String(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('confirmation_mode', sa.Boolean(), nullable=True)
)
op.add_column('user_settings', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('llm_base_url', sa.String(), nullable=True)
)
op.add_column(
'user_settings',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column(
'user_settings', sa.Column('condenser_max_size', sa.Integer(), nullable=True)
)
op.add_column('org_member', sa.Column('llm_base_url', sa.String(), nullable=True))
op.add_column('org_member', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'org_member', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'org', sa.Column('default_max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org', sa.Column('security_analyzer', sa.String(), nullable=True))
op.add_column('org', sa.Column('confirmation_mode', sa.Boolean(), nullable=True))
op.add_column('org', sa.Column('default_llm_model', sa.String(), nullable=True))
op.add_column('org', sa.Column('default_llm_base_url', sa.String(), nullable=True))
op.add_column(
'org',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
op.execute(
sa.text(
"""
UPDATE user_settings
SET
agent = agent_settings ->> 'agent',
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer,
security_analyzer =
agent_settings ->> 'verification.security_analyzer',
confirmation_mode = CASE
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
ELSE NULL
END,
llm_model = agent_settings ->> 'llm.model',
llm_base_url = agent_settings ->> 'llm.base_url',
enable_default_condenser = CASE
WHEN agent_settings::jsonb ? 'condenser.enabled'
THEN (agent_settings ->> 'condenser.enabled')::boolean
ELSE TRUE
END,
condenser_max_size =
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
"""
)
)
op.execute(
sa.text(
"""
UPDATE org_member
SET
llm_model = agent_settings_diff ->> 'llm.model',
llm_base_url = agent_settings_diff ->> 'llm.base_url',
max_iterations =
NULLIF(agent_settings_diff ->> 'max_iterations', '')::integer,
mcp_config = agent_settings_diff -> 'mcp_config'
"""
)
)
op.execute(
sa.text(
"""
UPDATE org
SET
agent = agent_settings ->> 'agent',
default_max_iterations =
NULLIF(agent_settings ->> 'max_iterations', '')::integer,
security_analyzer =
agent_settings ->> 'verification.security_analyzer',
confirmation_mode = CASE
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
ELSE NULL
END,
default_llm_model = agent_settings ->> 'llm.model',
default_llm_base_url = agent_settings ->> 'llm.base_url',
enable_default_condenser = CASE
WHEN agent_settings::jsonb ? 'condenser.enabled'
THEN (agent_settings ->> 'condenser.enabled')::boolean
ELSE TRUE
END,
mcp_config = agent_settings -> 'mcp_config',
condenser_max_size =
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
"""
)
)
op.drop_column('org', 'agent_settings')
op.drop_column('org', 'conversation_settings')
op.drop_column('org', '_llm_api_key')
op.drop_column('org_member', 'agent_settings_diff')
op.drop_column('org_member', 'conversation_settings_diff')
op.drop_column('org_member', 'has_custom_llm_api_key')
op.drop_column('user_settings', 'agent_settings')
op.drop_column('user_settings', 'conversation_settings')

87
enterprise/poetry.lock generated
View File

@@ -549,7 +549,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"},
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"},
@@ -1944,8 +1944,8 @@ files = [
[package.dependencies]
bytecode = [
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
{version = ">=0.15.1", markers = "python_version ~= \"3.12.0\""},
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
]
envier = ">=0.6.1,<0.7.0"
legacy-cgi = {version = ">=2.0.0", markers = "python_version >= \"3.13.0\""}
@@ -2994,8 +2994,8 @@ googleapis-common-protos = ">=1.63.2,<2.0.0"
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=4.25.8,<7.0.0"
requests = ">=2.20.0,<3.0.0"
@@ -3106,8 +3106,8 @@ google-auth = ">=2.47.0,<3.0.0"
google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0"
google-cloud-resource-manager = ">=1.3.3,<3.0.0"
google-cloud-storage = [
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.32.0,<4.0.0", markers = "python_version < \"3.13\""},
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
]
google-genai = {version = ">=1.59.0,<2.0.0", markers = "python_version >= \"3.10\""}
packaging = ">=14.3"
@@ -3214,8 +3214,8 @@ google-api-core = {version = ">=2.11.0,<3.0.0", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpcio = ">=1.33.2,<2.0.0"
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=4.25.8,<8.0.0"
@@ -3237,8 +3237,8 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
grpcio = ">=1.33.2,<2.0.0"
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=4.25.8,<8.0.0"
@@ -4795,7 +4795,7 @@ description = "Fork of the standard library cgi and cgitb modules removed in Pyt
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd"},
{file = "legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577"},
@@ -4890,25 +4890,24 @@ valkey = ["valkey (>=6)"]
[[package]]
name = "litellm"
version = "1.80.10"
version = "1.83.0"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "litellm-1.80.10-py3-none-any.whl", hash = "sha256:9b3e561efaba0eb1291cb1555d3dcb7283cf7f3cb65aadbcdb42e2a8765898c8"},
{file = "litellm-1.80.10.tar.gz", hash = "sha256:4a4aff7558945c2f7e5c6523e67c1b5525a46b10b0e1ad6b8f847cb13b16779e"},
{file = "litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8"},
{file = "litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a"},
]
[package.dependencies]
aiohttp = ">=3.10"
click = "*"
fastuuid = ">=0.13.0"
grpcio = {version = ">=1.62.3,<1.68.0", markers = "python_version < \"3.14\""}
httpx = ">=0.23.0"
importlib-metadata = ">=6.8.0"
jinja2 = ">=3.1.2,<4.0.0"
jsonschema = ">=4.22.0,<5.0.0"
jsonschema = ">=4.23.0,<5.0.0"
openai = ">=2.8.0"
pydantic = ">=2.5.0,<3.0.0"
python-dotenv = ">=0.2.0"
@@ -4917,9 +4916,11 @@ tokenizers = "*"
[package.extras]
caching = ["diskcache (>=5.6.1,<6.0.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
extra-proxy = ["a2a-sdk (>=0.3.22,<0.4.0) ; python_version >= \"3.10\"", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (>=0.11.0,<0.12.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
google = ["google-cloud-aiplatform (>=1.38.0)"]
grpc = ["grpcio (>=1.62.3,<1.68.dev0 || >1.71.0,!=1.71.1,!=1.72.0,!=1.72.1,!=1.73.0) ; python_version < \"3.14\"", "grpcio (>=1.75.0) ; python_version >= \"3.14\""]
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.25)", "litellm-proxy-extras (==0.4.14)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
proxy = ["PyJWT (>=2.12.0,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (>=1.40.76,<2.0.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.35)", "litellm-proxy-extras (>=0.4.62,<0.5.0)", "mcp (>=1.25.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "pyroscope-io (>=0.8,<0.9) ; sys_platform != \"win32\"", "python-multipart (>=0.0.20)", "pyyaml (>=6.0.1,<7.0.0)", "rich (>=13.7.1,<14.0.0)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.32.1,<1.0.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
utils = ["numpydoc"]
@@ -6453,14 +6454,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.16.1"
version = "1.17.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.16.1-py3-none-any.whl", hash = "sha256:015983b300510c9c329c8eace49fbd4117d31d0895a125e419c31a9964be4155"},
{file = "openhands_agent_server-1.16.1.tar.gz", hash = "sha256:489151d35250a424dede8646396bef7b7095adb25e5c973ca8bc6dcbd19cdf07"},
{file = "openhands_agent_server-1.17.0-py3-none-any.whl", hash = "sha256:44336cad001c31caeb516481a5a7aea6dd9b5ab4798461f147b5231668d8fb74"},
{file = "openhands_agent_server-1.17.0.tar.gz", hash = "sha256:3a88449a3b9ded653dcd2a8c518810c75602873cf9f7d4e8f9b90fd8fd225652"},
]
[package.dependencies]
@@ -6486,7 +6487,7 @@ files = []
develop = true
[package.dependencies]
aiohttp = ">=3.13.3"
aiohttp = ">=3.13.5"
anthropic = {version = "*", extras = ["vertex"]}
anyio = "4.9"
asyncpg = ">=0.30"
@@ -6499,7 +6500,7 @@ deprecation = ">=2.1"
dirhash = "*"
docker = "*"
fastapi = "*"
fastmcp = ">=3,<4"
fastmcp = ">=3.2,<4"
google-api-python-client = ">=2.164"
google-auth-httplib2 = "*"
google-auth-oauthlib = "*"
@@ -6522,9 +6523,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.3"
openhands-agent-server = "1.16.1"
openhands-sdk = "1.16.1"
openhands-tools = "1.16.1"
openhands-agent-server = "1.17"
openhands-sdk = "1.17"
openhands-tools = "1.17"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
orjson = ">=3.11.6"
@@ -6533,7 +6534,7 @@ pexpect = "*"
pg8000 = ">=1.31.5"
pillow = ">=12.1.1"
playwright = ">=1.55"
poetry = ">=2.1.2"
poetry = ">=2.3.3"
prompt-toolkit = ">=3.0.50"
protobuf = ">=5.29.6,<6"
psutil = "*"
@@ -6554,7 +6555,7 @@ pyyaml = ">=6.0.2"
qtconsole = ">=5.6.1"
rapidfuzz = ">=3.9"
redis = ">=5.2,<7"
requests = ">=2.32.5"
requests = ">=2.33"
setuptools = ">=78.1.1"
shellingham = ">=1.5.4"
sqlalchemy = {version = ">=2.0.40", extras = ["asyncio"]}
@@ -6579,14 +6580,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.16.1"
version = "1.17.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.16.1-py3-none-any.whl", hash = "sha256:0b487929e03e8c87ac6d99f37ff5314df3db6af70a06b516b0858327f9744f2b"},
{file = "openhands_sdk-1.16.1.tar.gz", hash = "sha256:12f203c3766800bdf5d9dd4dd0a7988b88e13ff4954b0c208903778111e29567"},
{file = "openhands_sdk-1.17.0-py3-none-any.whl", hash = "sha256:3b771e72209453871c3036a562cf33e9ad9642a54bd48edb44f89915ac54709d"},
{file = "openhands_sdk-1.17.0.tar.gz", hash = "sha256:3c69df6590f023a514137272d413658848e0d5bc9aecf941b946c8662862779a"},
]
[package.dependencies]
@@ -6596,7 +6597,7 @@ fakeredis = {version = ">=2.32.1", extras = ["lua"]}
fastmcp = ">=3.0.0"
filelock = ">=3.20.1"
httpx = {version = ">=0.27.0", extras = ["socks"]}
litellm = "1.80.10"
litellm = ">=1.82.6,<1.82.7 || >1.82.7,<1.82.8 || >1.82.8"
lmnr = ">=0.7.24"
pydantic = ">=2.12.5"
python-frontmatter = ">=1.1.0"
@@ -6609,14 +6610,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.16.1"
version = "1.17.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.16.1-py3-none-any.whl", hash = "sha256:f7fd1eb205571d02ee480ad71e96cac0c34c57c0938c4074fe135a579a7538d7"},
{file = "openhands_tools-1.16.1.tar.gz", hash = "sha256:64488f2d7705ff90f4bfb7dfd1a2f1fbb4f379059d96e0073677c168d97135e7"},
{file = "openhands_tools-1.17.0-py3-none-any.whl", hash = "sha256:76cd30fcc153627444f18638bcd926c9190989f80a3492381e84a181c021d815"},
{file = "openhands_tools-1.17.0.tar.gz", hash = "sha256:4a9d6c1aec00d366d0feb1ac2e9ee9988ad9806a0ef89f7dbe4655644e639d4a"},
]
[package.dependencies]
@@ -6691,8 +6692,8 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.57,<2.0"
grpcio = [
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
]
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.39.1"
@@ -7401,14 +7402,14 @@ tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "p
[[package]]
name = "posthog"
version = "6.9.3"
version = "7.9.12"
description = "Integrate PostHog into any python application."
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "posthog-6.9.3-py3-none-any.whl", hash = "sha256:c71e9cb7ac4ef13eb604f04c3161edd10b1d08a32499edd54437ba5eab591c58"},
{file = "posthog-6.9.3.tar.gz", hash = "sha256:7d201774ea9eba156f1de46d34313e30b2384d523900fe8e425accc92486cc34"},
{file = "posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0"},
{file = "posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec"},
]
[package.dependencies]
@@ -7422,7 +7423,7 @@ typing-extensions = ">=4.2.0"
[package.extras]
dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"]
langchain = ["langchain (>=0.2.0)"]
test = ["anthropic", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=0.3.15)", "langchain-community (>=0.3.25)", "langchain-core (>=0.3.65)", "langchain-openai (>=0.3.22)", "langgraph (>=0.4.8)", "mock (>=2.0.0)", "openai", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
test = ["anthropic (>=0.72)", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=1.0)", "langchain-community (>=0.4)", "langchain-core (>=1.0)", "langchain-openai (>=1.0)", "langgraph (>=1.0)", "mock (>=2.0.0)", "openai (>=2.0)", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
[[package]]
name = "pre-commit"
@@ -12130,14 +12131,14 @@ requests-toolbelt = ">=0.6.0"
[[package]]
name = "python-multipart"
version = "0.0.22"
version = "0.0.26"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"},
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
{file = "python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185"},
{file = "python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17"},
]
[[package]]
@@ -13730,7 +13731,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
@@ -13747,7 +13748,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
@@ -15263,4 +15264,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "c468b13e2d26e31e0e8f84518bcb8379234d431ca3819625f49b91aa3589359c"
content-hash = "55a09a40217bbbc876e5864b78c941d86a261e4111bce7e4495c1dd75df43fd7"

View File

@@ -36,7 +36,7 @@ resend = "^2.7.0"
tenacity = "^9.1.2"
slack-sdk = "^3.35.0"
ddtrace = "3.13.0" #pin to avoid yanked version 3.12.4
posthog = "^6.0.0"
posthog = "^7.0.0"
limits = "^5.2.0"
coredis = "^4.22.0"
httpx = "*"

View File

@@ -12,6 +12,9 @@ import socketio # noqa: E402
from fastapi import Request, status # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastapi.responses import JSONResponse # noqa: E402
from server.app_lifespan.saas_app_lifespan_service import ( # noqa: E402
SaasAppLifespanService,
)
from server.auth.auth_error import ExpiredError, NoCredentialsError # noqa: E402
from server.auth.constants import ( # noqa: E402
BITBUCKET_DATA_CENTER_HOST,
@@ -23,7 +26,10 @@ 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.middleware import SetAuthCookieMiddleware # noqa: E402
from server.middleware import ( # noqa: E402
PostHogSessionMiddleware,
SetAuthCookieMiddleware,
)
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
from server.routes.auth import api_router, oauth_router # noqa: E402
@@ -38,6 +44,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.onboarding import onboarding_router # noqa: E402
from server.routes.org_invitations import ( # noqa: E402
accept_router as invitation_accept_router,
)
@@ -49,6 +56,9 @@ from server.routes.readiness import readiness_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.routes.users_v1 import ( # noqa: E402
override_users_me_endpoint,
)
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
@@ -62,6 +72,14 @@ from server.verified_models.verified_model_router import ( # noqa: E402
override_llm_models_dependency,
)
# Patch global config with SaaS lifespan BEFORE openhands.server.app is imported.
# app.py reads get_app_lifespan_service() at module level (line ~69), so this
# must execute first.
from openhands.app_server.config import get_global_config # noqa: E402
_config = get_global_config()
_config.lifespan = SaasAppLifespanService()
from openhands.server.app import app as base_app # noqa: E402
from openhands.server.listen_socket import sio # noqa: E402
from openhands.server.middleware import ( # noqa: E402
@@ -123,6 +141,10 @@ base_app.include_router(
# This must happen after all routers are included
override_llm_models_dependency(base_app)
# Override the /api/v1/users/me endpoint to include organization info
# This replaces the OSS endpoint with a SAAS version that adds org_id, org_name, role, permissions
override_users_me_endpoint(base_app)
base_app.include_router(invitation_router) # Add routes for org invitation management
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
add_github_proxy_routes(base_app)
@@ -141,6 +163,7 @@ if BITBUCKET_DATA_CENTER_HOST:
base_app.include_router(bitbucket_dc_proxy_router)
base_app.include_router(email_router) # Add routes for email management
base_app.include_router(feedback_router) # Add routes for conversation feedback
base_app.include_router(onboarding_router) # Add route for onboarding submission
base_app.include_router(
event_webhook_router
) # Add routes for Events in nested runtimes
@@ -154,6 +177,7 @@ base_app.add_middleware(
allow_headers=['*'],
)
base_app.add_middleware(CacheControlMiddleware)
base_app.middleware('http')(PostHogSessionMiddleware())
base_app.middleware('http')(SetAuthCookieMiddleware())
base_app.mount('/', SPAStaticFiles(directory=directory, html=True), name='dist')

View File

@@ -0,0 +1,46 @@
"""SaaS-specific application lifespan service.
Initializes PostHog analytics on startup and flushes buffered events on
clean shutdown so no events are lost when the server exits gracefully.
"""
from __future__ import annotations
import os
from server.constants import IS_FEATURE_ENV
from openhands.analytics import get_analytics_service, init_analytics_service
from openhands.app_server.app_lifespan.app_lifespan_service import AppLifespanService
from openhands.core.logger import openhands_logger as logger
from openhands.server.types import AppMode
class SaasAppLifespanService(AppLifespanService):
"""Lifespan service for the SaaS server.
On enter: initialises the PostHog analytics singleton from environment vars.
On exit: calls ``analytics_service.shutdown()`` to flush any buffered events.
"""
async def __aenter__(self):
api_key = os.environ.get('POSTHOG_CLIENT_KEY', '')
host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', '')
app_mode = AppMode.SAAS if 'saas' in config_cls.lower() else AppMode.OPENHANDS
init_analytics_service(
api_key=api_key,
host=host,
app_mode=app_mode,
is_feature_env=IS_FEATURE_ENV,
)
return self
async def __aexit__(self, exc_type, exc_value, traceback):
try:
svc = get_analytics_service()
if svc is not None:
svc.shutdown()
except Exception:
logger.exception('Error shutting down analytics service')

View File

@@ -87,6 +87,9 @@ class Permission(str, Enum):
# Git organization claims
MANAGE_ORG_CLAIMS = 'manage_org_claims'
# Manage Automations
MANAGE_AUTOMATIONS = 'manage_automations'
class RoleName(str, Enum):
"""Role names used in the system."""
@@ -123,6 +126,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
Permission.DELETE_ORGANIZATION,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
RoleName.ADMIN: frozenset(
@@ -146,6 +151,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
Permission.EDIT_ORG_SETTINGS,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
RoleName.MEMBER: frozenset(
@@ -159,6 +166,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Settings (View only)
Permission.VIEW_ORG_SETTINGS,
Permission.VIEW_LLM_SETTINGS,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
}

View File

@@ -14,6 +14,10 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.authorization import (
get_role_permissions,
get_user_org_role,
)
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
from server.auth.token_manager import TokenManager
from server.config import get_config
@@ -23,10 +27,12 @@ from sqlalchemy import delete, select
from storage.api_key_store import ApiKeyStore
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
from storage.org_store import OrgStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from storage.user_authorization import UserAuthorizationType
from storage.user_authorization_store import UserAuthorizationStore
from storage.user_store import UserStore
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
from openhands.integrations.provider import (
@@ -64,6 +70,12 @@ class SaasUserAuth(UserAuth):
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
# Organization context fields - populated lazily via get_org_info()
_org_id: str | None = None
_org_name: str | None = None
_role: str | None = None
_permissions: list[str] | None = None
_org_info_loaded: bool = False
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
@@ -242,6 +254,72 @@ class SaasUserAuth(UserAuth):
)
return mcp_api_key
async def get_org_info(self) -> dict | None:
"""Get organization info for the current user.
Lazily loads and caches organization data including:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission names for the role
Returns:
dict with org_id, org_name, role, permissions or None if not available
"""
if self._org_info_loaded:
if self._org_id is None:
return None
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
# Mark as loaded to avoid repeated attempts on failure
self._org_info_loaded = True
try:
# Get user and their current org
user = await UserStore.get_user_by_id(self.user_id)
if not user:
logger.warning(f'User {self.user_id} not found for org info')
return None
# Get the current org
org = await OrgStore.get_org_by_id(user.current_org_id)
if not org:
logger.warning(
f'Organization {user.current_org_id} not found for user {self.user_id}'
)
return None
# Get user's role in the current org
role = await get_user_org_role(self.user_id, user.current_org_id)
role_name = role.name if role else None
# Get permissions for the role
permissions: list[str] = []
if role_name:
role_permissions = get_role_permissions(role_name)
permissions = [p.value for p in role_permissions]
# Cache the results
self._org_id = str(user.current_org_id)
self._org_name = org.name
self._role = role_name
self._permissions = permissions
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
except Exception as e:
logger.error(f'Error fetching org info for user {self.user_id}: {e}')
return None
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
logger.debug('saas_user_auth_get_instance')

View File

@@ -20,6 +20,7 @@ from server.auth.constants import (
GITLAB_APP_CLIENT_ID,
RECAPTCHA_SITE_KEY,
)
from server.constants import DEPLOYMENT_MODE
from openhands.core.config.utils import load_openhands_config
from openhands.integrations.service_types import ProviderType
@@ -179,6 +180,7 @@ class SaaSServerConfig(ServerConfig):
'ENABLE_JIRA': self.enable_jira,
'ENABLE_JIRA_DC': self.enable_jira_dc,
'ENABLE_LINEAR': self.enable_linear,
'DEPLOYMENT_MODE': DEPLOYMENT_MODE,
},
'PROVIDERS_CONFIGURED': providers_configured,
}

View File

@@ -15,6 +15,33 @@ IS_FEATURE_ENV = (
) # Does not include the staging deployment
IS_LOCAL_ENV = bool(HOST == 'localhost')
# _is_all_hands_managed_domain() can be removed/replaced when a self-hosted specific
# env var is created (e.g is_self_hosted` or `deployment_mode`)
def _is_all_hands_managed_domain(host: str) -> bool:
"""Check if the host is an All-Hands managed domain."""
return (
host == 'app.all-hands.dev'
or host == 'app.openhands.ai'
or host.endswith('.all-hands.dev')
or host.endswith('.openhands.ai')
)
def _get_deployment_mode() -> str:
"""Determine deployment mode based on WEB_HOST.
Returns:
'cloud' for All-Hands managed infrastructure (app.all-hands.dev, etc.)
'self_hosted' for enterprise self-hosted deployments (customer domains)
"""
if _is_all_hands_managed_domain(HOST):
return 'cloud'
return 'self_hosted'
DEPLOYMENT_MODE = _get_deployment_mode()
# Role name constants
ROLE_OWNER = 'owner'
ROLE_ADMIN = 'admin'

View File

@@ -4,9 +4,9 @@ if TYPE_CHECKING:
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.config.mcp_config import (
MCPSHTTPServerConfig,
MCPStdioServerConfig,
OpenHandsMCPConfig,
RemoteMCPServer,
StdioMCPServer,
)
from openhands.core.logger import openhands_logger as logger
@@ -24,16 +24,8 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
@staticmethod
async def create_default_mcp_server_config(
host: str, config: 'OpenHandsConfig', user_id: str | None = None
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
"""
Create a default MCP server configuration.
Args:
host: Host string
config: OpenHandsConfig
Returns:
A tuple containing the default SSE server configuration and a list of MCP stdio server configurations
"""
) -> dict[str, RemoteMCPServer | StdioMCPServer]:
"""Return a dict of default MCP server entries for SaaS mode."""
from storage.api_key_store import ApiKeyStore
api_key_store = ApiKeyStore.get_instance()
@@ -47,9 +39,14 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
if not api_key:
logger.error(f'Could not provision MCP API Key for user: {user_id}')
return None, []
return {}
return MCPSHTTPServerConfig(
url=f'https://{host}/mcp/mcp', api_key=api_key
), []
return None, []
return {
'openhands': RemoteMCPServer(
url=f'https://{host}/mcp/mcp',
transport='http',
auth=api_key,
timeout=60,
)
}
return {}

View File

@@ -198,3 +198,19 @@ class SetAuthCookieMiddleware:
await token_manager.logout(user_auth.refresh_token.get_secret_value())
except Exception:
logger.debug('Error logging out')
class PostHogSessionMiddleware:
"""Extract the PostHog session ID from the incoming request header.
Stores the value on ``request.state.posthog_session_id`` so that
subsequent event-capture call sites can link server-side events to the
corresponding frontend session-replay recording.
When the ``X-POSTHOG-SESSION-ID`` header is absent the attribute is set
to ``None`` — never raises, never blocks.
"""
async def __call__(self, request: Request, call_next: Callable):
request.state.posthog_session_id = request.headers.get('X-POSTHOG-SESSION-ID')
return await call_next(request)

View File

@@ -0,0 +1 @@
# Enterprise server models

View File

@@ -0,0 +1,16 @@
"""SAAS-specific user models that extend OSS UserInfo with organization fields."""
from openhands.app_server.user.user_models import UserInfo
class SaasUserInfo(UserInfo):
"""User info model for SAAS mode with organization context.
Extends the base UserInfo with SAAS-specific fields for organization
membership, role, and permissions.
"""
org_id: str | None = None
org_name: str | None = None
role: str | None = None
permissions: list[str] | None = None

View File

@@ -7,7 +7,6 @@ from typing import Annotated, Optional, cast
from urllib.parse import quote, urlencode
from uuid import UUID as parse_uuid
import posthog
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import SecretStr
@@ -27,7 +26,10 @@ from server.auth.user.user_authorizer import (
depends_user_authorizer,
)
from server.config import sign_token
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
from server.constants import (
DEPLOYMENT_MODE,
IS_FEATURE_ENV,
)
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from server.services.org_invitation_service import (
EmailMismatchError,
@@ -43,6 +45,7 @@ from storage.database import a_session_maker
from storage.user import User
from storage.user_store import UserStore
from openhands.analytics import get_analytics_service
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import ProviderType, TokenResponse
@@ -120,6 +123,35 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
return state, None, None
async def _get_user_orgs_with_data(user_id: str, org_member_ids: list) -> list:
"""Load Org objects for a user's org memberships.
Uses org_member.org_id list to batch-load Org objects, avoiding N+1
by loading all orgs a user belongs to in one query via OrgStore.
Args:
user_id: The user's ID string
org_member_ids: List of org_id UUIDs from user.org_members
Returns:
List of Org objects the user belongs to
"""
from storage.org_store import OrgStore
orgs = []
for org_id in org_member_ids:
try:
org = await OrgStore.get_org_by_id(org_id)
if org:
orgs.append(org)
except Exception:
logger.exception(
'auth:_get_user_orgs_with_data:failed',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
return orgs
@oauth_router.get('/keycloak/callback')
async def keycloak_callback(
request: Request,
@@ -198,9 +230,11 @@ async def keycloak_callback(
email = user_info.email
user_id = user_info.sub
user_info_dict = user_info.model_dump(exclude_none=True)
is_new_user = False
user = await UserStore.get_user_by_id(user_id)
if not user:
user = await UserStore.create_user(user_id, user_info_dict)
is_new_user = True
else:
# Existing user — gradually backfill contact_name if it still has a username-style value
await UserStore.backfill_contact_name(user_id, user_info_dict)
@@ -215,6 +249,36 @@ async def keycloak_callback(
logger.info(f'Logging in user {str(user.id)} in org {user.current_org_id}')
# Analytics: user signed up event (fires only for new users, once per user)
if is_new_user:
try:
analytics = get_analytics_service()
if analytics:
consented = (
user.user_consents_to_analytics is True
) # None = undecided = not consented
org_id_str = str(user.current_org_id) if user.current_org_id else None
analytics.track_user_signed_up(
distinct_id=user_id,
idp=user_info.get('identity_provider', 'keycloak'),
email_domain=email.split('@')[1]
if email and '@' in email
else None,
invitation_source='invitation'
if invitation_token
else 'self_signup',
org_id=org_id_str,
consented=consented,
)
analytics.set_person_properties(
distinct_id=user_id,
properties={'signed_up_at': datetime.now(timezone.utc).isoformat()},
consented=consented,
)
except Exception:
logger.exception('analytics:user_signed_up:failed')
# reCAPTCHA verification with Account Defender
if RECAPTCHA_SITE_KEY:
if not recaptcha_token:
@@ -331,36 +395,68 @@ async def keycloak_callback(
f'keycloakAccessToken: {keycloak_access_token}, keycloakUserId: {user_id}'
)
# adding in posthog tracking
# Server-side identity — full person and org group tracking via AnalyticsService
analytics = get_analytics_service()
if analytics:
consented = (
user.user_consents_to_analytics is True
) # None = undecided = not consented
org_id_str = str(user.current_org_id) if user.current_org_id else None
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
# Load current org for identify_user
from storage.org_store import OrgStore
try:
posthog.set(
distinct_id=posthog_user_id,
properties={
'user_id': posthog_user_id,
'original_user_id': user_id,
'is_feature_env': IS_FEATURE_ENV,
},
current_org = (
await OrgStore.get_org_by_id(user.current_org_id)
if user.current_org_id
else None
)
except Exception as e:
logger.error(
'auth:posthog_set:failed',
extra={
'user_id': user_id,
'error': str(e),
},
# Load org data for identify_user (orgs list with member_count)
org_member_ids = (
[om.org_id for om in user.org_members] if user.org_members else []
)
user_orgs = await _get_user_orgs_with_data(user_id, org_member_ids)
from storage.org_member_store import OrgMemberStore
orgs_data = []
for org in user_orgs:
try:
member_count = await OrgMemberStore.get_org_members_count(org_id=org.id)
except Exception:
logger.exception(
'auth:identify_user:member_count_failed',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
member_count = None
orgs_data.append(
{'id': str(org.id), 'name': org.name, 'member_count': member_count}
)
analytics.identify_user(
distinct_id=user_id,
consented=consented,
email=email,
org_id=org_id_str,
org_name=current_org.name if current_org else None,
idp=idp,
orgs=orgs_data,
)
analytics.track_user_logged_in(
distinct_id=user_id,
idp=idp,
org_id=org_id_str,
consented=consented,
)
# Continue execution as this is not critical
logger.info(
'user_logged_in',
extra={
'idp': idp,
'idp_type': idp_type,
'posthog_user_id': posthog_user_id,
'user_id': user_id,
'is_feature_env': IS_FEATURE_ENV,
},
)
@@ -462,8 +558,20 @@ async def keycloak_callback(
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
response = RedirectResponse(tos_redirect_url, status_code=302)
else:
# User has accepted TOS - check if they need onboarding
# Only redirect to onboarding if user has a valid offline token,
# otherwise they need to complete the Keycloak offline token flow first
if valid_offline_token and await _should_redirect_to_onboarding(user_id, user):
redirect_url = f'{web_url}/onboarding'
logger.info(
'Redirecting returning user to onboarding',
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
)
if invitation_token:
redirect_url = f'{redirect_url}&invitation_success=true'
if '?' in redirect_url:
redirect_url = f'{redirect_url}&invitation_success=true'
else:
redirect_url = f'{redirect_url}?invitation_success=true'
response = RedirectResponse(redirect_url, status_code=302)
set_response_cookie(
@@ -471,7 +579,7 @@ async def keycloak_callback(
response=response,
keycloak_access_token=keycloak_access_token,
keycloak_refresh_token=keycloak_refresh_token,
secure=True if redirect_url.startswith('https') else False,
secure=True if web_url.startswith('https') else False,
accepted_tos=has_accepted_tos,
)
@@ -512,8 +620,23 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
user_id=user_info.sub, offline_token=keycloak_refresh_token
)
user = await UserStore.get_user_by_id(user_info.sub)
has_accepted_tos = user is not None and user.accepted_tos is not None
redirect_url, _, _ = _extract_oauth_state(state)
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
default_url = redirect_url if redirect_url else web_url
final_url = await _get_post_auth_redirect(user_info.sub, default_url, web_url, user)
response = RedirectResponse(final_url, status_code=302)
set_response_cookie(
request=request,
response=response,
keycloak_access_token=keycloak_access_token,
keycloak_refresh_token=keycloak_refresh_token,
secure=True if web_url.startswith('https') else False,
accepted_tos=has_accepted_tos,
)
return response
@oauth_router.get('/github/callback')
@@ -549,6 +672,69 @@ async def authenticate(request: Request):
return response
async def _should_redirect_to_onboarding(user_id: str, user: User) -> bool:
"""Check if user should be redirected to onboarding after TOS acceptance.
Backend always redirects applicable users to /onboarding.
Returns True if:
- User has onboarding_completed explicitly set to False (new users)
- Either:
- Deployment mode is 'cloud' (all users)
- Deployment mode is 'self_hosted' AND user is the super admin
(first owner in their current org to accept TOS)
Returns False if:
- User has onboarding_completed=True (already completed)
- User has onboarding_completed=None (existing users before this feature)
"""
# Already completed onboarding
if user.onboarding_completed is True:
return False
# Existing user before this feature (NULL in database)
if user.onboarding_completed is None:
return False
# Cloud SaaS: all users go to onboarding
if DEPLOYMENT_MODE == 'cloud':
return True
# Self-hosted SaaS: only the super admin (first owner to accept TOS in the org)
if DEPLOYMENT_MODE == 'self_hosted':
first_owner = await UserStore.get_first_owner_in_org(user.current_org_id)
if first_owner and str(first_owner.id) == user_id:
return True
return False
async def _get_post_auth_redirect(
user_id: str, default_url: str, web_url: str, user: User | None = None
) -> str:
"""Determine where to redirect user after authentication completes.
Called after offline token is stored to determine final redirect destination.
Checks for pending user flows (e.g., onboarding) before falling back to default.
Args:
user_id: The user's ID.
default_url: The default URL to redirect to if no special flow is needed.
web_url: The base web URL for constructing absolute paths.
user: Optional user object to avoid refetching.
Returns:
The URL to redirect the user to.
"""
if not user:
user = await UserStore.get_user_by_id(user_id)
if user and await _should_redirect_to_onboarding(user_id, user):
logger.info(
'Redirecting user to onboarding',
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
)
return f'{web_url}/onboarding'
return default_url
@api_router.post('/accept_tos')
async def accept_tos(request: Request):
user_auth = cast(SaasUserAuth, await get_user_auth(request))
@@ -589,6 +775,12 @@ async def accept_tos(request: Request):
logger.info(f'User {user_id} accepted TOS')
# Determine final redirect - but don't override if it's the offline token flow
# (the offline callback will handle post-auth redirect after storing the token)
is_offline_flow = 'offline' in redirect_url
if not is_offline_flow:
redirect_url = await _get_post_auth_redirect(user_id, redirect_url, web_url)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
)
@@ -598,12 +790,42 @@ async def accept_tos(request: Request):
response=response,
keycloak_access_token=access_token.get_secret_value(),
keycloak_refresh_token=refresh_token.get_secret_value(),
secure=not IS_LOCAL_ENV,
secure=True if web_url.startswith('https') else False,
accepted_tos=True,
)
return response
@api_router.post('/complete_onboarding')
async def complete_onboarding(request: Request):
"""Mark onboarding as completed for the current user."""
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_id = await user_auth.get_user_id()
if not user_id:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'User is not authenticated'},
)
user = await UserStore.mark_onboarding_completed(user_id)
if not user:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'User not found'},
)
logger.info(
'User completed onboarding',
extra={'user_id': user_id},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Onboarding completed'},
)
@api_router.post('/logout')
async def logout(request: Request):
# Always create the response object first to ensure we can return it even if errors occur

View File

@@ -20,6 +20,7 @@ from storage.org import Org
from storage.subscription_access import SubscriptionAccess
from storage.user_store import UserStore
from openhands.analytics import get_analytics_service
from openhands.app_server.config import get_global_config
from openhands.server.user_auth import get_user_id
@@ -28,9 +29,7 @@ billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])
async def validate_billing_enabled() -> None:
"""
Validate that the billing feature flag is enabled
"""
"""Validate that the billing feature flag is enabled"""
config = get_global_config()
web_client_config = await config.web_client.get_web_client_config()
if not web_client_config.feature_flags.enable_billing:
@@ -299,6 +298,22 @@ async def success_callback(session_id: str, request: Request):
)
await session.commit()
# Analytics: credit purchased event (fires after commit so event only fires on success)
try:
analytics = get_analytics_service()
if analytics and user:
consented = user.user_consents_to_analytics is True
analytics.track_credit_purchased(
distinct_id=billing_session.user_id,
amount_usd=add_credits,
credit_balance_before=max_budget,
credit_balance_after=new_max_budget,
org_id=str(user.current_org_id) if user.current_org_id else None,
consented=consented,
)
except Exception:
logger.exception('analytics:credit_purchased:failed')
return RedirectResponse(
f'{get_web_url(request)}/settings/billing?checkout=success', status_code=302
)

View File

@@ -7,8 +7,8 @@ from storage.database import a_session_maker
from storage.feedback import ConversationFeedback
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.events.event_store import EventStore
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import file_store
from openhands.server.user_auth import get_user_id

View File

@@ -149,7 +149,12 @@ async def verify_jira_signature(body: bytes, signature: str, payload: dict):
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')
logger.warning(
'[Jira] No workspace name found in webhook payload',
extra={
'payload': payload,
},
)
raise HTTPException(
status_code=403, detail='Workspace name not found in payload'
)

View File

@@ -335,6 +335,9 @@ async def on_options_load(request: Request, background_tasks: BackgroundTasks):
2. Searches for repositories matching the user's query
3. Returns up to 100 options for the dropdown
Note: "No Repository" is handled by a separate button in the form, so it's
not included in the dropdown options. Error cases return an empty list.
Configuration: Set the Options Load URL in Slack App settings to:
https://your-domain/slack/on-options-load
"""

View File

@@ -10,6 +10,7 @@ from server.utils.url_utils import get_web_url
from storage.api_key_store import ApiKeyStore
from storage.device_code_store import DeviceCodeStore
from openhands.analytics import get_analytics_service, resolve_context
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -311,6 +312,42 @@ async def device_verification_authenticated(
'Device code authorized with API key successfully',
extra={'user_code': user_code, 'user_id': user_id},
)
# Server-side identity tracking for device auth flow
analytics = get_analytics_service()
if analytics:
try:
ctx = await resolve_context(user_id)
# Load current org name for identify_user
from storage.org_store import OrgStore
current_org = (
await OrgStore.get_org_by_id(ctx.user.current_org_id)
if ctx.user and ctx.user.current_org_id
else None
)
analytics.identify_user(
distinct_id=user_id,
consented=ctx.consented,
org_id=ctx.org_id,
org_name=current_org.name if current_org else None,
idp='device_auth',
)
analytics.track_user_logged_in(
distinct_id=user_id,
idp='device_auth',
org_id=ctx.org_id,
consented=ctx.consented,
)
except Exception:
logger.exception(
'oauth_device:analytics:failed',
extra={'user_id': user_id},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Device authorized successfully!'},

View File

@@ -0,0 +1,68 @@
"""Onboarding submission endpoint.
Receives user onboarding selections and fires analytics event.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from openhands.server.user_auth import get_user_id
onboarding_router = APIRouter(prefix='/api', tags=['Onboarding'])
class OnboardingSubmission(BaseModel):
selections: dict[
str, str
] # step_id -> option_id (e.g., {"step1": "software_engineer", "step2": "solo", "step3": "new_features"})
class OnboardingResponse(BaseModel):
status: str
redirect_url: str
@onboarding_router.post('/onboarding', response_model=OnboardingResponse)
async def submit_onboarding(
body: OnboardingSubmission,
user_id: str | None = Depends(get_user_id),
) -> OnboardingResponse:
"""Submit onboarding form selections and fire analytics event."""
# ACTV-03: onboarding completed
try:
from openhands.analytics import get_analytics_service, resolve_context
analytics = get_analytics_service()
if analytics and user_id:
ctx = await resolve_context(user_id)
analytics.track_onboarding_completed(
distinct_id=user_id,
role=body.selections.get('step1'),
org_size=body.selections.get('step2'),
use_case=body.selections.get('step3'),
org_id=ctx.org_id,
consented=ctx.consented,
)
# Associate onboarding timestamp with org group
if ctx.org_id:
analytics.group_identify(
group_type='org',
group_key=ctx.org_id,
properties={
'onboarding_completed_at': datetime.now(
timezone.utc
).isoformat(),
},
distinct_id=user_id,
consented=ctx.consented,
)
except Exception:
import logging
logging.getLogger(__name__).exception('analytics:onboarding_completed:failed')
return OnboardingResponse(status='ok', redirect_url='/')

View File

@@ -22,6 +22,7 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from storage.org_store import OrgStore
from storage.role_store import RoleStore
from openhands.analytics import get_analytics_service
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -94,6 +95,28 @@ async def create_invitation(
},
)
# Analytics: track team members invited
try:
analytics = get_analytics_service()
if analytics and user_id:
from storage.user_store import UserStore
user_obj = await UserStore.get_user_by_id(user_id)
consented = (
user_obj.user_consents_to_analytics is True if user_obj else False
)
analytics.track_team_members_invited(
distinct_id=user_id,
org_id=str(org_id),
invited_count=len(invitation_data.emails),
successful_count=len(successful),
failed_count=len(failed),
role=invitation_data.role,
consented=consented,
)
except Exception:
logger.exception('analytics:team_members_invited:failed')
successful_responses = [
await InvitationResponse.from_invitation(inv) for inv in successful
]

View File

@@ -1,4 +1,4 @@
from typing import Annotated
from typing import Annotated, Any
from pydantic import (
BaseModel,
@@ -12,6 +12,8 @@ from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
from openhands.sdk.settings import AgentSettings, ConversationSettings
class OrgCreationError(Exception):
"""Base exception for organization creation errors."""
@@ -144,21 +146,16 @@ class OrgResponse(BaseModel):
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
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
conversation_settings: ConversationSettings = Field(
default_factory=ConversationSettings
)
search_api_key: str | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = None
@@ -171,33 +168,14 @@ class OrgResponse(BaseModel):
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
"""
"""Create an OrgResponse from an Org entity."""
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
@@ -205,7 +183,12 @@ class OrgResponse(BaseModel):
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,
agent_settings=AgentSettings.model_validate(
dict(org.agent_settings) if org.agent_settings else {}
),
conversation_settings=ConversationSettings.model_validate(
dict(org.conversation_settings) if org.conversation_settings else {}
),
search_api_key=None,
sandbox_api_key=None,
max_budget_per_task=org.max_budget_per_task,
@@ -227,7 +210,6 @@ class OrgPage(BaseModel):
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),
@@ -235,7 +217,6 @@ class OrgUpdate(BaseModel):
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
@@ -245,31 +226,20 @@ class OrgUpdate(BaseModel):
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)
agent_settings_diff: dict[str, Any] | None = None
conversation_settings_diff: dict[str, Any] | None = None
class OrgLLMSettingsResponse(BaseModel):
"""Response model for organization LLM settings."""
"""Response model for organization default LLM settings."""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
conversation_settings: ConversationSettings = Field(
default_factory=ConversationSettings
)
llm_api_key_set: bool = False
search_api_key: str | None = None # Masked in response
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool = True
condenser_max_size: int | None = None
default_max_iterations: int | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str | None:
@@ -287,83 +257,55 @@ class OrgLLMSettingsResponse(BaseModel):
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
"""Create response from Org entity."""
return cls(
default_llm_model=org.default_llm_model,
default_llm_base_url=org.default_llm_base_url,
agent_settings=AgentSettings.model_validate(
dict(org.agent_settings) if org.agent_settings else {}
),
conversation_settings=ConversationSettings.model_validate(
dict(org.conversation_settings) if org.conversation_settings else {}
),
llm_api_key_set=org.llm_api_key is not None,
search_api_key=cls._mask_key(org.search_api_key),
agent=org.agent,
confirmation_mode=org.confirmation_mode,
security_analyzer=org.security_analyzer,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
condenser_max_size=org.condenser_max_size,
default_max_iterations=org.default_max_iterations,
)
class OrgMemberLLMSettings(BaseModel):
"""LLM settings to propagate to organization members.
"""Shared LLM settings that may be propagated to organization members."""
Field names match OrgMember DB columns.
"""
llm_model: str | None = None
llm_base_url: str | None = None
max_iterations: int | None = None
agent_settings_diff: dict[str, Any] | None = None
conversation_settings_diff: dict[str, Any] | None = None
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
return any(
getattr(self, field) is not None for field in type(self).model_fields
)
class OrgLLMSettingsUpdate(BaseModel):
"""Request model for updating organization LLM settings.
"""Request model for updating organization LLM settings."""
Field names match Org DB columns exactly.
"""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
agent_settings_diff: dict[str, Any] | None = None
conversation_settings_diff: dict[str, Any] | None = None
search_api_key: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
default_max_iterations: int | None = Field(default=None, gt=0)
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
return any(
getattr(self, field) is not None for field in type(self).model_fields
)
def apply_to_org(self, org: Org) -> None:
"""Apply non-None settings to the organization model.
Args:
org: Organization entity to update in place
"""
for field_name in self.model_fields:
value = getattr(self, field_name)
# Skip llm_api_key - it's only for member propagation, not org-level
if value is not None and field_name != 'llm_api_key':
setattr(org, field_name, value)
"""Apply non-None settings to the organization model."""
if self.search_api_key is not None:
org.search_api_key = self.search_api_key or None
if self.llm_api_key is not None:
org.llm_api_key = self.llm_api_key or None
def get_member_updates(self) -> OrgMemberLLMSettings | None:
"""Get updates that need to be propagated to org members.
Returns:
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
default_max_iterations → max_iterations, llm_api_key → llm_api_key
"""
member_settings = OrgMemberLLMSettings(
llm_model=self.default_llm_model,
llm_base_url=self.default_llm_base_url,
max_iterations=self.default_max_iterations,
llm_api_key=self.llm_api_key,
)
"""Get updates that need to be propagated to org members."""
member_settings = OrgMemberLLMSettings(llm_api_key=self.llm_api_key)
return member_settings if member_settings.has_updates() else None
@@ -393,25 +335,28 @@ class OrgMemberUpdate(BaseModel):
class MeResponse(BaseModel):
"""Response model for the current user's membership in an organization."""
"""Response model for the current user's membership in an organization.
``agent_settings_diff`` and ``conversation_settings_diff`` carry the
member-level overrides on top of the organization defaults.
"""
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
agent_settings_diff: dict[str, Any] = Field(default_factory=dict)
conversation_settings_diff: dict[str, Any] = Field(default_factory=dict)
status: str | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str:
def _mask_key(secret: str | SecretStr | None) -> str:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return ''
raw = secret.get_secret_value()
raw = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
if not raw:
return ''
if len(raw) <= 4:
@@ -419,27 +364,22 @@ class MeResponse(BaseModel):
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
"""
def from_org_member(
cls,
member: OrgMember,
role: Role,
email: str,
) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email."""
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,
agent_settings_diff=dict(member.agent_settings_diff or {}),
conversation_settings_diff=dict(member.conversation_settings_diff or {}),
status=member.status,
)

View File

@@ -54,6 +54,7 @@ from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.analytics import get_analytics_service
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -1105,6 +1106,29 @@ async def switch_org(
org_id=org_id,
)
# Refresh person profile with new active org on org switch
analytics = get_analytics_service()
if analytics:
try:
from openhands.analytics import resolve_context
ctx = await resolve_context(user_id)
analytics.set_person_properties(
distinct_id=user_id,
properties={
'org_id': str(org_id),
'org_name': org.name,
'plan_tier': None, # plan_tier not yet on Org model
},
consented=ctx.consented,
)
except Exception:
logger.exception(
'orgs:switch_org:analytics:failed',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
# Retrieve credits from LiteLLM for the new current org
credits = await OrgService.get_org_credits(user_id, org.id)

View File

@@ -7,6 +7,7 @@ from server.auth.token_manager import TokenManager
from storage.user_store import UserStore
from utils.identity import resolve_display_name
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
@@ -23,7 +24,6 @@ from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.routes.git import (
get_repository_branches,
get_repository_microagent_content,
@@ -45,7 +45,12 @@ saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies()
token_manager = TokenManager()
@saas_user_router.get('/installations', response_model=list[str])
@saas_user_router.get(
'/installations',
response_model=list[str],
deprecated=True,
description='Deprecated: Use `/api/v1/git/installations` instead.',
)
async def saas_get_user_installations(
provider: ProviderType,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
@@ -115,7 +120,12 @@ async def saas_get_user_git_organizations(
}
@saas_user_router.get('/repositories', response_model=list[Repository])
@saas_user_router.get(
'/repositories',
response_model=list[Repository],
deprecated=True,
description='Deprecated: Use `/api/v1/git/repositories` instead.',
)
async def saas_get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
@@ -146,12 +156,13 @@ async def saas_get_user_repositories(
)
@saas_user_router.get('/info', response_model=User)
@saas_user_router.get('/info', response_model=User, deprecated=True)
async def saas_get_user(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> User | JSONResponse:
"""Get the current user git info. Use GET /api/v1/users/git-info instead"""
if not provider_tokens:
if not access_token:
return JSONResponse(

View File

@@ -0,0 +1,137 @@
"""SAAS-specific extensions for the /api/v1/users endpoints.
This module provides SAAS-specific implementations that extend the OSS
user endpoints with organization context (org_id, org_name, role, permissions).
"""
import logging
from typing import Any
from fastapi import APIRouter, FastAPI, Header, HTTPException, Query, status
from fastapi.responses import JSONResponse
from server.auth.saas_user_auth import SaasUserAuth
from server.models.user_models import SaasUserInfo
from openhands.app_server.config import (
depends_user_context,
resolve_provider_llm_base_url,
)
from openhands.app_server.sandbox.session_auth import validate_session_key_ownership
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.dependencies import get_dependencies
_logger = logging.getLogger(__name__)
saas_users_v1_router = APIRouter(
prefix='/api/v1/users', tags=['User'], dependencies=get_dependencies()
)
user_dependency = depends_user_context()
def _inject_sdk_compat_fields(
content: dict[str, Any], *, include_api_key: bool
) -> None:
"""Inject flat top-level convenience fields for the SDK.
The SDK's ``get_llm()`` and ``get_mcp_config()`` read ``llm_model``,
``llm_api_key``, ``llm_base_url``, and ``mcp_config`` from the top
level of the ``/api/v1/users/me`` response. These values live inside
the nested ``agent_settings`` structure, so we mirror them at the top
level for backward compatibility.
The canonical representation is ``agent_settings``; these flat fields
exist solely for SDK backward compatibility.
"""
agent_settings = content.get('agent_settings') or {}
llm = agent_settings.get('llm') or {}
model = llm.get('model')
content['llm_model'] = model
content['llm_base_url'] = resolve_provider_llm_base_url(model, llm.get('base_url'))
if include_api_key:
content['llm_api_key'] = llm.get('api_key')
content['mcp_config'] = agent_settings.get('mcp_config')
@saas_users_v1_router.get('/me')
async def get_current_user_saas(
user_context: UserContext = user_dependency,
expose_secrets: bool = Query(
default=False,
description='If true, return unmasked secret values (e.g. llm_api_key). '
'Requires a valid X-Session-API-Key header for an active sandbox '
'owned by the authenticated user.',
),
x_session_api_key: str | None = Header(default=None),
) -> SaasUserInfo:
"""Get the current authenticated user with SAAS-specific org info.
Returns user settings along with organization context:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission strings for the role
"""
# Get base user info from the context
base_user_info = await user_context.get_user_info()
if base_user_info is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
# Build SAAS user info from base settings
user_info_data = base_user_info.model_dump(
mode='json', context={'expose_secrets': True}
)
# Add org info if available (from SaasUserAuth)
org_info = await _get_org_info_from_context(user_context)
if org_info:
user_info_data.update(org_info)
user_info = SaasUserInfo(**user_info_data)
if expose_secrets:
await validate_session_key_ownership(user_context, x_session_api_key)
content = user_info.model_dump(mode='json', context={'expose_secrets': True})
_inject_sdk_compat_fields(content, include_api_key=True)
return JSONResponse(content=content) # type: ignore[return-value]
content = user_info.model_dump(mode='json')
_inject_sdk_compat_fields(content, include_api_key=False)
return JSONResponse(content=content) # type: ignore[return-value]
async def _get_org_info_from_context(user_context: UserContext) -> dict | None:
"""Extract org info from the user context if available.
This works by checking if the underlying user_auth is a SaasUserAuth
instance that has the get_org_info method.
"""
# Check if this is an AuthUserContext with a SaasUserAuth
if isinstance(user_context, AuthUserContext):
user_auth = user_context.user_auth
if isinstance(user_auth, SaasUserAuth):
return await user_auth.get_org_info()
return None
def override_users_me_endpoint(app: FastAPI) -> None:
"""Override the OSS /api/v1/users/me endpoint with SAAS version.
This removes the base OSS endpoint and registers the SAAS version
which includes organization context (org_id, org_name, role, permissions).
Must be called after the app is created in saas_server.py.
"""
# Find and remove the OSS /api/v1/users/me route
routes_to_remove = []
for route in app.routes:
if hasattr(route, 'path') and route.path == '/api/v1/users/me':
routes_to_remove.append(route)
for route in routes_to_remove:
app.routes.remove(route)
_logger.debug('Removed OSS route: %s', route.path)
# Add the SAAS version
app.include_router(saas_users_v1_router)
_logger.debug('Added SAAS /api/v1/users/me endpoint')

View File

@@ -27,7 +27,7 @@ from storage.stored_conversation_metadata_saas import StoredConversationMetadata
from openhands.controller.agent import Agent
from openhands.core.config import LLMConfig, OpenHandsConfig
from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig
from openhands.core.config.mcp_config import MCPConfig, RemoteMCPServer
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import MessageAction
from openhands.events.event_store import EventStore
@@ -497,10 +497,16 @@ class SaasNestedConversationManager(ConversationManager):
if not mcp_api_key:
return None
web_host = os.environ.get('WEB_HOST', 'app.all-hands.dev')
shttp_servers = [
MCPSHTTPServerConfig(url=f'https://{web_host}/mcp/mcp', api_key=mcp_api_key)
]
return MCPConfig(shttp_servers=shttp_servers)
return MCPConfig(
mcpServers={
'openhands': RemoteMCPServer(
url=f'https://{web_host}/mcp/mcp',
transport='http',
auth=mcp_api_key,
timeout=60,
)
}
)
async def _create_nested_conversation(
self,
@@ -523,9 +529,11 @@ class SaasNestedConversationManager(ConversationManager):
mcp_config = await self._get_mcp_config(user_id)
if mcp_config:
# Merge with any MCP config from settings
if settings.mcp_config:
mcp_config = mcp_config.merge(settings.mcp_config)
# Check again since theoretically merge could return None.
sdk_mcp = settings.agent_settings.mcp_config
if sdk_mcp and sdk_mcp.mcpServers:
from openhands.core.config.mcp_config import merge_mcp_configs
mcp_config = merge_mcp_configs(mcp_config, sdk_mcp)
if mcp_config:
init_conversation['mcp_config'] = mcp_config.model_dump()
@@ -855,7 +863,7 @@ class SaasNestedConversationManager(ConversationManager):
user_id=user_id,
)
llm_registry.retry_listner = session._notify_on_llm_retry
agent_cls = settings.agent or self.config.default_agent
agent_cls = settings.agent_settings.agent or self.config.default_agent
agent_config = self.config.get_agent_config(agent_cls)
agent = Agent.get_cls(agent_cls)(agent_config, llm_registry)

View File

@@ -365,15 +365,17 @@ class OrgInvitationService:
'Failed to set up organization access. Please try again.'
)
# Step 4.5: Fetch organization to get its LLM settings
# Step 4.5: Ensure the organization still exists before adding membership
org = await OrgStore.get_org_by_id(invitation.org_id)
if not org:
raise InvitationInvalidError('Organization not found')
# Step 5: Add user to organization with inherited org LLM settings
# Get the llm_api_key as string (it's SecretStr | None in Settings)
# Step 5: Add user to organization. New members start with no
# personal agent-setting overrides so future org default changes
# continue to flow through automatically.
llm_api_key_secret = settings.agent_settings.llm.api_key
llm_api_key = (
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
llm_api_key_secret.get_secret_value() if llm_api_key_secret else ''
)
await OrgMemberStore.add_user_to_org(
@@ -382,9 +384,8 @@ class OrgInvitationService:
role_id=invitation.role_id,
llm_api_key=llm_api_key,
status='active',
llm_model=org.default_llm_model,
llm_base_url=org.default_llm_base_url,
max_iterations=org.default_max_iterations,
agent_settings_diff={},
conversation_settings_diff={},
)
# Step 6: Mark invitation as accepted

View File

@@ -1,12 +1,9 @@
import asyncio
from abc import ABC, abstractmethod
from datetime import datetime
from uuid import UUID
from server.sharing.shared_conversation_models import (
SharedConversation,
SharedConversationPage,
SharedConversationSortOrder,
)
from openhands.app_server.services.injector import Injector
@@ -16,32 +13,6 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
class SharedConversationInfoService(ABC):
"""Service for accessing shared conversation info without user restrictions."""
@abstractmethod
async def search_shared_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: SharedConversationSortOrder = SharedConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
include_sub_conversations: bool = False,
) -> SharedConversationPage:
"""Search for shared conversations."""
@abstractmethod
async def count_shared_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 shared conversations."""
@abstractmethod
async def get_shared_conversation_info(
self, conversation_id: UUID

View File

@@ -1,5 +1,4 @@
from datetime import datetime
from enum import Enum
# Simplified imports to avoid dependency chain issues
# from openhands.integrations.service_types import ProviderType
@@ -40,17 +39,3 @@ class SharedConversation(BaseModel):
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class SharedConversationSortOrder(Enum):
CREATED_AT = 'CREATED_AT'
CREATED_AT_DESC = 'CREATED_AT_DESC'
UPDATED_AT = 'UPDATED_AT'
UPDATED_AT_DESC = 'UPDATED_AT_DESC'
TITLE = 'TITLE'
TITLE_DESC = 'TITLE_DESC'
class SharedConversationPage(BaseModel):
items: list[SharedConversation]
next_page_id: str | None = None

View File

@@ -1,6 +1,5 @@
"""Shared Conversation router for OpenHands Server."""
from datetime import datetime
from typing import Annotated
from uuid import UUID
@@ -10,8 +9,6 @@ from server.sharing.shared_conversation_info_service import (
)
from server.sharing.shared_conversation_models import (
SharedConversation,
SharedConversationPage,
SharedConversationSortOrder,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoServiceInjector,
@@ -22,101 +19,13 @@ shared_conversation_info_service_dependency = Depends(
SQLSharedConversationInfoServiceInjector().depends
)
# Read methods
@router.get('/search')
async def search_shared_conversations(
title__contains: Annotated[
str | None,
Query(title='Filter by title containing this string'),
] = None,
created_at__gte: Annotated[
datetime | None,
Query(title='Filter by created_at greater than or equal to this datetime'),
] = None,
created_at__lt: Annotated[
datetime | None,
Query(title='Filter by created_at less than this datetime'),
] = None,
updated_at__gte: Annotated[
datetime | None,
Query(title='Filter by updated_at greater than or equal to this datetime'),
] = None,
updated_at__lt: Annotated[
datetime | None,
Query(title='Filter by updated_at less than this datetime'),
] = None,
sort_order: Annotated[
SharedConversationSortOrder,
Query(title='Sort order for results'),
] = SharedConversationSortOrder.CREATED_AT_DESC,
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,
le=100,
),
] = 100,
include_sub_conversations: Annotated[
bool,
Query(
title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.'
),
] = False,
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> SharedConversationPage:
"""Search / List shared conversations."""
return await shared_conversation_service.search_shared_conversation_info(
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,
sort_order=sort_order,
page_id=page_id,
limit=limit,
include_sub_conversations=include_sub_conversations,
)
@router.get('/count')
async def count_shared_conversations(
title__contains: Annotated[
str | None,
Query(title='Filter by title containing this string'),
] = None,
created_at__gte: Annotated[
datetime | None,
Query(title='Filter by created_at greater than or equal to this datetime'),
] = None,
created_at__lt: Annotated[
datetime | None,
Query(title='Filter by created_at less than this datetime'),
] = None,
updated_at__gte: Annotated[
datetime | None,
Query(title='Filter by updated_at greater than or equal to this datetime'),
] = None,
updated_at__lt: Annotated[
datetime | None,
Query(title='Filter by updated_at less than this datetime'),
] = None,
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> int:
"""Count shared conversations matching the given filters."""
return await shared_conversation_service.count_shared_conversation_info(
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,
)
#
# These endpoints are unauthenticated. Only batch lookup by known IDs is
# exposed publicly so that share links of the form
# /shared/conversations/<id> can be viewed without auth. Listing or
# enumerating shared conversations is intentionally not exposed.
@router.get('')

View File

@@ -1,4 +1,12 @@
"""Shared Event router for OpenHands Server."""
"""Shared Event router for OpenHands Server.
All endpoints in this router are unauthenticated — shared conversations are
public. To avoid returning internal system state that the viewer does not
need, ``ConversationStateUpdateEvent`` instances are filtered out before the
response is sent. The shared-conversation frontend only renders messages,
actions, observations, errors, and hook-execution events; state snapshots
are consumed exclusively by the authenticated WebSocket path.
"""
from datetime import datetime
from typing import Annotated
@@ -13,9 +21,15 @@ from server.sharing.shared_event_service import (
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.sdk import Event
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
from openhands.utils.environment import StorageProvider, get_storage_provider
def _is_viewable(event: Event) -> bool:
"""Return True if *event* should be included in public shared responses."""
return not isinstance(event, ConversationStateUpdateEvent)
def get_shared_event_service_injector() -> SharedEventServiceInjector:
"""Get the appropriate SharedEventServiceInjector based on configuration.
@@ -87,15 +101,36 @@ async def search_shared_events(
] = 100,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> EventPage:
"""Search / List events for a shared conversation."""
return await shared_event_service.search_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
"""Search / List events for a shared conversation.
Because non-viewable events (e.g. ``ConversationStateUpdateEvent``) are
filtered out after fetching, a single backend page may yield fewer items
than *limit*. This method transparently fetches additional backend pages
until the requested *limit* is reached or there are no more results.
"""
conv_id = UUID(conversation_id)
viewable: list[Event] = []
cursor = page_id
while len(viewable) < limit:
remaining = limit - len(viewable)
page = await shared_event_service.search_shared_events(
conversation_id=conv_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=cursor,
limit=remaining,
)
viewable.extend(e for e in page.items if _is_viewable(e))
cursor = page.next_page_id
if cursor is None:
break
return EventPage(
items=viewable[:limit],
next_page_id=cursor,
)
@@ -147,7 +182,7 @@ async def batch_get_shared_events(
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids
)
return events
return [e if e is not None and _is_viewable(e) else None for e in events]
@router.get('/{conversation_id}/{event_id}')
@@ -157,6 +192,9 @@ async def get_shared_event(
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(
event = await shared_event_service.get_shared_event(
UUID(conversation_id), UUID(event_id)
)
if event is not None and not _is_viewable(event):
return None
return event

View File

@@ -21,8 +21,6 @@ from server.sharing.shared_conversation_info_service import (
)
from server.sharing.shared_conversation_models import (
SharedConversation,
SharedConversationPage,
SharedConversationSortOrder,
)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -33,8 +31,7 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
)
from openhands.app_server.services.injector import InjectorState
from openhands.integrations.provider import ProviderType
from openhands.sdk.llm import MetricsSnapshot
from openhands.sdk.llm.utils.metrics import TokenUsage
from openhands.sdk.llm import MetricsSnapshot, TokenUsage
logger = logging.getLogger(__name__)
@@ -45,113 +42,6 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
db_session: AsyncSession
async def search_shared_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: SharedConversationSortOrder = SharedConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
include_sub_conversations: bool = False,
) -> SharedConversationPage:
"""Search for shared conversations."""
query = self._public_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(
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 == SharedConversationSortOrder.CREATED_AT:
query = query.order_by(StoredConversationMetadata.created_at)
elif sort_order == SharedConversationSortOrder.CREATED_AT_DESC:
query = query.order_by(StoredConversationMetadata.created_at.desc())
elif sort_order == SharedConversationSortOrder.UPDATED_AT:
query = query.order_by(StoredConversationMetadata.last_updated_at)
elif sort_order == SharedConversationSortOrder.UPDATED_AT_DESC:
query = query.order_by(StoredConversationMetadata.last_updated_at.desc())
elif sort_order == SharedConversationSortOrder.TITLE:
query = query.order_by(StoredConversationMetadata.title)
elif sort_order == SharedConversationSortOrder.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_shared_conversation(stored, saas_metadata=saas_metadata)
for stored, saas_metadata in rows
]
# Calculate next page ID
next_page_id = None
if has_more:
next_page_id = str(offset + limit)
return SharedConversationPage(items=items, next_page_id=next_page_id)
async def count_shared_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 shared conversations matching the given filters."""
from sqlalchemy import func
query = select(func.count(StoredConversationMetadata.conversation_id))
# Only include shared conversations
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
query = query.where(StoredConversationMetadata.conversation_version == 'V1')
query = self._apply_filters(
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)
return result.scalar() or 0
async def get_shared_conversation_info(
self, conversation_id: UUID
) -> SharedConversation | None:
@@ -169,15 +59,6 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
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."""
query = select(StoredConversationMetadata).where(
StoredConversationMetadata.conversation_version == 'V1'
)
# Only include conversations marked as public
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.
@@ -197,41 +78,6 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
)
return query
def _apply_filters(
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 common filters to a query."""
if title__contains is not None:
query = query.where(
StoredConversationMetadata.title.contains(title__contains)
)
if created_at__gte is not None:
query = query.where(
StoredConversationMetadata.created_at >= created_at__gte
)
if created_at__lt is not None:
query = query.where(StoredConversationMetadata.created_at < created_at__lt)
if updated_at__gte is not None:
query = query.where(
StoredConversationMetadata.last_updated_at >= updated_at__gte
)
if updated_at__lt is not None:
query = query.where(
StoredConversationMetadata.last_updated_at < updated_at__lt
)
return query
def _to_shared_conversation(
self,
stored: StoredConversationMetadata,

View File

@@ -14,6 +14,7 @@ from storage.conversation_work import ConversationWork
from storage.database import a_session_maker, session_maker
from storage.stored_conversation_metadata import StoredConversationMetadata
from openhands.analytics import get_analytics_service
from openhands.core.config import load_openhands_config
from openhands.core.schema.agent import AgentState
from openhands.events.event_store import EventStore
@@ -31,6 +32,15 @@ from openhands.utils.async_utils import call_sync_from_async
config = load_openhands_config()
file_store = get_file_store(config.file_store, config.file_store_path)
# V0 terminal state sets for analytics
_TERMINAL_ERROR_STATES = {AgentState.ERROR}
_TERMINAL_FINISHED_STATES = {
AgentState.FINISHED,
AgentState.STOPPED,
AgentState.AWAITING_USER_INPUT,
}
_ALL_TERMINAL_STATES = _TERMINAL_ERROR_STATES | _TERMINAL_FINISHED_STATES
async def process_event(
user_id: str, conversation_id: str, subpath: str, content: dict
@@ -62,6 +72,120 @@ async def process_event(
# Load and invoke all active callbacks for this conversation
await invoke_conversation_callbacks(conversation_id, event)
# V0 best-effort analytics for terminal states
if event.agent_state in _ALL_TERMINAL_STATES:
try:
analytics = get_analytics_service()
if analytics and user_id:
from openhands.analytics import resolve_context
ctx = await resolve_context(user_id)
# Look up conversation metadata for cost/token data
with session_maker() as meta_session:
conv_meta = (
meta_session.query(StoredConversationMetadata)
.filter(
StoredConversationMetadata.conversation_id
== conversation_id
)
.first()
)
if event.agent_state in _TERMINAL_ERROR_STATES:
analytics.track_conversation_errored(
distinct_id=user_id,
conversation_id=conversation_id,
error_type='unknown', # V0: error classification not available from AgentState alone
error_message=None,
llm_model=conv_meta.llm_model
if conv_meta and hasattr(conv_meta, 'llm_model')
else None,
turn_count=None,
terminal_state=event.agent_state.value
if hasattr(event.agent_state, 'value')
else str(event.agent_state),
org_id=ctx.org_id,
consented=ctx.consented,
)
else:
analytics.track_conversation_finished(
distinct_id=user_id,
conversation_id=conversation_id,
terminal_state=event.agent_state.value
if hasattr(event.agent_state, 'value')
else str(event.agent_state),
turn_count=None,
accumulated_cost_usd=conv_meta.accumulated_cost
if conv_meta
else None,
prompt_tokens=conv_meta.prompt_tokens
if conv_meta
else None,
completion_tokens=conv_meta.completion_tokens
if conv_meta
else None,
llm_model=conv_meta.llm_model
if conv_meta and hasattr(conv_meta, 'llm_model')
else None,
trigger=None, # V0: trigger not available in callback context
org_id=ctx.org_id,
consented=ctx.consented,
)
# ACTV-01: user activated (first finished conversation only)
if event.agent_state == AgentState.FINISHED:
try:
import uuid as _uuid
from datetime import timezone
from sqlalchemy import func
from sqlalchemy import select as sa_select
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
user_uuid = _uuid.UUID(user_id)
with session_maker() as act_session:
count_result = act_session.execute(
sa_select(func.count()).where(
StoredConversationMetadataSaas.user_id
== user_uuid,
StoredConversationMetadataSaas.conversation_id
!= conversation_id,
)
)
prior_count = count_result.scalar()
if prior_count == 0:
tos_ts = ctx.user.accepted_tos if ctx.user else None
if tos_ts is not None:
if tos_ts.tzinfo is None:
tos_ts = tos_ts.replace(tzinfo=timezone.utc)
from datetime import datetime
time_to_activate_seconds = (
datetime.now(timezone.utc) - tos_ts
).total_seconds()
else:
time_to_activate_seconds = None
analytics.track_user_activated(
distinct_id=user_id,
conversation_id=conversation_id,
time_to_activate_seconds=time_to_activate_seconds,
llm_model=conv_meta.llm_model
if conv_meta
else None,
trigger=None, # V0: trigger not available in callback context
org_id=ctx.org_id,
consented=ctx.consented,
)
except Exception:
logger.exception('analytics:user_activated:v0:failed')
except Exception:
logger.exception('analytics:v0_terminal_state:failed')
# Update active working seconds if agent state is not Running
if event.agent_state != AgentState.RUNNING:
event_store = EventStore(conversation_id, file_store, user_id)

View File

@@ -363,6 +363,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
if api_key_org_id is not None:
org_id = api_key_org_id
# Override with resolver org_id if set (from git org claim resolution)
resolver_org_id = getattr(self.user_context, 'resolver_org_id', None)
if resolver_org_id is not None:
org_id = resolver_org_id
# Check if SAAS metadata already exists
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)

View File

@@ -1,5 +1,13 @@
"""
Unified SQLAlchemy declarative base for all models.
Re-exports the core Base to ensure enterprise and core models share the same
metadata registry. This allows foreign key relationships between enterprise
models (e.g., ConversationCallback) and core models (e.g., StoredConversationMetadata).
The core Base now uses SQLAlchemy 2.0 DeclarativeBase for proper type inference
with Mapped types, while remaining backward compatible with existing Column()
definitions.
"""
from openhands.app_server.utils.sql_utils import Base

View File

@@ -1,22 +1,28 @@
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import DECIMAL, Column, DateTime, Enum, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy import DECIMAL, DateTime, Enum, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from storage.base import Base
if TYPE_CHECKING:
from storage.org import Org
class BillingSession(Base): # type: ignore
class BillingSession(Base):
"""
Represents a Stripe billing session for credit purchases.
Tracks the status of payment transactions and associated user information.
"""
__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(
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(String, nullable=False)
org_id: Mapped[UUID | None] = mapped_column(ForeignKey('org.id'), nullable=True)
status: Mapped[str] = mapped_column(
Enum(
'in_progress',
'completed',
@@ -26,16 +32,16 @@ class BillingSession(Base): # type: ignore
),
default='in_progress',
)
price = Column(DECIMAL(19, 4), nullable=False)
price_code = Column(String, nullable=False)
created_at = Column(
price: Mapped[Decimal] = mapped_column(DECIMAL(19, 4), nullable=False)
price_code: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
default=lambda: datetime.now(UTC),
)
updated_at = Column(
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
default=lambda: datetime.now(UTC),
)
# Relationships
org = relationship('Org', back_populates='billing_sessions')
org: Mapped['Org | None'] = relationship('Org', back_populates='billing_sessions')

View File

@@ -3,7 +3,8 @@
from datetime import datetime, timezone
from enum import Enum
from sqlalchemy import Column, DateTime, Integer, String
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from storage.base import Base
@@ -25,21 +26,33 @@ class DeviceCode(Base):
__tablename__ = 'device_codes'
id = Column(Integer, primary_key=True, autoincrement=True)
device_code = Column(String(128), unique=True, nullable=False, index=True)
user_code = Column(String(16), unique=True, nullable=False, index=True)
status = Column(String(32), nullable=False, default=DeviceCodeStatus.PENDING.value)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
device_code: Mapped[str] = mapped_column(
String(128), unique=True, nullable=False, index=True
)
user_code: Mapped[str] = mapped_column(
String(16), unique=True, nullable=False, index=True
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=DeviceCodeStatus.PENDING.value
)
# Keycloak user ID who authorized the device (set during verification)
keycloak_user_id = Column(String(255), nullable=True)
keycloak_user_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Timestamps
expires_at = Column(DateTime(timezone=True), nullable=False)
authorized_at = Column(DateTime(timezone=True), nullable=True)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
authorized_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Rate limiting fields for RFC 8628 section 3.5 compliance
last_poll_time = Column(DateTime(timezone=True), nullable=True)
current_interval = Column(Integer, nullable=False, default=5)
last_poll_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
current_interval: Mapped[int] = mapped_column(nullable=False, default=5)
def __repr__(self) -> str:
return f"<DeviceCode(user_code='{self.user_code}', status='{self.status}')>"

View File

@@ -1,29 +1,34 @@
from sqlalchemy import JSON, Column, DateTime, Enum, Integer, String, Text
from sqlalchemy.sql import func
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, Enum, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from storage.base import Base
class Feedback(Base): # type: ignore
class Feedback(Base):
__tablename__ = 'feedback'
id = Column(String, primary_key=True)
version = Column(String, nullable=False)
email = Column(String, nullable=False)
polarity = Column(
id: Mapped[str] = mapped_column(String, primary_key=True)
version: Mapped[str] = mapped_column(String, nullable=False)
email: Mapped[str] = mapped_column(String, nullable=False)
polarity: Mapped[str] = mapped_column(
Enum('positive', 'negative', name='polarity_enum'), nullable=False
)
permissions = Column(
permissions: Mapped[str] = mapped_column(
Enum('public', 'private', name='permissions_enum'), nullable=False
)
trajectory = Column(JSON, nullable=True)
trajectory: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
class ConversationFeedback(Base): # type: ignore
class ConversationFeedback(Base):
__tablename__ = 'conversation_feedback'
id = Column(Integer, primary_key=True, autoincrement=True)
conversation_id = Column(String, nullable=False, index=True)
event_id = Column(Integer, nullable=True)
rating = Column(Integer, nullable=False)
reason = Column(Text, nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
conversation_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
event_id: Mapped[int | None] = mapped_column(nullable=True)
rating: Mapped[int] = mapped_column(nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
nullable=False, server_default=func.now()
)

View File

@@ -17,7 +17,6 @@ from server.constants import (
get_default_litellm_model,
)
from server.logger import logger
from storage.encrypt_utils import decrypt_legacy_value
from storage.user_settings import UserSettings
from openhands.server.settings import Settings
@@ -216,11 +215,18 @@ class LiteLlmManager:
None,
)
oss_settings.agent = 'CodeActAgent'
# Use the model corresponding to the current user settings version
oss_settings.llm_model = get_default_litellm_model()
oss_settings.llm_api_key = SecretStr(key)
oss_settings.llm_base_url = LITE_LLM_API_URL
oss_settings.update(
{
'agent_settings': {
'agent': 'CodeActAgent',
'llm': {
'model': get_default_litellm_model(),
'api_key': key,
'base_url': LITE_LLM_API_URL,
},
}
}
)
return oss_settings
@staticmethod
@@ -354,12 +360,16 @@ class LiteLlmManager:
# Check if the database key exists in LiteLLM
# If not, generate a new key to prevent verification failures later
db_key = None
if (
user_settings
and user_settings.llm_api_key
and user_settings.llm_base_url == LITE_LLM_API_URL
):
db_key = user_settings.llm_api_key
llm_base_url = None
# agent_settings is a JSON column (dict) on UserSettings
llm_cfg = (
(user_settings.agent_settings or {}).get('llm', {})
if user_settings
else {}
)
llm_base_url = llm_cfg.get('base_url')
if llm_base_url == LITE_LLM_API_URL:
db_key = llm_cfg.get('api_key')
if hasattr(db_key, 'get_secret_value'):
db_key = db_key.get_secret_value()
@@ -392,8 +402,13 @@ class LiteLlmManager:
extra={'org_id': org_id, 'user_id': keycloak_user_id},
)
# Update user_settings with the new key so it gets stored in org_member
user_settings.llm_api_key = SecretStr(new_key)
user_settings.llm_api_key_for_byor = SecretStr(new_key)
# agent_settings is a JSON column (dict) on UserSettings
if user_settings.agent_settings is None:
user_settings.agent_settings = {}
user_settings.agent_settings.setdefault('llm', {})[
'api_key'
] = new_key
user_settings.llm_api_key_for_byor_secret = SecretStr(new_key)
logger.info(
'LiteLlmManager:migrate_lite_llm_entries:complete',
@@ -861,13 +876,6 @@ class LiteLlmManager:
logger.warning('LiteLLM API configuration not found')
return
try:
# Sometimes the key we get is encrypted - attempt to decrypt.
key = decrypt_legacy_value(key)
except Exception:
# The key was not encrypted
pass
payload = {
'key': key,
}

View File

@@ -21,14 +21,7 @@ class Org(Base): # type: ignore
name = Column(String, nullable=False, unique=True)
contact_name = Column(String, nullable=True)
contact_email = Column(String, nullable=True)
agent = Column(String, nullable=True)
default_max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
default_llm_model = Column(String, nullable=True)
default_llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
enable_proactive_conversation_starters = Column(
Boolean, nullable=False, default=True
@@ -36,7 +29,10 @@ class Org(Base): # type: ignore
sandbox_base_container_image = Column(String, nullable=True)
sandbox_runtime_container_image = Column(String, nullable=True)
org_version = Column(Integer, nullable=False, default=0)
mcp_config = Column(JSON, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
conversation_settings = Column(JSON, nullable=False, default=dict)
# encrypted column, don't set directly, set without the underscore
_llm_api_key = Column(String, nullable=True)
# encrypted column, don't set directly, set without the underscore
_search_api_key = Column(String, nullable=True)
# encrypted column, don't set directly, set without the underscore
@@ -45,7 +41,6 @@ class Org(Base): # type: ignore
enable_solvability_analysis = Column(Boolean, nullable=True, default=False)
v1_enabled = Column(Boolean, nullable=True)
conversation_expiration = Column(Integer, nullable=True)
condenser_max_size = Column(Integer, nullable=True)
byor_export_enabled = Column(Boolean, nullable=False, default=False)
sandbox_grouping_strategy = Column(String, nullable=True)
@@ -67,12 +62,21 @@ class Org(Base): # type: ignore
git_claims = relationship('OrgGitClaim', back_populates='org')
def __init__(self, **kwargs):
# Serialize Pydantic model objects to dicts for JSON columns.
from pydantic import BaseModel
for key in ('agent_settings', 'conversation_settings'):
if key in kwargs and isinstance(kwargs[key], BaseModel):
kwargs[key] = kwargs[key].model_dump(mode='json')
# Handle known SQLAlchemy columns directly
for key in list(kwargs):
if hasattr(self.__class__, key):
setattr(self, key, kwargs.pop(key))
# Handle custom property-style fields
if 'llm_api_key' in kwargs:
self.llm_api_key = kwargs.pop('llm_api_key')
if 'search_api_key' in kwargs:
self.search_api_key = kwargs.pop('search_api_key')
if 'sandbox_api_key' in kwargs:
@@ -81,6 +85,18 @@ class Org(Base): # type: ignore
if kwargs:
raise TypeError(f'Unexpected keyword arguments: {list(kwargs.keys())}')
@property
def llm_api_key(self) -> SecretStr | None:
if self._llm_api_key:
decrypted = decrypt_value(self._llm_api_key)
return SecretStr(decrypted)
return None
@llm_api_key.setter
def llm_api_key(self, value: str | SecretStr | None):
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
self._llm_api_key = encrypt_value(raw) if raw else None
@property
def search_api_key(self) -> SecretStr | None:
if self._search_api_key:

View File

@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from storage.org import Org
from storage.user import User
from openhands.utils.jsonpatch_compat import deep_merge
@dataclass
class OrgAppSettingsStore:
@@ -65,8 +67,15 @@ class OrgAppSettingsStore:
"""
if org.org_version < ORG_SETTINGS_VERSION:
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
org.llm_base_url = LITE_LLM_API_URL
org.agent_settings = deep_merge(
org.agent_settings,
{
'llm': {
'model': get_default_litellm_model(),
'base_url': LITE_LLM_API_URL,
},
},
)
await self.db_session.flush()
await self.db_session.refresh(org)

View File

@@ -10,9 +10,10 @@ from server.routes.org_models import OrgLLMSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.org import Org
from storage.org_member_store import OrgMemberStore
from storage.user import User
from openhands.utils.jsonpatch_compat import deep_merge
@dataclass
class OrgLLMSettingsStore:
@@ -49,7 +50,6 @@ class OrgLLMSettingsStore:
) -> Org | None:
"""Update organization LLM settings.
Also propagates relevant settings to all org members.
Uses flush() - commit happens at request end via DbSessionInjector.
Args:
@@ -67,14 +67,16 @@ class OrgLLMSettingsStore:
if not org:
return None
# Apply updates to org (excludes llm_api_key which is member-only)
update_data.apply_to_org(org)
# Propagate relevant settings to all org members
member_updates = update_data.get_member_updates()
if member_updates:
await OrgMemberStore.update_all_members_llm_settings_async(
self.db_session, org_id, member_updates
if update_data.agent_settings_diff:
org.agent_settings = deep_merge(
org.agent_settings,
update_data.agent_settings_diff,
)
if update_data.conversation_settings_diff:
org.conversation_settings = deep_merge(
org.conversation_settings,
update_data.conversation_settings_diff,
)
# flush instead of commit - DbSessionInjector auto-commits at request end

View File

@@ -3,7 +3,7 @@ SQLAlchemy model for Organization-Member relationship.
"""
from pydantic import SecretStr
from sqlalchemy import JSON, UUID, Column, ForeignKey, Integer, String
from sqlalchemy import JSON, UUID, Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from storage.base import Base
from storage.encrypt_utils import decrypt_value, encrypt_value
@@ -18,12 +18,11 @@ class OrgMember(Base): # type: ignore
user_id = Column(UUID(as_uuid=True), ForeignKey('user.id'), primary_key=True)
role_id = Column(Integer, ForeignKey('role.id'), nullable=False)
_llm_api_key = Column(String, nullable=False)
max_iterations = Column(Integer, nullable=True)
llm_model = Column(String, nullable=True)
_llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
has_custom_llm_api_key = Column(Boolean, nullable=False, default=False)
agent_settings_diff = Column(JSON, nullable=False, default=dict)
conversation_settings_diff = Column(JSON, nullable=False, default=dict)
status = Column(String, nullable=True)
mcp_config = Column(JSON, nullable=True)
# Relationships
org = relationship('Org', back_populates='org_members')

View File

@@ -2,20 +2,20 @@
Store class for managing organization-member relationships.
"""
from typing import Optional
from typing import Any, Optional
from uuid import UUID
from server.routes.org_models import OrgMemberLLMSettings
from sqlalchemy import func, select, update
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from storage.database import a_session_maker
from storage.encrypt_utils import encrypt_value
from storage.org_member import OrgMember
from storage.user import User
from storage.user_settings import UserSettings
from openhands.storage.data_models.settings import Settings
from openhands.utils.jsonpatch_compat import deep_merge
class OrgMemberStore:
@@ -28,9 +28,8 @@ class OrgMemberStore:
role_id: int,
llm_api_key: str,
status: Optional[str] = None,
llm_model: Optional[str] = None,
llm_base_url: Optional[str] = None,
max_iterations: Optional[int] = None,
agent_settings_diff: Optional[dict[str, Any]] = None,
conversation_settings_diff: Optional[dict[str, Any]] = None,
) -> OrgMember:
"""Add a user to an organization with a specific role."""
async with a_session_maker() as session:
@@ -40,9 +39,8 @@ class OrgMemberStore:
role_id=role_id,
llm_api_key=llm_api_key,
status=status,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
agent_settings_diff=dict(agent_settings_diff or {}),
conversation_settings_diff=dict(conversation_settings_diff or {}),
)
session.add(org_member)
await session.commit()
@@ -149,22 +147,22 @@ class OrgMemberStore:
return True
@staticmethod
def get_kwargs_from_settings(settings: Settings):
kwargs = {
normalized: getattr(settings, normalized)
for c in OrgMember.__table__.columns
if (normalized := c.name.lstrip('_')) and hasattr(settings, normalized)
def get_kwargs_from_settings(settings: Settings) -> dict[str, Any]:
"""Return kwargs for OrgMember construction (keys match column names)."""
return {
'llm_api_key': settings.agent_settings.llm.api_key,
'agent_settings_diff': {},
'conversation_settings_diff': {},
}
return kwargs
@staticmethod
def get_kwargs_from_user_settings(user_settings: UserSettings):
kwargs = {
normalized: getattr(user_settings, normalized)
for c in OrgMember.__table__.columns
if (normalized := c.name.lstrip('_')) and hasattr(user_settings, normalized)
def get_kwargs_from_user_settings(user_settings: UserSettings) -> dict[str, Any]:
"""Return kwargs for OrgMember construction (keys match column names)."""
return {
'llm_api_key': user_settings.llm_api_key,
'agent_settings_diff': dict(user_settings.agent_settings),
'conversation_settings_diff': dict(user_settings.conversation_settings),
}
return kwargs
@staticmethod
async def get_org_members_count(
@@ -244,21 +242,41 @@ class OrgMemberStore:
org_id: UUID,
member_settings: OrgMemberLLMSettings,
) -> None:
"""Update LLM settings for all members of an organization.
"""Update shared LLM settings for all members of an organization.
Args:
session: Database session (passed from caller for transaction)
org_id: Organization ID
member_settings: Typed LLM settings to apply to all members
member_settings: Shared settings to apply to all members
"""
# Build update values from non-None fields
values = member_settings.model_dump(exclude_none=True)
if not values:
return
# Handle encrypted llm_api_key field - map to _llm_api_key column with encryption
if 'llm_api_key' in values:
raw_key = values.pop('llm_api_key')
values['_llm_api_key'] = encrypt_value(raw_key)
result = await session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
)
org_members = list(result.scalars().all())
if values:
stmt = update(OrgMember).where(OrgMember.org_id == org_id).values(**values)
await session.execute(stmt)
raw_key = values.pop('llm_api_key', None)
agent_settings_diff = values.pop('agent_settings_diff', None)
conversation_settings_diff = values.pop('conversation_settings_diff', None)
for org_member in org_members:
if raw_key is not None:
org_member.llm_api_key = raw_key
if agent_settings_diff is not None:
org_member.agent_settings_diff = deep_merge(
org_member.agent_settings_diff,
agent_settings_diff,
)
if conversation_settings_diff is not None:
org_member.conversation_settings_diff = deep_merge(
org_member.conversation_settings_diff,
conversation_settings_diff,
)
for key, value in values.items():
setattr(org_member, key, value)

View File

@@ -25,6 +25,7 @@ from storage.role_store import RoleStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.sdk.settings import AgentSettings, ConversationSettings
from openhands.storage.data_models.settings import Settings
@@ -107,13 +108,16 @@ class OrgService:
Returns:
Org: New organization entity (not yet persisted)
"""
default_agent_settings = AgentSettings()
default_agent_settings.llm.model = get_default_litellm_model()
return Org(
id=org_id,
name=name,
contact_name=contact_name,
contact_email=contact_email,
org_version=ORG_SETTINGS_VERSION,
default_llm_model=get_default_litellm_model(),
agent_settings=default_agent_settings,
conversation_settings=ConversationSettings(),
)
@staticmethod
@@ -467,42 +471,6 @@ class OrgService:
)
return False
@staticmethod
def _get_llm_settings_fields() -> set[str]:
"""
Get the set of organization fields that are considered LLM settings
and require admin/owner role to update.
Returns:
set[str]: Set of field names that require elevated permissions
"""
return {
'default_llm_model',
'default_llm_api_key_for_byor',
'default_llm_base_url',
'search_api_key',
'security_analyzer',
'agent',
'confirmation_mode',
'enable_default_condenser',
'condenser_max_size',
}
@staticmethod
def _has_llm_settings_updates(update_data: OrgUpdate) -> set[str]:
"""
Check if the update contains any LLM settings fields.
Args:
update_data: The organization update data
Returns:
set[str]: Set of LLM fields being updated (empty if none)
"""
llm_fields = OrgService._get_llm_settings_fields()
update_dict = update_data.model_dump(exclude_none=True)
return llm_fields.intersection(update_dict.keys())
@staticmethod
async def update_org_with_permissions(
org_id: UUID,
@@ -571,33 +539,6 @@ class OrgService:
)
raise OrgNameExistsError(update_data.name)
# Check if update contains any LLM settings
llm_fields_being_updated = OrgService._has_llm_settings_updates(update_data)
if llm_fields_being_updated:
# Verify user has admin or owner role
has_permission = await OrgService.has_admin_or_owner_role(user_id, org_id)
if not has_permission:
logger.warning(
'User attempted to update LLM settings without permission',
extra={
'user_id': user_id,
'org_id': str(org_id),
'attempted_fields': list(llm_fields_being_updated),
},
)
raise PermissionError(
'Admin or owner role required to update LLM settings'
)
logger.debug(
'User has permission to update LLM settings',
extra={
'user_id': user_id,
'org_id': str(org_id),
'llm_fields': list(llm_fields_being_updated),
},
)
# Convert to dict for OrgStore (excluding None values)
update_dict = update_data.model_dump(exclude_none=True)
if not update_dict:
@@ -607,6 +548,29 @@ class OrgService:
)
return existing_org
restricted_fields = {
'agent_settings_diff',
'conversation_settings_diff',
'search_api_key',
'sandbox_api_key',
}
if restricted_fields.intersection(
update_dict
) and not await OrgService.has_admin_or_owner_role(user_id, org_id):
logger.warning(
'Insufficient role for restricted organization settings update',
extra={
'user_id': user_id,
'org_id': str(org_id),
'restricted_fields': sorted(
restricted_fields.intersection(update_dict)
),
},
)
raise PermissionError(
'Admin or owner role required to update organization agent settings'
)
# Perform the update
try:
updated_org = await OrgStore.update_org(org_id, update_dict)

View File

@@ -22,12 +22,44 @@ from storage.user import User
from storage.user_settings import UserSettings
from openhands.core.logger import openhands_logger as logger
from openhands.sdk.settings import AgentSettings, ConversationSettings
from openhands.storage.data_models.settings import Settings
from openhands.utils.jsonpatch_compat import deep_merge
_ORG_SETTINGS_EXCLUDED_FIELDS = {
'id',
'name',
'contact_name',
'contact_email',
'org_version',
'llm_api_key',
}
_ORG_SETTINGS_FIELDS = {
normalized
for column in Org.__table__.columns
if (normalized := column.name.lstrip('_')) not in _ORG_SETTINGS_EXCLUDED_FIELDS
}
class OrgStore:
"""Store for managing organizations."""
@staticmethod
def get_agent_settings_from_org(org: Org) -> AgentSettings:
return AgentSettings.model_validate(dict(org.agent_settings))
@staticmethod
def get_conversation_settings_from_org(org: Org) -> ConversationSettings:
return ConversationSettings.model_validate(dict(org.conversation_settings))
@staticmethod
def sync_agent_settings(org: Org) -> None:
org.agent_settings = dict(org.agent_settings)
@staticmethod
def sync_conversation_settings(org: Org) -> None:
org.conversation_settings = dict(org.conversation_settings)
@staticmethod
async def create_org(
kwargs: dict,
@@ -36,7 +68,16 @@ class OrgStore:
async with a_session_maker() as session:
org = Org(**kwargs)
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
agent_settings = org.agent_settings or {}
org.agent_settings = deep_merge(
agent_settings,
{
'llm': {
'model': agent_settings.get('llm', {}).get('model')
or get_default_litellm_model()
}
},
)
if org.v1_enabled is None:
org.v1_enabled = DEFAULT_V1_ENABLED
session.add(org)
@@ -92,8 +133,12 @@ class OrgStore:
org.id,
{
'org_version': ORG_SETTINGS_VERSION,
'default_llm_model': get_default_litellm_model(),
'llm_base_url': LITE_LLM_API_URL,
'agent_settings_diff': {
'llm': {
'model': get_default_litellm_model(),
'base_url': LITE_LLM_API_URL,
},
},
},
)
return org
@@ -180,56 +225,43 @@ class OrgStore:
if 'id' in kwargs:
kwargs.pop('id')
agent_settings_diff = kwargs.pop('agent_settings_diff', None)
conversation_settings_diff = kwargs.pop('conversation_settings_diff', None)
for key, value in kwargs.items():
if hasattr(org, key):
setattr(org, key, value)
if agent_settings_diff is not None:
org.agent_settings = deep_merge(
org.agent_settings,
agent_settings_diff,
)
if conversation_settings_diff is not None:
org.conversation_settings = deep_merge(
org.conversation_settings,
conversation_settings_diff,
)
await session.commit()
await session.refresh(org)
return org
@staticmethod
def get_kwargs_from_settings(settings: Settings):
kwargs = {}
for c in Org.__table__.columns:
# Normalize for lookup
normalized = (
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
)
if not hasattr(settings, normalized):
continue
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
key = c.name
if key.startswith('_'):
key = key[1:] # remove only the very first leading underscore
kwargs[key] = getattr(settings, normalized)
return kwargs
dumped = settings.model_dump(mode='json', context={'expose_secrets': True})
return {
field: dumped[field] for field in _ORG_SETTINGS_FIELDS if field in dumped
}
@staticmethod
def get_kwargs_from_user_settings(user_settings: UserSettings):
kwargs = {}
for c in Org.__table__.columns:
# Normalize for lookup
normalized = (
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
)
if not hasattr(user_settings, normalized):
continue
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
key = c.name
if key.startswith('_'):
key = key[1:] # remove only the very first leading underscore
kwargs[key] = getattr(user_settings, normalized)
kwargs = {
field: getattr(user_settings, field)
for field in _ORG_SETTINGS_FIELDS
if hasattr(user_settings, field)
}
kwargs['org_version'] = user_settings.user_version
return kwargs
@@ -431,8 +463,17 @@ class OrgStore:
if not org:
return None
# Apply updates to org
llm_settings.apply_to_org(org)
if llm_settings.agent_settings_diff is not None:
org.agent_settings = deep_merge(
org.agent_settings,
llm_settings.agent_settings_diff,
)
if llm_settings.conversation_settings_diff is not None:
org.conversation_settings = deep_merge(
org.conversation_settings,
llm_settings.conversation_settings_diff,
)
# Propagate relevant settings to all org members
member_updates = llm_settings.get_member_updates()

View File

@@ -34,10 +34,17 @@ class SaasConversationStore(ConversationStore):
session_maker: sessionmaker
org_id: UUID | None = None # will be fetched automatically
def __init__(self, user_id: str, org_id: UUID, session_maker: sessionmaker):
def __init__(
self,
user_id: str,
org_id: UUID,
session_maker: sessionmaker,
resolver_org_id: UUID | None = None,
):
self.user_id = user_id
self.org_id = org_id
self.session_maker = session_maker
self.resolver_org_id = resolver_org_id
def _select_by_id(self, session, conversation_id: str):
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
@@ -103,6 +110,13 @@ class SaasConversationStore(ConversationStore):
stored_metadata = StoredConversationMetadata(**kwargs)
# Override with resolver org_id if set (from git org claim resolution),
# same pattern as V1's save_app_conversation_info in
# saas_app_conversation_info_injector.py
org_id = self.org_id
if self.resolver_org_id is not None:
org_id = self.resolver_org_id
def _save_metadata():
with self.session_maker() as session:
# Save the main conversation metadata
@@ -122,13 +136,13 @@ class SaasConversationStore(ConversationStore):
saas_metadata = StoredConversationMetadataSaas(
conversation_id=stored_metadata.conversation_id,
user_id=UUID(self.user_id),
org_id=self.org_id,
org_id=org_id,
)
session.add(saas_metadata)
else:
# Validate
expected_user_id = UUID(self.user_id)
expected_org_id = self.org_id
expected_org_id = org_id
if saas_metadata.user_id != expected_user_id:
raise ValueError(
@@ -240,3 +254,19 @@ class SaasConversationStore(ConversationStore):
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker)
@classmethod
async def get_resolver_instance(
cls,
config: OpenHandsConfig,
user_id: str,
resolver_org_id: UUID | None = None,
) -> 'SaasConversationStore':
"""Get a store for resolver conversations with explicit org routing.
Unlike get_instance, this accepts a resolver_org_id that overrides
the user's default org when saving conversation metadata.
"""
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)

View File

@@ -1,23 +1,21 @@
from __future__ import annotations
import binascii
import hashlib
import uuid
from base64 import b64decode, b64encode
from dataclasses import dataclass
from typing import Any
from cryptography.fernet import Fernet
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from server.constants import LITE_LLM_API_URL
from server.logger import logger
from sqlalchemy import select, update
from server.routes.org_models import OrgMemberLLMSettings
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from storage.database import a_session_maker
from storage.encrypt_utils import encrypt_value
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
from storage.org import Org
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.org_store import OrgStore
from storage.user import User
from storage.user_settings import UserSettings
@@ -26,6 +24,7 @@ from storage.user_store import UserStore
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.settings import Settings
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.jsonpatch_compat import deep_merge
from openhands.utils.llm import is_openhands_model
@@ -33,7 +32,6 @@ from openhands.utils.llm import is_openhands_model
class SaasSettingsStore(SettingsStore):
user_id: str
config: OpenHandsConfig
ENCRYPT_VALUES = ['llm_api_key', 'llm_api_key_for_byor', 'search_api_key']
async def _get_user_settings_by_keycloak_id_async(
self, keycloak_user_id: str, session=None
@@ -69,6 +67,19 @@ class SaasSettingsStore(SettingsStore):
)
return result.scalars().first()
@staticmethod
def _get_effective_llm_api_key(
org: Org,
org_member: OrgMember,
) -> SecretStr | None:
if org_member.has_custom_llm_api_key:
return org_member.llm_api_key
return org.llm_api_key or org_member.llm_api_key
@staticmethod
def _get_persisted_agent_settings(item: Settings) -> dict[str, Any]:
return item.agent_settings.model_dump(mode='json')
async def load(self) -> Settings | None:
user = await UserStore.get_user_by_id(self.user_id)
if not user:
@@ -81,7 +92,7 @@ class SaasSettingsStore(SettingsStore):
if om.org_id == org_id:
org_member = om
break
if not org_member or not org_member.llm_api_key:
if not org_member:
return None
org = await OrgStore.get_org_by_id_async(org_id)
if not org:
@@ -89,6 +100,9 @@ class SaasSettingsStore(SettingsStore):
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
)
return None
org_agent_settings = OrgStore.get_agent_settings_from_org(org)
member_agent_settings_diff = dict(org_member.agent_settings_diff)
kwargs = {
**{
normalized: getattr(org, c.name)
@@ -106,26 +120,36 @@ class SaasSettingsStore(SettingsStore):
if (normalized := c.name.lstrip('_')) in Settings.model_fields
},
}
kwargs['llm_api_key'] = org_member.llm_api_key
if org_member.max_iterations:
kwargs['max_iterations'] = org_member.max_iterations
if org_member.llm_model:
kwargs['llm_model'] = org_member.llm_model
if org_member.llm_api_key_for_byor:
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
if org_member.llm_base_url:
kwargs['llm_base_url'] = org_member.llm_base_url
# MCP config is user-specific (stored on org_member, not org)
if org_member.mcp_config is not None:
kwargs['mcp_config'] = org_member.mcp_config
merged_agent_settings = deep_merge(
org_agent_settings.model_dump(mode='json'),
member_agent_settings_diff,
)
effective_llm_api_key = self._get_effective_llm_api_key(org, org_member)
if effective_llm_api_key is not None:
merged_agent_settings.setdefault('llm', {})['api_key'] = (
effective_llm_api_key.get_secret_value()
if isinstance(effective_llm_api_key, SecretStr)
else effective_llm_api_key
)
else:
logger.warning(
f'No effective LLM API key found for user {self.user_id} '
f'in org {org_id} (org key and member key are both unset)'
)
kwargs['agent_settings'] = merged_agent_settings
org_conversation = OrgStore.get_conversation_settings_from_org(org)
member_conversation_diff = dict(org_member.conversation_settings_diff)
kwargs['conversation_settings'] = deep_merge(
org_conversation.model_dump(mode='json'),
member_conversation_diff,
)
if org.v1_enabled is None:
kwargs['v1_enabled'] = True
# Apply default if sandbox_grouping_strategy is None in the database
if kwargs.get('sandbox_grouping_strategy') is None:
kwargs.pop('sandbox_grouping_strategy', None)
settings = Settings(**kwargs)
return settings
return Settings(**kwargs)
async def store(self, item: Settings):
async with a_session_maker() as session:
@@ -170,7 +194,7 @@ class SaasSettingsStore(SettingsStore):
if om.org_id == org_id:
org_member = om
break
if not org_member or not org_member.llm_api_key:
if not org_member:
return None
result = await session.execute(select(Org).filter(Org.id == org_id))
@@ -181,56 +205,88 @@ class SaasSettingsStore(SettingsStore):
)
return None
# Check if we need to generate an LLM key.
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
llm_model = item.agent_settings.llm.model
llm_base_url = item.agent_settings.llm.base_url
normalized_llm_base_url = llm_base_url.rstrip('/') if llm_base_url else None
normalized_managed_base_url = LITE_LLM_API_URL.rstrip('/')
uses_managed_llm_key = (
normalized_llm_base_url == normalized_managed_base_url
or (normalized_llm_base_url is None and is_openhands_model(llm_model))
)
if uses_managed_llm_key:
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
item, str(org_id), openhands_type=is_openhands_model(llm_model)
)
effective_agent_settings_diff = self._get_persisted_agent_settings(item)
org.agent_settings = deep_merge(
OrgStore.get_agent_settings_from_org(org).model_dump(mode='json'),
effective_agent_settings_diff,
)
effective_conversation_diff = item.conversation_settings.model_dump(
mode='json'
)
org.conversation_settings = deep_merge(
OrgStore.get_conversation_settings_from_org(org).model_dump(
mode='json'
),
effective_conversation_diff,
)
kwargs = item.model_dump(context={'expose_secrets': True})
for model in (user, org, org_member):
for key, value in kwargs.items():
# Skip mcp_config for org - it should only be stored on org_member (user-specific)
if key == 'mcp_config' and model is org:
continue
if hasattr(model, key):
setattr(model, key, value)
kwargs.pop('agent_settings', None)
kwargs.pop('conversation_settings', None)
# Map Settings fields to Org fields with 'default_' prefix
# The generic loop above doesn't update these because Org uses
# 'default_llm_model' not 'llm_model', etc.
# Use exclude_unset to only update explicitly-set fields (allows clearing with null)
settings_data = item.model_dump(exclude_unset=True)
if 'llm_model' in settings_data:
org.default_llm_model = settings_data['llm_model']
if 'llm_base_url' in settings_data:
org.default_llm_base_url = settings_data['llm_base_url']
if 'max_iterations' in settings_data:
org.default_max_iterations = settings_data['max_iterations']
for key, value in kwargs.items():
if hasattr(user, key):
setattr(user, key, value)
if hasattr(org, key) and key not in {
'llm_api_key',
'agent_settings',
'conversation_settings',
}:
setattr(org, key, value)
# Propagate LLM settings to all org members
# This ensures all members see the same LLM configuration when an admin saves
# Note: Concurrent saves by multiple admins will result in last-write-wins.
# Consider adding optimistic locking if this becomes a problem.
member_update_values: dict = {}
if item.llm_model is not None:
member_update_values['llm_model'] = item.llm_model
if item.llm_base_url is not None:
member_update_values['llm_base_url'] = item.llm_base_url
if item.max_iterations is not None:
member_update_values['max_iterations'] = item.max_iterations
if item.llm_api_key is not None:
member_update_values['_llm_api_key'] = encrypt_value(
item.llm_api_key.get_secret_value()
)
current_member_llm_api_key = item.agent_settings.llm.api_key
org_default_llm_api_key = org.llm_api_key
org_default_llm_api_key_raw = (
org_default_llm_api_key.get_secret_value()
if org_default_llm_api_key
else None
)
current_member_llm_api_key_raw = (
current_member_llm_api_key.get_secret_value()
if current_member_llm_api_key
else None
)
if member_update_values:
stmt = (
update(OrgMember)
.where(OrgMember.org_id == org_id)
.values(**member_update_values)
)
await session.execute(stmt)
await OrgMemberStore.update_all_members_llm_settings_async(
session,
org_id,
OrgMemberLLMSettings(
agent_settings_diff=effective_agent_settings_diff,
conversation_settings_diff=effective_conversation_diff,
llm_api_key=(
current_member_llm_api_key_raw
if not uses_managed_llm_key
else None
),
),
)
if uses_managed_llm_key and current_member_llm_api_key is not None:
# Managed/proxy key — store on this member but mark as org-managed
org_member.llm_api_key = current_member_llm_api_key
org_member.has_custom_llm_api_key = False
elif current_member_llm_api_key_raw is not None:
# BYOR: member supplied their own (non-managed) API key
org_member.llm_api_key = current_member_llm_api_key
org_member.has_custom_llm_api_key = True
elif org_default_llm_api_key_raw is not None:
# No member key, falling back to org default
org_member.has_custom_llm_api_key = False
await session.commit()
@@ -243,52 +299,6 @@ class SaasSettingsStore(SettingsStore):
logger.debug(f'saas_settings_store.get_instance::{user_id}')
return SaasSettingsStore(user_id, config)
def _should_encrypt(self, key):
return key in self.ENCRYPT_VALUES
def _decrypt_kwargs(self, kwargs: dict):
fernet = self._fernet()
for key, value in kwargs.items():
try:
if value is None:
continue
if self._should_encrypt(key):
if isinstance(value, SecretStr):
value = fernet.decrypt(
b64decode(value.get_secret_value().encode())
).decode()
else:
value = fernet.decrypt(b64decode(value.encode())).decode()
kwargs[key] = value
except binascii.Error:
pass # Key is in legacy format...
def _encrypt_kwargs(self, kwargs: dict):
fernet = self._fernet()
for key, value in kwargs.items():
if value is None:
continue
if isinstance(value, dict):
self._encrypt_kwargs(value)
continue
if self._should_encrypt(key):
if isinstance(value, SecretStr):
value = b64encode(
fernet.encrypt(value.get_secret_value().encode())
).decode()
else:
value = b64encode(fernet.encrypt(value.encode())).decode()
kwargs[key] = value
def _fernet(self):
if not self.config.jwt_secret:
raise ValueError('jwt_secret must be defined on config')
jwt_secret = self.config.jwt_secret.get_secret_value()
fernet_key = b64encode(hashlib.sha256(jwt_secret.encode()).digest())
return Fernet(fernet_key)
async def _ensure_api_key(
self, item: Settings, org_id: str, openhands_type: bool = False
) -> None:
@@ -298,9 +308,11 @@ class SaasSettingsStore(SettingsStore):
is valid in LiteLLM. If valid, reuses it. Otherwise, generates a new key.
"""
llm_api_key = item.agent_settings.llm.api_key
# First, check if our current key is valid
if item.llm_api_key and not await LiteLlmManager.verify_existing_key(
item.llm_api_key.get_secret_value(),
if llm_api_key and not await LiteLlmManager.verify_existing_key(
llm_api_key.get_secret_value(),
self.user_id,
org_id,
openhands_type=openhands_type,
@@ -323,7 +335,7 @@ class SaasSettingsStore(SettingsStore):
None,
)
item.llm_api_key = SecretStr(generated_key)
item.agent_settings.llm.api_key = SecretStr(generated_key)
logger.info(
'saas_settings_store:store:generated_openhands_key',
extra={'user_id': self.user_id},

View File

@@ -1,10 +1,12 @@
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import DECIMAL, Column, DateTime, Enum, Integer, String
from sqlalchemy import DECIMAL, DateTime, Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from storage.base import Base
class SubscriptionAccess(Base): # type: ignore
class SubscriptionAccess(Base):
"""
Represents a user's subscription access record.
Tracks subscription status, duration, payment information, and cancellation status.
@@ -12,8 +14,8 @@ class SubscriptionAccess(Base): # type: ignore
__tablename__ = 'subscription_access'
id = Column(Integer, primary_key=True, autoincrement=True)
status = Column(
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
status: Mapped[str] = mapped_column(
Enum(
'ACTIVE',
'DISABLED',
@@ -22,22 +24,30 @@ class SubscriptionAccess(Base): # type: ignore
nullable=False,
index=True,
)
user_id = Column(String, nullable=False, index=True)
start_at = Column(DateTime(timezone=True), nullable=True)
end_at = Column(DateTime(timezone=True), nullable=True)
amount_paid = Column(DECIMAL(19, 4), nullable=True)
stripe_invoice_payment_id = Column(String, nullable=False)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
stripe_subscription_id = Column(String, nullable=True, index=True)
created_at = Column(
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
start_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
end_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
amount_paid: Mapped[Decimal | None] = mapped_column(DECIMAL(19, 4), nullable=True)
stripe_invoice_payment_id: Mapped[str] = mapped_column(String, nullable=False)
cancelled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
stripe_subscription_id: Mapped[str | None] = mapped_column(
String, nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at = Column(
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
onupdate=lambda: datetime.now(UTC), # type: ignore[attr-defined]
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)

View File

@@ -36,6 +36,7 @@ class User(Base): # type: ignore
git_user_email = Column(String, nullable=True)
sandbox_grouping_strategy = Column(String, nullable=True)
disabled_skills = Column(JSON, nullable=True)
onboarding_completed = Column(Boolean, nullable=True, default=False)
# Relationships
role = relationship('Role', back_populates='users')

View File

@@ -1,6 +1,10 @@
from __future__ import annotations
from pydantic import SecretStr
from server.constants import DEFAULT_BILLING_MARGIN
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Identity, Integer, String
from storage.base import Base
from storage.encrypt_utils import decrypt_legacy_value, encrypt_legacy_value
class UserSettings(Base): # type: ignore
@@ -8,17 +12,11 @@ class UserSettings(Base): # type: ignore
id = Column(Integer, Identity(), primary_key=True)
keycloak_user_id = Column(String, nullable=True, index=True)
language = Column(String, nullable=True)
agent = Column(String, nullable=True)
max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
llm_model = Column(String, nullable=True)
# Deprecated (v0): API keys now live on Org / OrgMember.
# Kept for backward-compat during migration; do not use in new code.
llm_api_key = Column(String, nullable=True)
llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
condenser_max_size = Column(Integer, nullable=True)
user_consents_to_analytics = Column(Boolean, nullable=True)
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
@@ -30,6 +28,7 @@ class UserSettings(Base): # type: ignore
sandbox_grouping_strategy = Column(String, nullable=True)
user_version = Column(Integer, nullable=False, default=0)
accepted_tos = Column(DateTime, nullable=True)
# Deprecated (v0): mcp_config now lives inside AgentSettings on Org / OrgMember.
mcp_config = Column(JSON, nullable=True)
disabled_skills = Column(JSON, nullable=True)
search_api_key = Column(String, nullable=True)
@@ -41,6 +40,38 @@ class UserSettings(Base): # type: ignore
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
v1_enabled = Column(Boolean, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
conversation_settings = Column(JSON, nullable=False, default=dict)
@property
def llm_api_key_for_byor_secret(self) -> SecretStr | None:
raw = self.llm_api_key_for_byor
if not raw:
return None
try:
return SecretStr(decrypt_legacy_value(raw))
except Exception:
return SecretStr(raw)
@llm_api_key_for_byor_secret.setter
def llm_api_key_for_byor_secret(self, value: str | SecretStr | None) -> None:
if value is None:
self.llm_api_key_for_byor = None
return
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
self.llm_api_key_for_byor = encrypt_legacy_value(raw)
already_migrated = Column(
Boolean, nullable=True, default=False
) # False = not migrated, True = migrated
def to_settings(self):
from openhands.sdk.settings import AgentSettings, ConversationSettings
from openhands.storage.data_models.settings import Settings
return Settings(
agent_settings=AgentSettings.model_validate(self.agent_settings or {}),
conversation_settings=ConversationSettings.model_validate(
self.conversation_settings or {}
),
)

View File

@@ -1,6 +1,7 @@
"""Store class for managing users."""
import asyncio
import os
import uuid
from typing import Optional
from uuid import UUID
@@ -24,11 +25,14 @@ from storage.encrypt_utils import (
)
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
from storage.role_store import RoleStore
from storage.user import User
from storage.user_settings import UserSettings
from utils.identity import resolve_display_name
from openhands.sdk.settings import AGENT_SETTINGS_SCHEMA_VERSION
# The max possible time to wait for another process to finish creating a user before retrying
_REDIS_CREATE_TIMEOUT_SECONDS = 30
# The delay to wait for another process to finish creating a user before trying to load again
@@ -82,6 +86,9 @@ class UserStore:
)
user.email = user_info.get('email')
user.email_verified = user_info.get('email_verified')
# SaaS consent is implicit via Terms of Service — new SaaS users default to consented
if 'saas' in (os.environ.get('OPENHANDS_CONFIG_CLS', '')).lower():
user.user_consents_to_analytics = True
session.add(user)
role = await RoleStore.get_role_by_name('owner')
@@ -91,9 +98,6 @@ class UserStore:
from storage.org_member_store import OrgMemberStore
org_member_kwargs = OrgMemberStore.get_kwargs_from_settings(settings)
# avoid setting org member llm fields to use org defaults on user creation
del org_member_kwargs['llm_model']
del org_member_kwargs['llm_base_url']
org_member = OrgMember(
org_id=org.id,
user_id=user.id,
@@ -233,10 +237,15 @@ class UserStore:
org_kwargs = OrgStore.get_kwargs_from_user_settings(decrypted_user_settings)
org_kwargs.pop('id', None)
# if user has custom settings, set org defaults to current version
# If the user has custom settings, keep the org defaults minimal.
if custom_settings:
org_kwargs['default_llm_model'] = get_default_litellm_model()
org_kwargs['llm_base_url'] = LITE_LLM_API_URL
org_kwargs['agent_settings'] = {
'schema_version': AGENT_SETTINGS_SCHEMA_VERSION,
'llm': {
'model': get_default_litellm_model(),
'base_url': LITE_LLM_API_URL,
},
}
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
for key, value in org_kwargs.items():
@@ -276,12 +285,10 @@ class UserStore:
org_member_kwargs = OrgMemberStore.get_kwargs_from_user_settings(
decrypted_user_settings
)
# if the user did not have custom settings in the old model,
# then use the org defaults by not setting org_member fields
if not custom_settings:
del org_member_kwargs['llm_model']
del org_member_kwargs['llm_base_url']
org_member_kwargs['agent_settings_diff'] = (
OrgStore.get_agent_settings_from_org(org).model_dump(mode='json')
)
org_member = OrgMember(
org_id=org.id,
@@ -749,6 +756,65 @@ class UserStore:
await session.refresh(user)
return user
@staticmethod
async def mark_onboarding_completed(user_id: str) -> Optional[User]:
"""Mark the user's onboarding as completed.
Args:
user_id: The user's ID (Keycloak user ID)
Returns:
User: The updated user object, or None if user not found
"""
async with a_session_maker() as session:
result = await session.execute(
select(User).filter(User.id == uuid.UUID(user_id)).with_for_update()
)
user = result.scalars().first()
if not user:
logger.warning(
'mark_onboarding_completed:user_not_found',
extra={'user_id': user_id},
)
return None
user.onboarding_completed = True
await session.commit()
await session.refresh(user)
logger.info(
'mark_onboarding_completed:success',
extra={'user_id': user_id},
)
return user
@staticmethod
async def get_first_owner_in_org(org_id: UUID) -> Optional[User]:
"""Get the first owner in an organization who accepted the Terms of Service.
This user is considered the super admin for that org in self-hosted deployments.
The super admin is identified as the owner with the earliest accepted_tos timestamp.
Args:
org_id: The organization UUID
Returns:
User: The first owner to accept TOS in this org, or None if not found.
"""
async with a_session_maker() as session:
result = await session.execute(
select(User)
.join(OrgMember, OrgMember.user_id == User.id)
.join(Role, Role.id == OrgMember.role_id)
.filter(
OrgMember.org_id == org_id,
Role.name == 'owner',
User.accepted_tos.isnot(None),
)
.order_by(User.accepted_tos.asc())
.limit(1)
)
return result.scalars().first()
@staticmethod
async def backfill_contact_name(user_id: str, user_info: dict) -> None:
"""Update contact_name on the personal org if it still has a username-style value.
@@ -951,44 +1017,30 @@ class UserStore:
Returns:
A new UserSettings object populated from the entities
"""
# Mapping from OrgMember fields to corresponding Org "default_" fields
org_member_to_org_default = {
'llm_model': 'default_llm_model',
'llm_base_url': 'default_llm_base_url',
'max_iterations': 'default_max_iterations',
from storage.org_store import OrgStore
member_agent_settings_diff = dict(org_member.agent_settings_diff)
org_agent_settings = OrgStore.get_agent_settings_from_org(org)
agent_settings = {
**org_agent_settings.model_dump(mode='json'),
**member_agent_settings_diff,
}
def get_value_with_org_fallback(field_name: str, org_member_value):
"""Get value from OrgMember, falling back to Org default if None."""
if org_member_value is not None:
return org_member_value
org_default_field = org_member_to_org_default.get(field_name)
if org_default_field and hasattr(org, org_default_field):
return getattr(org, org_default_field)
return None
# Get values from OrgMember with Org fallback for fields with default_ prefix
llm_model = get_value_with_org_fallback('llm_model', org_member.llm_model)
llm_base_url = get_value_with_org_fallback(
'llm_base_url', org_member.llm_base_url
)
max_iterations = get_value_with_org_fallback(
'max_iterations', org_member.max_iterations
)
member_conversation_settings_diff = dict(org_member.conversation_settings_diff)
org_conversation_settings = OrgStore.get_conversation_settings_from_org(org)
conversation_settings = {
**org_conversation_settings.model_dump(mode='json'),
**member_conversation_settings_diff,
}
return UserSettings(
keycloak_user_id=user_id,
# OrgMember fields
llm_api_key=org_member.llm_api_key.get_secret_value()
if org_member.llm_api_key
else None,
llm_api_key_for_byor=org_member.llm_api_key_for_byor.get_secret_value()
if org_member.llm_api_key_for_byor
else None,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
# User fields
accepted_tos=user.accepted_tos,
enable_sound_notifications=user.enable_sound_notifications,
language=user.language,
@@ -997,18 +1049,12 @@ class UserStore:
email_verified=user.email_verified,
git_user_name=user.git_user_name,
git_user_email=user.git_user_email,
# Org fields
agent=org.agent,
security_analyzer=org.security_analyzer,
confirmation_mode=org.confirmation_mode,
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
enable_default_condenser=org.enable_default_condenser,
billing_margin=org.billing_margin,
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters,
sandbox_base_container_image=org.sandbox_base_container_image,
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
user_version=org.org_version,
mcp_config=org.mcp_config,
search_api_key=org.search_api_key.get_secret_value()
if org.search_api_key
else None,
@@ -1018,7 +1064,9 @@ class UserStore:
max_budget_per_task=org.max_budget_per_task,
enable_solvability_analysis=org.enable_solvability_analysis,
v1_enabled=org.v1_enabled,
condenser_max_size=org.condenser_max_size,
sandbox_grouping_strategy=org.sandbox_grouping_strategy,
agent_settings=agent_settings,
conversation_settings=conversation_settings,
already_migrated=False,
)
@@ -1036,15 +1084,17 @@ class UserStore:
Returns:
True if user has custom settings, False if using old defaults
"""
# Normalize values
user_model = (
user_settings.llm_model.strip() or None if user_settings.llm_model else None
)
user_base_url = (
user_settings.llm_base_url.strip() or None
if user_settings.llm_base_url
else None
)
persisted_agent_settings = user_settings.agent_settings or {}
llm_settings = persisted_agent_settings.get('llm', {})
if isinstance(llm_settings, dict):
user_model = llm_settings.get('model')
user_base_url = llm_settings.get('base_url')
else:
user_model = None
user_base_url = None
user_model = user_model.strip() or None if user_model else None
user_base_url = user_base_url.strip() or None if user_base_url else None
# Custom base_url = definitely custom settings (BYOK)
if user_base_url and user_base_url != LITE_LLM_API_URL:

View File

@@ -1,3 +1,4 @@
import os
import uuid
from datetime import datetime
from uuid import UUID
@@ -37,6 +38,20 @@ from storage.stored_conversation_metadata_saas import (
from storage.stored_offline_token import StoredOfflineToken
from storage.stripe_customer import StripeCustomer
from storage.user import User
from storage.user_settings import UserSettings # noqa: F401
@pytest.fixture(autouse=True)
def allow_short_context_windows():
old = os.environ.get('ALLOW_SHORT_CONTEXT_WINDOWS')
os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = 'true'
try:
yield
finally:
if old is None:
os.environ.pop('ALLOW_SHORT_CONTEXT_WINDOWS', None)
else:
os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = old
@pytest.fixture
@@ -172,7 +187,6 @@ def add_minimal_fixtures(session_maker):
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
name='mock-org',
org_version=ORG_SETTINGS_VERSION,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
)

View File

@@ -88,6 +88,7 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -144,6 +145,7 @@ class TestGithubViewV1InitialUserMessage:
]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -200,6 +202,7 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = []
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)

View File

@@ -206,7 +206,7 @@ def new_conversation_view(
sample_webhook_payload, sample_user_auth, sample_jira_user, sample_jira_workspace
):
"""JiraNewConversationView instance for testing"""
return JiraNewConversationView(
view = JiraNewConversationView(
payload=sample_webhook_payload,
saas_user_auth=sample_user_auth,
jira_user=sample_jira_user,
@@ -215,6 +215,8 @@ def new_conversation_view(
conversation_id='conv-123',
_decrypted_api_key='decrypted_key',
)
view.v1_enabled = False
return view
@pytest.fixture

View File

@@ -202,14 +202,10 @@ class TestStartJob:
)
jira_manager._send_comment = AsyncMock()
with patch(
'integrations.jira.jira_manager.register_callback_processor'
) as mock_register:
await jira_manager.start_job(new_conversation_view)
await jira_manager.start_job(new_conversation_view)
new_conversation_view.create_or_update_conversation.assert_called_once()
mock_register.assert_called_once()
jira_manager._send_comment.assert_called_once()
new_conversation_view.create_or_update_conversation.assert_called_once()
jira_manager._send_comment.assert_called_once()
@pytest.mark.asyncio
async def test_start_job_missing_settings_error(

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