Compare commits

..

232 Commits

Author SHA1 Message Date
openhands
678b5c727b feat(github): add optional Laminar observability for @openhands resolver
This adds optional Laminar tracing to the GitHub integration, allowing
users to observe and analyze how the @openhands bot processes GitHub
issues and PRs.

Changes:
- Add LAMINAR_ENABLED and LAMINAR_PROJECT_API_KEY config in utils.py
- Initialize Laminar in start_job() when LMNR_PROJECT_API_KEY is set
- Create a 'github-resolver' span with metadata (source, repo, issue_number, username)
- Flush traces at end of job execution
- All Laminar operations are wrapped in try/except to prevent failures from affecting core functionality

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 23:12:59 +00: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
Jathin Sreenivas
0731e8c68a feat(frontend): Display LLM model on conversation cards and header (#13616)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-03 17:57:37 +07:00
Tim O'Farrell
0a9570eea2 APP-1197 Consolidate health routes to app_server package (#13724)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-04-02 21:34:40 -06:00
Rohit Malhotra
c00f90bf86 feat: add tags storage for conversation metadata (#13680)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 00:54:27 +00:00
aivong-openhands
1bbf699498 Add Laminar redirect URI to Keycloak allhands client (#13666)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-02 14:15:59 -05:00
Rohit Malhotra
f76517732d Add git to app container runtime dependencies (#13715)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-02 14:43:23 -04:00
Hiep Le
7bb567734d feat(frontend): replace mocked git conversation routing with real API integration (#13698) 2026-04-03 01:05:28 +07:00
aivong-openhands
45f0c77f36 Fix CVE-2026-33699: Update pypdf to 6.9.2 (#13689)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-02 11:14:39 -05:00
dependabot[bot]
fe3d33f222 chore(deps): bump the security-all group across 1 directory with 2 updates (#13706)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 10:57:05 -05:00
dependabot[bot]
2b53d44c2a chore(deps): bump the security-all group across 1 directory with 1 update (#13607)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-04-02 10:32:36 -04:00
dependabot[bot]
0541cb58b2 chore(deps): bump dawidd6/action-download-artifact from 6 to 15 (#13001)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-04-02 09:55:12 -04:00
Hiep Le
5d593ca6e4 feat(backend): add API endpoints to claim and disconnect git organizations (#13683) 2026-04-02 12:35:30 +07:00
Jamie Chicago
2158e30e87 Fix README intro link formatting (#13695)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-02 02:32:01 +02:00
aivong-openhands
7b4ae66e5a fix: upgrade pip to fix CVE-2025-8869 (#13640)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-04-01 16:53:11 -05:00
Graham Neubig
3e1e8f00f7 refactor: single source of truth for verified models (#13421)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Juan Michelini <juan@juan.com.uy>
2026-04-01 18:00:29 -03:00
Joe Laverty
74a69b2dcc ci: add cloud-semver tag support for enterprise image (#13687) 2026-04-01 14:50:15 -04:00
mamoodi
fc36913518 ci: skip PyPI release for cloud- tags (#13686)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-01 13:18:51 -04:00
Engel Nyst
c788674b41 fix: remove resolver summary language hint (#13684)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-01 16:35:28 +02:00
dependabot[bot]
849548a132 chore(deps): bump actions/stale from 9 to 10 (#12261)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2026-03-31 16:34:21 -04:00
dependabot[bot]
c73e22d7cd chore(deps): bump actions/download-artifact from 6 to 7 (#12260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:25:58 -04:00
dependabot[bot]
6304f9f4c5 chore(deps): bump actions/checkout from 4 to 6 (#12259)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:25:24 -04:00
dependabot[bot]
93be4d9d0b chore(deps): bump peter-evans/find-comment from 3 to 4 (#12190)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:23:51 -04:00
Hiep Le
ec66250e74 feat(backend): develop api to retrieve git organizations for the current organization (#13676) 2026-04-01 01:31:14 +07:00
Engel Nyst
dbd199e77c Validate selected branch names before checkout (#13667)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 18:21:21 +02:00
Jamie Chicago
f0c454caf1 Improve README trusted-by logos across light and dark themes (#13659)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 12:18:24 -04:00
Hiep Le
df3360005c feat(frontend): add Git Conversation Routing section for org claims UI (#13668) 2026-03-31 22:14:45 +07:00
Jamie Chicago
df4fea6aca Revert "[fix] maintainer doc" (#13673) 2026-03-31 11:09:58 -04:00
Hiep Le
2b3868ddc3 feat(frontend): add feature flag for organization claims resolver routing (#13669) 2026-03-31 21:39:36 +07:00
Joe Laverty
e3c9fa9d05 Remove unused KEYCLOAK_PROVIDER_NAME constant (#13663)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 09:46:25 -04:00
Hiep Le
2fec71320a fix(frontend): pin axios version to mitigate supply chain attack (#13670) 2026-03-31 19:29:02 +07:00
Hiep Le
9c0f5d785e fix(backend): persist disabled_skills in SaaS settings store (#13658) 2026-03-31 02:23:08 +07:00
Tim O'Farrell
73ba66faea Handling the new server error event (#13643)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 10:56:37 -06:00
aivong-openhands
a198599d91 docs(AGENTS.md): add guidance to preserve tool versions when regenerating lockfiles (#13561)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:23:39 -04:00
mamoodi
7e20bd51f9 Release 1.6.0 (#13604)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:01:16 -04:00
Hiep Le
b75c83d92a fix(frontend): prevent duplicate payment successful toast after Stripe checkout (#13649) 2026-03-30 22:36:35 +07:00
Hiep Le
5528b01c18 refactor(frontend): replace loading spinner with static icon for task tracking (#13625) 2026-03-30 20:32:11 +07:00
Hiep Le
ed5ab11fcc fix: planning agent auth error due to missing base_url (#13638) 2026-03-30 20:32:02 +07:00
Hiep Le
e1afc95b6c fix(frontend): hide right panel when active tab is unpinned (#13648) 2026-03-30 20:31:48 +07:00
Tim O'Farrell
6dd9046ba2 Fix issue where git setup fails on remote sandboxed when grouping. (#13646)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:58:42 +00:00
Xingyao Wang
9ad47bf43f fix: prevent V0 conversation creation due to settings race condition (#13628)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 13:11:25 +01:00
Jathin Sreenivas
b0d8244ad5 fix(frontend): prevent "Unknown event" shown for actions with empty d… (#13639)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-30 16:49:25 +07:00
Karanja
c210d5294f feat: add /new to slash command menu for V1 conversations (#13599) 2026-03-30 15:39:35 +07:00
Tim O'Farrell
c7190ddb30 APP-1153 Fix for issue where popup menu does not display (#13635)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-28 07:47:13 -06:00
Hiep Le
df64ce9668 fix(frontend): reduce padding and gap for chat status indicator (#13624) 2026-03-28 01:39:02 +07:00
Jamie Chicago
f72a9622f6 [fix] maintainer doc (#13632)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 14:33:48 -04:00
Tim O'Farrell
193eb34dc7 fix(migration): serialize dict to JSON string in migration 103 (#13634)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 18:22:31 +00:00
Hiep Le
87f582db6a fix(frontend): tab icon overflow on mobile devices (#13627) 2026-03-28 00:25:39 +07:00
Hiep Le
4b69370c73 fix(frontend): set max width for toast messages (#13623) 2026-03-28 00:25:26 +07:00
Hiep Le
74ac6e06a1 refactor(frontend): add white background color on learn more button hover (user journey project) (#13621) 2026-03-28 00:25:12 +07:00
Hiep Le
a91dceacfb fix(frontend): add missing border radius to diff view (#13620) 2026-03-28 00:25:01 +07:00
Joe Laverty
98c61e1ee4 feat(enterprise): acquire pg_advisory_lock before running database migrations (#13608) 2026-03-27 23:24:49 +07:00
Tim O'Farrell
3268c29945 APP-1152 Add legacy fallback variable when finding persistence directory (#13629) 2026-03-27 10:18:13 -06:00
Engel Nyst
239e40da75 Fix: restore conversation link in PR bodies created via MCP (#13092)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 11:25:34 -04:00
Jamie Chicago
d190d8ee50 Add trusted-by logos to top of README (#13613)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 15:32:39 +01:00
aivong-openhands
5f064fa88b PLTF-330: log module funcName and lineno in enterprise (#13612)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 09:18:02 -05:00
Vasco Schiavo
8f87ef59c7 feat(frontend): Add view mode toggle (old/diff/new) to file changes viewer (#13519)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-27 19:16:20 +07:00
Vasco Schiavo
fdc6ba82c9 feat(frontend): Display skill ready events as expandable skill list in chat (#13511)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-27 18:57:47 +07:00
Hiep Le
a75038bee0 fix: user does not immediately appear in org after accepting invite in openhands cloud (#13562) 2026-03-27 14:37:38 +07:00
Hiep Le
fbe6eb30cb feat(backend): add organization members financial data endpoint (#13595) 2026-03-27 12:18:46 +07:00
Hiep Le
aeda0ea762 feat(frontend): display toast notification when switching organizations (#13598) 2026-03-27 12:18:17 +07:00
Hiep Le
30b7af31b9 feat(frontend): add contextual info messages on LLM settings page (org project) (#13601) 2026-03-27 12:17:58 +07:00
Hiep Le
05a3916c98 feat(frontend): use LoginCTA in device verify with source-specific Learn more behavior (#13606) 2026-03-27 12:17:38 +07:00
Tim O'Farrell
eba1f60c1d Reduced thrash on sandbox service (#13610)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-26 15:29:59 -06:00
OpenHands Bot
024f4d3326 Bump SDK packages to v1.15.0 (#13602)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-03-26 14:34:17 -06:00
Ray Myers
3e38f13d12 perf: speed up Docker builds — amd64-only PRs, eliminate cross-layer chmod/chown bloat (#13590)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-03-26 11:57:31 -06:00
Tim O'Farrell
8a61fc824b Fix for issue where messages is null and error occurs (#13592)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-26 08:02:46 -06:00
Hiep Le
6794603963 feat(frontend): update settings UI with section headers and dividers (org project) (#13584) 2026-03-26 12:37:53 +07:00
Hiep Le
9be60bc286 fix: make MCP settings user-specific within organization (#13591) 2026-03-26 11:42:08 +07:00
Xingyao Wang
f7b53283b5 fix(frontend): guard against undefined matcher.hooks in hooks modal (#13589)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-25 18:20:46 +00:00
Tim O'Farrell
3cd85a07b7 APP-1093 fix(frontend): display 'Starting' status when server reports STARTING on conversation resume (#13580)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-25 08:55:39 -04:00
Hiep Le
0b935669f3 fix(backend): clean up orphaned Keycloak users on duplicate email rejection (#13495) 2026-03-25 16:46:20 +07:00
Hiep Le
889754abfd fix: use API key's org_id when creating conversations via API key auth (#13568) 2026-03-25 16:46:06 +07:00
Tim O'Farrell
06cd53d752 APP-1113 fix: Increase polling time for SetTitleCallbackProcessor (#13577)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 17:40:40 -06:00
Tim O'Farrell
eb189144f2 APP-1115 Fix for AWS config (Minio) for feature branches (#13579)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 17:40:04 -06:00
statxc
c9b2ce2fb9 feat: add user-configurable enable/disable of default global skills w… (#13046)
Co-authored-by: intelliking <intelliking@users.noreply.github.com>
2026-03-24 14:48:22 -06:00
HeyItsChloe
abdc58cd28 feat(frontend): lead capture form (#13496)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-24 13:41:35 -07:00
aivong-openhands
9f47727da5 PLTF-330: add timestamp to enterprise JSON logger formatter (#13555)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 14:53:14 -05:00
Ash Clarke
19da63aae6 Log all terminal states (error, stuck) in V1 callback processors (#13549)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 13:04:39 -05:00
Rohit Malhotra
f1b65d9534 Rename env name (#13570) 2026-03-24 16:38:49 +00:00
aivong-openhands
3516c3cdbe chore(deps): make pythonnet Windows-only dependency (#13515)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 11:21:25 -05:00
Tim O'Farrell
1f275a7cfe fix: reuse db session in migrate_customer call causing FK violation (#13558)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 21:10:45 -06:00
Tim O'Farrell
ff240c968b fix: add 30s timeout to LiteLlmManager HTTP client (#13557)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 21:43:02 +00:00
aivong-openhands
36039d2bb8 upgrade setuptools in /enterprise for updated wheel CVE-2026-24049 (#13509)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 16:37:20 -05:00
Tim O'Farrell
45529fa451 Added Falsy check for base url (#13553) 2026-03-23 13:06:25 -06:00
Tim O'Farrell
0fc4b0fb55 Add infinite scroll pagination and filesystem storage support to public share page (#13545)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 12:18:07 -06:00
Tim O'Farrell
810fc340fc Fix count endpoint 500 error (#13548)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 17:40:56 +00:00
Tim O'Farrell
33a0f95dac Small typo fix (#13546) 2026-03-23 15:36:17 +00:00
aivong-openhands
bdd0214266 chore: increase dependabot open-pull-requests-limit to 5 (#13538)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:28:32 -05:00
Saurya Velagapudi
7fbb499f03 feat: switch default base image to nikolaik slim variant (#13244)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:26:04 -05:00
aivong-openhands
abbfbda450 chore(frontend): update flatted to 3.4.2 (#13503)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:26:30 -04:00
John-Mason P. Shackelford
7774f43ca1 feat(frontend): Add /launch route for starting conversations with plugins (#12699)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-03-23 15:06:42 +07:00
Vasco Schiavo
b705b015fa fix(frontend): rounded corners on diff viewer bottom in Changes tab (#13521) 2026-03-23 14:06:23 +07:00
Jathin Sreenivas
1581b95ab9 fix(frontend): Ensure error and status messages wrap correctly within containers (#13522)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
2026-03-23 13:55:49 +07:00
aivong-openhands
94b45c6c36 PLTF-327: upgrade enterprise nodejs to v24 LTS (#13507)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 14:42:03 -05:00
dependabot[bot]
cbc380fe49 chore(deps): bump node from 25.2-trixie-slim to 25.8-trixie-slim in /containers/app (#13316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-20 14:40:23 -05:00
Vasco Schiavo
fb776ef650 feat(frontend): Add copy button to code blocks (#13458)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 18:20:25 +07:00
Abi
a75b576f1c fix: treat llm_base_url="" as explicit clear in store_llm_settings (#13471)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:14:15 +01:00
Rohit Malhotra
63956c3292 Fix FastAPI Query parameter validation: lte -> le (#13502)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 20:27:10 -04:00
chuckbutkus
f75141af3e fix: prevent secrets deletion across organizations when storing secrets (#13500)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 19:34:12 -04:00
dependabot[bot]
e4515b21eb chore(deps): bump socket.io-parser from 4.2.5 to 4.2.6 in /frontend in the security-all group across 1 directory (#13474)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 17:28:15 -04:00
aivong-openhands
a8f6a35341 fix: patch GLib CVE-2025-14087 in runtime Docker images (#13403)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 16:21:24 -05:00
Joe Laverty
f706a217d0 fix: Use commit SHA instead of mutable branch tag for enterprise base (#13498) 2026-03-19 16:24:07 -04:00
aivong-openhands
0137201903 fix: remove vulnerable VSCode extensions in build_from_scratch path (#13399)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-19 19:36:22 +00:00
aivong-openhands
49a98885ab chore: Update OpenSSL in Debian images for security patches (#13401)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 14:33:23 -05:00
Hiep Le
38648bddb3 fix(frontend): use correct git path based on sandbox grouping strategy (#13488) 2026-03-20 00:13:02 +07:00
Hiep Le
b44774d2be refactor(frontend): extract AddCreditsModal into separate component file (#13490) 2026-03-20 00:12:48 +07:00
Hiep Le
04330898b6 refactor(frontend): add delay before closing user context menu (#13491) 2026-03-20 00:12:38 +07:00
Chris Bagwell
120fd7516a Fix: Prevent auto-logout on 401 errors in oss mode (#13466) 2026-03-19 16:33:01 +01:00
chuckbutkus
2224127ac3 Fix when budgets are None (#13482)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 10:14:48 -05:00
aivong-openhands
2d1e9fa35b Fix CVE-2026-33123: Update pypdf to 6.9.1 (#13473)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-19 11:05:30 -04:00
MkDev11
0ec962e96b feat: add /clear endpoint for V1 conversations (#12786)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-19 21:13:58 +07:00
Engel Nyst
3a9f00aa37 Keep VSCode accessible when agent errors (#13492)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 14:46:56 +01:00
Hiep Le
e02dbb8974 fix(backend): validate API key org_id during authorization to prevent cross-org access (org project) (#13468) 2026-03-19 16:09:37 +07:00
Hiep Le
8039807c3f fix(frontend): scope organization data queries by organization ID (org project) (#13459) 2026-03-19 14:18:29 +07:00
Saurya Velagapudi
a96760eea7 fix: ensure LiteLLM user exists before generating API keys (#12667)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:16:43 -07:00
Saurya Velagapudi
dcb2e21b87 feat: Auto-forward LLM_* env vars to agent-server and fix host network config (#13192)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:07:19 -07:00
Tim O'Farrell
7edebcbc0c fix: use atomic write in LocalFileStore to prevent race conditions (#13480)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-18 16:49:32 -06:00
HeyItsChloe
abd1f9948f fix: return empty skills list instead of 404 for stopped sandboxes (#13429)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:46:00 -06:00
aivong-openhands
2879e58781 Fix CVE-2026-30922: Update pyasn1 to 0.6.3 (#13452)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-18 16:00:06 -04:00
Rohit Malhotra
1d1ffc2be0 feat(enterprise): Add service API for automation API key creation (#13467)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 19:07:36 +00:00
Hiep Le
db41148396 feat(backend): expose API key org_id via new GET /api/keys/current endpoint (org project) (#13469) 2026-03-19 01:46:23 +07:00
Robert Brennan
39a4ca422f fix: use sentence case for 'Waiting for sandbox' text (#12958)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:42:46 -04:00
Varun Chawla
6d86803f41 Add loading feedback to git changes refresh button (#12792)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-19 01:26:27 +07:00
Jordi Mas
8e0386c416 feat: add Catalan translation (#13299) 2026-03-18 13:17:43 -04:00
Nelson Spence
48cd85e47e fix(security): add sleep to container wait loop (#12869)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:04:36 -04:00
不做了睡大觉
c62b47dcb1 fix: handle empty body in GitHub issue resolver (#13039)
Co-authored-by: User <user@example.com>
2026-03-18 12:36:52 -04:00
Jamie Chicago
eb9a822d4c Update CONTRIBUTING.md (#13463) 2026-03-18 12:10:22 -04:00
Engel Nyst
fb7333aa62 fix: stop calling agent-server /generate_title (#13093)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:10:07 +01:00
aivong-openhands
fb23418803 clarify docstring for provider token reference (#13386)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 12:03:56 -04:00
Xingyao Wang
991585c05d docs: add cross-repo testing skill for SDK ↔ OH Cloud e2e workflow (#13446)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 16:00:23 +00:00
Chris Bagwell
35a40ddee8 fix: handle containers with tagless images in DockerSandboxService (#13238) 2026-03-18 11:55:48 -04:00
Hiep Le
5d1f9f815a fix(frontend): preserve settings page route on browser refresh (org project) (#13462) 2026-03-18 22:50:42 +07:00
Hiep Le
d3bf989e77 feat(frontend): improve conversation access error message with workspace hint (org project) (#13461) 2026-03-18 22:50:30 +07:00
Hiep Le
6589e592e3 feat(frontend): add contextual info messages on LLM settings page (org project) (#13460) 2026-03-18 22:50:16 +07:00
Chris Bagwell
fe4c0569f7 Remove unused WORK_HOSTS_SKILL_FOOTER (#12594) 2026-03-18 21:57:23 +07:00
Xingyao Wang
28ecf06404 Render V1 paired tool summaries (#13451)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 10:52:05 +00:00
dependabot[bot]
26fa1185a4 chore(deps): bump mcp from 1.25.0 to 1.26.0 in the mcp-packages group (#13314)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-17 17:44:35 -05:00
HeyItsChloe
d3a8b037f2 feat(frontend): home page cta (#13339)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-18 03:44:36 +07:00
HeyItsChloe
af1fa8961a feat(frontend): login page cta (#13337)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 03:14:59 +07:00
HeyItsChloe
3b215c4ad1 feat(frontend): context menu cta (#13338)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 02:52:02 +07:00
HeyItsChloe
7516b53f5a feat(frontend): self hosted new user questions (#13367)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-18 02:51:40 +07:00
aivong-openhands
855ef7ba5f PLTF-309: disable budget enforcement when ENABLE_BILLING=false (#13440)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 14:26:13 -05:00
Rohit Malhotra
09ca1b882f (Hotfix): use direct attrib for file download result (#13448) 2026-03-17 14:48:46 -04:00
Jamie Chicago
79cfffce60 docs: Improve Development.md and CONTRIBUTING.md with OS-specific setup guides (#13432)
Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 17:03:33 +01:00
Saurya Velagapudi
b68c75252d Add architecture diagrams explaining system components and WebSocket flow (#12542)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Saurya <saurya@openhands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-17 08:52:40 -07:00
aivong-openhands
d58e12ad74 Fix CVE-2026-27962: Update authlib to 1.6.9 (#13439)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-17 10:13:08 -05:00
Engel Nyst
bd837039dd chore: update skills path comments (#12794) 2026-03-17 10:45:50 -04:00
Kooltek68
8a7779068a docs: fix typo in README.md (#13444) 2026-03-17 10:16:31 -04:00
Neha Prasad
38099934b6 fix : planner PLAN.md rendering and search labels (#13418)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-17 20:59:02 +07:00
Xingyao Wang
75c823c486 feat: expose_secrets param on /users/me + sandbox-scoped secrets API (#13383)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 12:54:57 +00:00
Tim O'Farrell
8941111c4e refactor: use status instead of pod_status in RemoteSandboxService (#13436)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 17:34:27 -06:00
ankit kumar
59dd1da7d6 fix: update deprecated libtmux API calls (#12596)
Co-authored-by: ANKIT <ankit@ANKITs-MacBook-Air.local>
2026-03-16 18:21:05 -04:00
Rohit Malhotra
934fbe93c2 Feat: enterprise banner option during device oauth (#13361)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 18:54:36 +00:00
Xingyao Wang
55e4f07200 fix: add missing params to TestLoadHooksFromWorkspace setup (#13424)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 14:49:36 -04:00
Xingyao Wang
00daaa41d3 feat: Load workspace hooks for V1 conversations and add hooks viewer UI (#12773)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: Alona King <alona@all-hands.dev>
2026-03-17 00:55:23 +08:00
HeyItsChloe
a0e777503e fix(frontend): prevent auto sandbox resume behavior (#13133)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 10:22:23 -06:00
Hiep Le
238cab4d08 fix(frontend): prevent chat message loss during websocket disconnections or page refresh (#13380) 2026-03-16 22:25:44 +07:00
Tim O'Farrell
aec95ecf3b feat(frontend): update stop sandbox dialog to display conversations in sandbox (#13388)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 05:20:10 -06:00
Tim O'Farrell
d591b140c8 feat: Add configurable sandbox reuse with grouping strategies (#11922)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 05:19:31 -06:00
Rohit Malhotra
4dfcd68153 (Hotfix): followup messages for slack conversations (#13411)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-15 14:23:06 -04:00
aivong-openhands
f7ca32126f Fix CVE-2026-32597: Update pyjwt to 2.12.0 (#13405)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-14 09:35:56 -05:00
Hiep Le
c66a112bf5 fix(frontend): add rendering support for GlobObservation and GrepObservation events (#13379) 2026-03-14 19:56:57 +07:00
Ray Myers
a8ff720b40 chore: Update imagemagick in Debian images for security patches (#13397) 2026-03-13 22:48:50 -05:00
chuckbutkus
a14158e818 fix: use query params for file upload path (#13376)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 21:08:23 -04:00
John-Mason P. Shackelford
0c51089ab6 Upgrade the SDK to 1.14.0 (#13398)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 21:07:20 -04:00
chuckbutkus
8189d21445 Fix async call to await return (#13395) 2026-03-13 19:13:18 -04:00
chuckbutkus
b7e5c9d25b Use a flag to indicate if new users should use V1 (#13393)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 22:39:07 +00:00
chuckbutkus
873dc6628f Add Enterprise SSO login button to V1 login page (#13390)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 16:57:34 -04:00
chuckbutkus
f5d0af15d9 Add default initial budget for teams/users (#13389)
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 16:57:03 -04:00
chuckbutkus
922e3a2431 Add AwsSharedEventService for shared conversations (#13141)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 14:32:58 -04:00
Tim O'Farrell
0527c46bba Add sandbox_id__eq filter to AppConversationService search and count methods (#13387)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 11:24:58 -06:00
Hiep Le
b4f00379b8 fix(frontend): auto-scroll not working in Planner tab when plan content updates (#13355) 2026-03-13 23:47:03 +07:00
sp.wack
cd2d0ee9a5 feat(frontend): Organizational support (#9496)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: Abhay Mishra <grabhaymishra@gmail.com>
Co-authored-by: Hyun Han <62870362+smosco@users.noreply.github.com>
Co-authored-by: Nhan Nguyen <nhan13574@gmail.com>
Co-authored-by: Bharath A V <avbharath1221@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: Chloe <chloe@openhands.com>
Co-authored-by: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com>
2026-03-13 23:38:54 +07:00
Tim O'Farrell
8e6d05fc3a Add sandbox_id__eq filter parameter to search/count conversation methods (#13385)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 15:30:16 +00:00
Hiep Le
9d82f97a82 fix(frontend): address the responsive issue on the integrations page (#13354) 2026-03-13 21:28:38 +07:00
Hiep Le
2c7b25ab1c fix(frontend): address the responsive issue on the home page (#13353) 2026-03-13 21:28:15 +07:00
aivong-openhands
e82bf44324 Fix CVE-2025-67221: Update orjson to 3.11.6+ (#13371)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-13 06:58:56 -05:00
Xingyao Wang
8799c07027 fix: add PR creation instructions to V1 issue comment template and fix summary prompt (#13377)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 14:35:22 +08:00
Tim O'Farrell
8b8ed5be96 fix: Revert on_conversation_update to load conversation inside method (#13368)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 19:08:04 -06:00
Tim O'Farrell
c1328f512d Upgrade the SDK to 1.13.0 (#13365) 2026-03-12 13:28:19 -06:00
Tim O'Farrell
e2805dea75 Fix pagination bug in event_service_base.search_events causing duplicate events in exports (#13364)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 12:24:06 -06:00
aivong-openhands
127e611706 Fix GHSA-78cv-mqj4-43f7: Update tornado to 6.5.5 (#13362)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-12 13:22:39 -05:00
Hiep Le
a176a135da fix: sdk conversations not appearing in cloud ui (#13296) 2026-03-12 22:23:08 +07:00
Tim O'Farrell
ab78d7d6e8 fix: Set correct user context in webhook callbacks based on sandbox owner (#13340)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 09:11:35 -06:00
mamoodi
4eb6e4da09 Release 1.5.0 (#13336) 2026-03-11 14:50:13 -04:00
dependabot[bot]
7e66304746 chore(deps): bump pypdf from 6.7.5 to 6.8.0 (#13348)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 12:09:09 -05:00
Graham Neubig
a8b12e8eb8 Remove Common Room sync scripts (#13347)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 10:48:37 -04:00
Xingyao Wang
53bb82fe2e fix: use project_dir consistently for workspace.working_dir, setup.sh, and git hooks (#13329)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 15:26:34 +08:00
Tim O'Farrell
db40eb1e94 Using the web_url where it is configured rather than the request.url (#13319)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-10 13:11:33 -06:00
Hiep Le
debbaae385 fix(backend): inherit organization llm settings for new members (#13330) 2026-03-11 01:28:46 +07:00
828 changed files with 67330 additions and 23258 deletions

View File

@@ -0,0 +1,202 @@
---
name: cross-repo-testing
description: This skill should be used when the user asks to "test a cross-repo feature", "deploy a feature branch to staging", "test SDK against OH Cloud", "e2e test a cloud workspace feature", "test provider tokens", "test secrets inheritance", or when changes span the SDK and OpenHands server repos and need end-to-end validation against a staging deployment.
triggers:
- cross-repo
- staging deployment
- feature branch deploy
- test against cloud
- e2e cloud
---
# Cross-Repo Testing: SDK ↔ OpenHands Cloud
How to end-to-end test features that span `OpenHands/software-agent-sdk` and `OpenHands/OpenHands` (the Cloud backend).
## Repository Map
| Repo | Role | What lives here |
|------|------|-----------------|
| [`software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Agent core | `openhands-sdk`, `openhands-workspace`, `openhands-tools` packages. `OpenHandsCloudWorkspace` lives here. |
| [`OpenHands`](https://github.com/OpenHands/OpenHands) | Cloud backend | FastAPI server (`openhands/app_server/`), sandbox management, auth, enterprise integrations. Deployed as OH Cloud. |
| [`deploy`](https://github.com/OpenHands/deploy) | Infrastructure | Helm charts + GitHub Actions that build the enterprise Docker image and deploy to staging/production. |
**Data flow:** SDK client → OH Cloud API (`/api/v1/...`) → sandbox agent-server (inside runtime container)
## When You Need This
There are **two flows** depending on which direction the dependency goes:
| Flow | When | Example |
|------|------|---------|
| **A — SDK client → new Cloud API** | The SDK calls an API that doesn't exist yet on production | `workspace.get_llm()` calling `GET /api/v1/users/me?expose_secrets=true` |
| **B — OH server → new SDK code** | The Cloud server needs unreleased SDK packages or a new agent-server image | Server consumes a new tool, agent behavior, or workspace method from the SDK |
Flow A only requires deploying the server PR. Flow B requires pinning the SDK to an unreleased commit in the server PR **and** using the SDK PR's agent-server image. Both flows may apply simultaneously.
---
## Flow A: SDK Client Tests Against New Cloud API
Use this when the SDK calls an endpoint that only exists on the server PR branch.
### A1. Write and test the server-side changes
In the `OpenHands` repo, implement the new API endpoint(s). Run unit tests:
```bash
cd OpenHands
poetry run pytest tests/unit/app_server/test_<relevant>.py -v
```
Push a PR. Wait for the **"Push Enterprise Image" (Docker) CI job** to succeed — this builds `ghcr.io/openhands/enterprise-server:sha-<COMMIT>`.
### A2. Write the SDK-side changes
In `software-agent-sdk`, implement the client code (e.g., new methods on `OpenHandsCloudWorkspace`). Run SDK unit tests:
```bash
cd software-agent-sdk
pip install -e openhands-sdk -e openhands-workspace
pytest tests/ -v
```
Push a PR. SDK CI is independent — it doesn't need the server changes to pass unit tests.
### A3. Deploy the server PR to staging
See [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) below.
### A4. Run the SDK e2e test against staging
See [Running E2E Tests Against Staging](#running-e2e-tests-against-staging) below.
---
## Flow B: OH Server Needs Unreleased SDK Code
Use this when the Cloud server depends on SDK changes that haven't been released to PyPI yet. The server's runtime containers run the `agent-server` image built from the SDK repo, so the server PR must be configured to use the SDK PR's image and packages.
### B1. Get the SDK PR merged (or identify the commit)
The SDK PR must have CI pass so its agent-server Docker image is built. The image is tagged with the **merge-commit SHA** from GitHub Actions — NOT the head-commit SHA shown in the PR.
Find the correct image tag:
- Check the SDK PR description for an `AGENT_SERVER_IMAGES` section
- Or check the "Consolidate Build Information" CI job for `"short_sha": "<tag>"`
### B2. Pin SDK packages to the commit in the OpenHands PR
In the `OpenHands` repo PR, pin all 3 SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`) to the unreleased commit and update the agent-server image tag. This involves editing 3 files and regenerating 3 lock files.
Follow the **`update-sdk` skill** → "Development: Pin SDK to an Unreleased Commit" section for the full procedure and file-by-file instructions.
### B3. Wait for the OpenHands enterprise image to build
Push the pinned changes. The OpenHands CI will build a new enterprise Docker image (`ghcr.io/openhands/enterprise-server:sha-<OH_COMMIT>`) that bundles the unreleased SDK. Wait for the "Push Enterprise Image" job to succeed.
### B4. Deploy and test
Follow [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) using the new OpenHands commit SHA.
### B5. Before merging: remove the pin
**CI guard:** `check-package-versions.yml` blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields. Before the OpenHands PR can merge, the SDK PR must be merged and released to PyPI, then the pin must be replaced with the released version number.
---
## Deploying to a Staging Feature Environment
The `deploy` repo creates preview environments from OpenHands PRs.
**Option A — GitHub Actions UI (preferred):**
Go to `OpenHands/deploy` → Actions → "Create OpenHands preview PR" → enter the OpenHands PR number. This creates a branch `ohpr-<PR>-<random>` and opens a deploy PR.
**Option B — Update an existing feature branch:**
```bash
cd deploy
git checkout ohpr-<PR>-<random>
# In .github/workflows/deploy.yaml, update BOTH:
# OPENHANDS_SHA: "<full-40-char-commit>"
# OPENHANDS_RUNTIME_IMAGE_TAG: "<same-commit>-nikolaik"
git commit -am "Update OPENHANDS_SHA to <commit>" && git push
```
**Before updating the SHA**, verify the enterprise Docker image exists:
```bash
gh api repos/OpenHands/OpenHands/actions/runs \
--jq '.workflow_runs[] | select(.head_sha=="<COMMIT>") | "\(.name): \(.conclusion)"' \
| grep Docker
# Must show: "Docker: success"
```
The deploy CI auto-triggers and creates the environment at:
```
https://ohpr-<PR>-<random>.staging.all-hands.dev
```
**Wait for it to be live:**
```bash
curl -s -o /dev/null -w "%{http_code}" https://ohpr-<PR>-<random>.staging.all-hands.dev/api/v1/health
# 401 = server is up (auth required). DNS may take 1-2 min on first deploy.
```
## Running E2E Tests Against Staging
**Critical: Feature deployments have their own Keycloak instance.** API keys from `app.all-hands.dev` or `$OPENHANDS_API_KEY` will NOT work. You need a test API key issued by the specific feature deployment's Keycloak.
**You (the agent) cannot obtain this key yourself** — the feature environment requires interactive browser login with credentials you do not have. You must **ask the user** to:
1. Log in to the feature deployment at `https://ohpr-<PR>-<random>.staging.all-hands.dev` in their browser
2. Generate a test API key from the UI
3. Provide the key to you so you can proceed with e2e testing
Do **not** attempt to log in via the browser or guess credentials. Wait for the user to supply the key before running any e2e tests.
```python
from openhands.workspace import OpenHandsCloudWorkspace
STAGING = "https://ohpr-<PR>-<random>.staging.all-hands.dev"
with OpenHandsCloudWorkspace(
cloud_api_url=STAGING,
cloud_api_key="<test-api-key-for-this-deployment>",
) as workspace:
# Test the new feature
llm = workspace.get_llm()
secrets = workspace.get_secrets()
print(f"LLM: {llm.model}, secrets: {list(secrets.keys())}")
```
Or run an example script:
```bash
OPENHANDS_CLOUD_API_KEY="<key>" \
OPENHANDS_CLOUD_API_URL="https://ohpr-<PR>-<random>.staging.all-hands.dev" \
python examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py
```
### Recording results
Both repos support a `.pr/` directory for temporary PR artifacts (design docs, test logs, scripts). These files are automatically removed when the PR is approved — see `.github/workflows/pr-artifacts.yml` and the "PR-Specific Artifacts" section in each repo's `AGENTS.md`.
Push test output to the `.pr/logs/` directory of whichever repo you're working in:
```bash
mkdir -p .pr/logs
python test_script.py 2>&1 | tee .pr/logs/<test_name>.log
git add -f .pr/logs/
git commit -m "docs: add e2e test results" && git push
```
Comment on **both PRs** with pass/fail summary and link to logs.
## Key Gotchas
| Gotcha | Details |
|--------|---------|
| **Feature env auth is isolated** | Each `ohpr-*` deployment has its own Keycloak. Production API keys don't work. Agents cannot log in — you must ask the user to provide a test API key from the feature deployment's UI. |
| **Two SHAs in deploy.yaml** | `OPENHANDS_SHA` and `OPENHANDS_RUNTIME_IMAGE_TAG` must both be updated. The runtime tag is `<sha>-nikolaik`. |
| **Enterprise image must exist** | The Docker CI job on the OpenHands PR must succeed before you can deploy. If it hasn't run, push an empty commit to trigger it. |
| **DNS propagation** | First deployment of a new branch takes 1-2 min for DNS. Subsequent deploys are instant. |
| **Merge-commit SHA ≠ head SHA** | SDK CI tags Docker images with GitHub Actions' merge-commit SHA, not the PR head SHA. Check the SDK PR description or CI logs for the correct tag. |
| **SDK pin blocks merge** | `check-package-versions.yml` prevents merging an OpenHands PR that has `rev` fields in `[tool.poetry.dependencies]`. The SDK must be released to PyPI first. |
| **Flow A: stock agent-server is fine** | When only the Cloud API changes, `OpenHandsCloudWorkspace` talks to the Cloud server, not the agent-server. No custom image needed. |
| **Flow B: agent-server image is required** | When the server needs new SDK code inside runtime containers, you must pin to the SDK PR's agent-server image. |

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

@@ -4,7 +4,7 @@ updates:
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
open-pull-requests-limit: 5
groups:
# put packages in their own group if they have a history of breaking the build or needing to be reverted
pre-commit:
@@ -29,7 +29,7 @@ updates:
directory: "/frontend"
schedule:
interval: "daily"
open-pull-requests-limit: 1
open-pull-requests-limit: 5
groups:
docusaurus:
patterns:
@@ -51,7 +51,7 @@ updates:
schedule:
interval: "weekly"
day: "wednesday"
open-pull-requests-limit: 1
open-pull-requests-limit: 5
groups:
docusaurus:
patterns:
@@ -72,9 +72,11 @@ updates:
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
- package-ecosystem: "docker"
directories:
- "containers/*"
schedule:
interval: "weekly"
open-pull-requests-limit: 5

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

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
@@ -34,7 +34,7 @@ jobs:
fi
- name: Find Comment
uses: peter-evans/find-comment@v3
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}

View File

@@ -17,18 +17,20 @@ 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]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v4
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

@@ -21,18 +21,20 @@ 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]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v4
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,37 +30,42 @@ 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 }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
platforms="linux/amd64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
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 }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
needs: define-matrix
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -82,12 +87,12 @@ jobs:
- name: Build and push app image
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push -p ${{ needs.define-matrix.outputs.platforms }}
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
@@ -98,7 +103,7 @@ jobs:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -117,7 +122,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
@@ -136,7 +141,7 @@ jobs:
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
@@ -144,7 +149,7 @@ jobs:
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
- 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 }}
@@ -158,7 +163,7 @@ jobs:
# 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
@@ -171,7 +176,7 @@ jobs:
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: read
packages: write
@@ -180,7 +185,7 @@ jobs:
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -210,6 +215,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=match,pattern=cloud-\d+\.\d+\.\d+
flavor: |
latest=auto
prefix=
@@ -219,13 +225,11 @@ jobs:
- name: Determine app image tag
shell: bash
run: |
# Duplicated with build.sh
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
# Use the commit SHA to pin the exact app image built by ghcr_build_app,
# 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
@@ -244,7 +248,7 @@ jobs:
# 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!"
@@ -253,10 +257,10 @@ jobs:
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
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get short SHA
id: short_sha

View File

@@ -9,12 +9,12 @@ 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
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -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,12 +59,12 @@ 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
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -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@v4
- 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@v4
- 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@v4
- 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
@@ -27,7 +27,7 @@ jobs:
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 2 # Need previous commit to compare
@@ -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:
@@ -63,7 +63,7 @@ jobs:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -86,7 +86,7 @@ jobs:
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

136
.github/workflows/pr-artifacts.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
---
name: PR Artifacts
on:
workflow_dispatch: # Manual trigger for testing
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
pull_request_review:
types: [submitted]
jobs:
# Auto-remove .pr/ directory when a reviewer approves
cleanup-on-approval:
concurrency:
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
cancel-in-progress: false
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check if fork PR
id: check-fork
run: |
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
echo "is_fork=true" >> $GITHUB_OUTPUT
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
else
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@v5
if: steps.check-fork.outputs.is_fork == 'false'
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
- name: Remove .pr/ directory
id: remove
if: steps.check-fork.outputs.is_fork == 'false'
run: |
if [ -d ".pr" ]; then
git config user.name "allhands-bot"
git config user.email "allhands-bot@users.noreply.github.com"
git rm -rf .pr/
git commit -m "chore: Remove PR-only artifacts [automated]"
git push || {
echo "::error::Failed to push cleanup commit. Check branch protection rules."
exit 1
}
echo "removed=true" >> $GITHUB_OUTPUT
echo "::notice::Removed .pr/ directory"
else
echo "removed=false" >> $GITHUB_OUTPUT
echo "::notice::No .pr/ directory to remove"
fi
- name: Update PR comment after cleanup
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
✅ **PR Artifacts Cleaned Up**
The \`.pr/\` directory has been automatically removed.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body,
});
}
# Warn if .pr/ directory exists (will be auto-removed on approval)
check-pr-artifacts:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v5
- name: Check for .pr/ directory
id: check
run: |
if [ -d ".pr" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Post or update PR comment
if: steps.check.outputs.exists == 'true'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
📁 **PR Artifacts Notice**
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
> For fork PRs: Manual removal is required before merging.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (!existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}

View File

@@ -28,7 +28,7 @@ jobs:
steps:
- name: Download review trace artifact
id: download-trace
uses: dawidd6/action-download-artifact@v6
uses: dawidd6/action-download-artifact@v15
continue-on-error: true
with:
workflow: pr-review-by-openhands.yml

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:
@@ -30,20 +30,22 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- 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"
@@ -73,16 +75,16 @@ 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"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- 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"
@@ -111,9 +113,9 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
id: download
with:
pattern: coverage-*

View File

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

View File

@@ -8,10 +8,10 @@ on:
jobs:
stale:
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'

View File

@@ -19,10 +19,10 @@ 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@v4
uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"

2
.gitignore vendored
View File

@@ -234,6 +234,8 @@ yarn-error.log*
logs
ralph/
# agent
.envrc
/workspace

View File

@@ -36,9 +36,81 @@ then re-run the command to ensure it passes. Common issues include:
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
## Lockfile Regeneration (Preserve Original Tool Versions)
When regenerating lockfiles (poetry.lock, uv.lock, etc.), you MUST use the same tool version that originally generated the lockfile to avoid unnecessary diff noise. Each lockfile contains a version header indicating which tool version was used.
### Poetry (poetry.lock)
1. Extract the version from the lockfile header:
```bash
POETRY_VERSION=$(grep -m1 "^# This file is automatically @generated by Poetry" poetry.lock | sed 's/.*Poetry \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install poetry==$POETRY_VERSION --force
```
3. Then regenerate the lockfile:
```bash
poetry lock --no-update
```
### uv (uv.lock)
1. Extract the version from the lockfile header:
```bash
UV_VERSION=$(grep -m1 "^# This file was autogenerated by uv" uv.lock | sed 's/.*uv version \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install uv==$UV_VERSION --force
```
3. Then regenerate the lockfile:
```bash
uv lock
```
This ensures that lockfile updates only contain actual dependency changes, not tool version migration artifacts.
## PR-Specific Artifacts (`.pr/` directory)
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.
### Usage
```
.pr/
├── design.md # Design decisions and architecture notes
├── analysis.md # Investigation or debugging notes
├── logs/ # Test output or CI logs for reviewer reference
└── notes.md # Any other PR-specific content
```
### How It Works
1. **Notification**: When `.pr/` exists, a comment is posted to the PR conversation alerting reviewers
2. **Auto-cleanup**: When the PR is approved, the `.pr/` directory is automatically removed via `.github/workflows/pr-artifacts.yml`
3. **Fork PRs**: Auto-cleanup cannot push to forks, so manual removal is required before merging
### Important Notes
- Do NOT put anything in `.pr/` that needs to be preserved after merge
- The `.pr/` check passes (green ✅) during development — it only posts a notification, not a blocking error
- For fork PRs: You must manually remove `.pr/` before the PR can be merged
### When to Use
- Complex refactoring that benefits from written design rationale
- Debugging sessions where you want to document your investigation
- E2E test results or logs that demonstrate a cross-repo feature works
- Feature implementations that need temporary planning docs
- Any analysis that helps reviewers understand the PR but isn't needed long-term
## Repository Structure
Backend:
- Located in the `openhands` directory
- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`.
- For V1 web-app docs, LLM setup should point users to the Settings UI.
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
@@ -342,3 +414,30 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo
- Models appear in CLI provider selection based on the verified arrays
- The `organize_models_and_providers` function groups models by provider
- Default model selection prioritizes verified models for each provider
### Sandbox Settings API (SDK Credential Inheritance)
The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials
(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox,
never through the SDK client.
#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`):
- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`)
- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only)
Auth requirements for `expose_secrets=true`:
- Bearer token (proves user identity via `OPENHANDS_API_KEY`)
- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user)
Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key.
#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`):
- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values)
- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox)
#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()`
#### Related SDK code (in `software-agent-sdk` repo):
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`

View File

@@ -1,83 +1,105 @@
# Contributing
Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions.
Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey.
## Understanding OpenHands's CodeBase
## Our Vision
To understand the codebase, please refer to the README in each module:
- [frontend](./frontend/README.md)
- [openhands](./openhands/README.md)
- [agenthub](./openhands/agenthub/README.md)
- [server](./openhands/server/README.md)
The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone.
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources.
## Setting up Your Development Environment
## Getting Started
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
you how to set up a development workflow.
### Quick Ways to Contribute
## How Can I Contribute?
- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter
- **Give feedback** using the thumbs-up/thumbs-down buttons after each session
- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands)
- **Share OpenHands** with other developers
There are many ways that you can contribute:
### Set Up Your Development Environment
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+
- **Quick setup**: `make build`
- **Run locally**: `make run`
- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts
## What Can I Build?
Full details in our [Development Guide](./Development.md).
Here are a few ways you can help improve the codebase.
### Find Your First Issue
#### UI/UX
- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue)
- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks
- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help
We're always looking to improve the look and feel of the application. If you've got a small fix
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
## Understanding the Codebase
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
to gather consensus from our design team first.
- **[Frontend](./frontend/README.md)** - React application
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
#### Improving the agent
## What Can You Build?
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
### Frontend & UI/UX
- React & TypeScript development
- UI/UX improvements
- Mobile responsiveness
- Component libraries
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent
is getting better over time.
For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first.
We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation
channel in Slack to learn more.
### Agent Development
- Prompt engineering
- New agent types
- Agent evaluation
- Multi-agent systems
#### Adding a new agent
We use [SWE-bench](https://www.swebench.com/) to evaluate agents.
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
to help expand the capabilities of OpenHands.
### Backend & Infrastructure
- Python development
- Runtime systems (Docker containers, sandboxes)
- Cloud integrations
- Performance optimization
#### Adding a new runtime
### Testing & Quality Assurance
- Unit testing
- Integration testing
- Bug hunting
- Performance testing
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
to do this by default. But there are other ways of creating a sandbox for the agent.
### Documentation & Education
- Technical documentation
- Translation
- Community support
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
## Pull Request Process
#### Testing
### Small Improvements
- Quick review and approval
- Ensure CI tests pass
- Include clear description of changes
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
quality of the project.
### Core Agent Changes
These are evaluated based on:
- **Accuracy** - Does it make the agent better at solving problems?
- **Efficiency** - Does it improve speed or reduce resource usage?
- **Code Quality** - Is the code maintainable and well-tested?
Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first.
## Sending Pull Requests to OpenHands
You'll need to fork our repository to send us a Pull Request. You can learn more
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
### Pull Request title
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
### Pull Request Title Format
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
- `feat`: A new feature
- `fix`: A bug fix
@@ -95,45 +117,27 @@ For example, a PR title could be:
- `refactor: modify package path`
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
### Pull Request Description
### Pull Request description
- Explain what the PR does and why
- Link to related issues
- Include screenshots for UI changes
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
please include a short message that we can add to our changelog
- If your PR is small (such as a typo fix), you can go brief.
- If it contains a lot of changes, it's better to write more details.
## Becoming a Maintainer
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
please include a short message that we can add to our changelog.
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team.
The process for this is as follows:
## How to Make Effective Contributions
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
### Opening Issues
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
the community has interest/effort for.
## Need Help?
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
### Making Pull Requests
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
#### For Small Improvements
Small improvements with few downsides are typically reviewed and approved quickly.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
before getting a review.
#### For Core Agent Changes
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
evaluated based on three key metrics:
1. **Accuracy**
2. **Efficiency**
3. **Code Complexity**
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
- **Slack**: [Join our community](https://openhands.dev/joinslack)
- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues)
- **Email**: contact@openhands.dev

View File

@@ -6,22 +6,196 @@ If you wish to contribute your changes, check out the
on how to clone and setup the project initially before moving on. Otherwise,
you can clone the OpenHands project directly.
## Start the Server for Development
## Choose Your Setup
### 1. Requirements
Select your operating system to see the specific setup instructions:
- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
- [Python](https://www.python.org/downloads/) = 3.12
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
- OS-specific dependencies:
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
- WSL: netcat => `sudo apt-get install netcat`
- [macOS](#macos-setup)
- [Linux](#linux-setup)
- [Windows WSL](#windows-wsl-setup)
- [Dev Container](#dev-container)
- [Developing in Docker](#developing-in-docker)
- [No sudo access?](#develop-without-sudo-access)
Make sure you have all these dependencies installed before moving on to `make build`.
---
#### Dev container
## macOS Setup
### 1. Install Prerequisites
You'll need the following installed:
- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this).
- **Node.js >= 22** — `brew install node`
- **Poetry >= 1.8** — `brew install poetry`
- **Docker Desktop** — `brew install --cask docker`
- After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"**
### 2. Build and Setup the Environment
```bash
make build
```
### 3. Configure the Language Model
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
For the V1 web app, start OpenHands and configure your model and API key in the Settings UI.
If you are running headless or CLI workflows, you can prepare local defaults with:
```bash
make setup-config
```
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
### 4. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`.
---
## Linux Setup
This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly.
### 1. Install Prerequisites
```bash
# Update package list
sudo apt update
# Install system dependencies
sudo apt install -y build-essential curl netcat software-properties-common
# Install Python 3.12
# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if
# python3.12 --version already works on your system.
# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older:
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update
sudo apt install -y python3.12 python3.12-dev python3.12-venv
# Install Node.js 22.x
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Install Poetry
curl -sSL https://install.python-poetry.org | python3 -
# Add Poetry to your PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# Install Docker
# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/
# Quick version:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
# Log out and back in for Docker group changes to take effect
```
### 2. Build and Setup the Environment
```bash
make build
```
### 3. Configure the Language Model
See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI.
### 4. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
---
## Windows WSL Setup
WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations.
### 1. Install WSL2
**Option A: Windows 11 (Microsoft Store)**
The easiest way on Windows 11:
1. Open the **Microsoft Store** app
2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"**
3. Click **Install**
4. Launch Ubuntu from the Start menu
**Option B: PowerShell**
```powershell
# Run this in PowerShell as Administrator
wsl --install -d Ubuntu-22.04
```
After installation, restart your computer and open Ubuntu.
### 2. Install Prerequisites (in WSL Ubuntu)
Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below.
### 3. Configure Docker for WSL2
1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
2. Open Docker Desktop > Settings > General
3. Enable: "Use the WSL 2 based engine"
4. Go to Settings > Resources > WSL Integration
5. Enable integration with your Ubuntu distribution
**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower.
### 4. Build and Setup the Environment
```bash
make build
```
### 5. Configure the Language Model
See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows.
### 6. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
Access the frontend at `http://localhost:3001` from your Windows browser.
---
## Dev Container
There is a [dev container](https://containers.dev/) available which provides a
pre-configured environment with all the necessary dependencies installed if you
@@ -32,7 +206,38 @@ extension installed, you can open the project in a dev container by using the
_Dev Container: Reopen in Container_ command from the Command Palette
(Ctrl+Shift+P).
#### Develop without sudo access
---
## Developing in Docker
If you don't want to install dependencies on your host machine, you can develop inside a Docker container.
### Quick Start
```bash
make docker-dev
```
For more details, see the [dev container documentation](./containers/dev/README.md).
### Alternative: Docker Run
If you just want to run OpenHands without setting up a dev environment:
```bash
make docker-run
```
If you don't have `make` installed, run:
```bash
cd ./containers/dev
./dev.sh
```
---
## Develop without sudo access
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
`conda` or `mamba` to manage the packages for you:
@@ -48,159 +253,90 @@ mamba install conda-forge::nodejs
mamba install conda-forge::poetry
```
### 2. Build and Setup The Environment
---
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
that OpenHands is ready to run on your system:
## Running OpenHands with OpenHands
You can use OpenHands to develop and improve OpenHands itself!
### Quick Start
```bash
make build
export INSTALL_DOCKER=0
export RUNTIME=local
make build && make run
```
### 3. Configuring the Language Model
Access the interface at:
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
For external access:
```bash
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
```
To configure the LM of your choice, run:
---
## LLM Debugging
If you encounter issues with the Language Model, enable debug logging:
```bash
make setup-config
export DEBUG=1
# Restart the backend
make start-backend
```
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
please set the model in the UI.
Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting.
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
variables in your terminal. The final configurations are set from highest to lowest priority:
Environment variables > config.toml variables > default variables
---
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
## Testing
### 4. Running the application
#### Option A: Run the Full Application
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
```bash
make run
```
#### Option B: Individual Server Startup
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
backend-related tasks or configurations.
```bash
make start-backend
```
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
components or interface enhancements.
```bash
make start-frontend
```
### 5. Running OpenHands with OpenHands
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
#### Quick Start
1. **Build and run OpenHands:**
```bash
export INSTALL_DOCKER=0
export RUNTIME=local
make build && make run
```
2. **Access the interface:**
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
3. **Configure for external access (if needed):**
```bash
# For external access (e.g., cloud environments)
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
```
### 6. LLM Debugging
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
### 7. Help
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
```bash
make help
```
### 8. Testing
To run tests, refer to the following:
#### Unit tests
### Unit Tests
```bash
poetry run pytest ./tests/unit/test_*.py
```
### 9. Add or update dependency
---
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
2. Update the poetry.lock file via `poetry lock --no-update`.
## Adding Dependencies
### 10. Use existing Docker image
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
2. Update the lock file: `poetry lock --no-update`
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
---
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
## Using Existing Docker Images
## Develop inside Docker container
TL;DR
To reduce build time, you can use an existing runtime image:
```bash
make docker-dev
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
```
See more details [here](./containers/dev/README.md).
---
If you are just interested in running `OpenHands` without installing all the required tools on your host.
## Help
```bash
make docker-run
make help
```
If you do not have `make` on your host, run:
```bash
cd ./containers/dev
./dev.sh
```
You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though.
---
## Key Documentation Resources
Here's a guide to the important documentation files in the repository:
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

View File

@@ -23,7 +23,6 @@
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
</div>
<hr>
@@ -84,3 +83,71 @@ All our work is available under the MIT license, except for the `enterprise/` di
The core `openhands` and `agent-server` Docker images are fully MIT-licensed as well.
If you need help with anything, or just want to chat, [come find us on Slack](https://dub.sh/openhands).
<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">
<br/><br/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
<img src="https://assets.openhands.dev/logos/external/black/tiktok.svg" alt="TikTok" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/vmware.svg">
<img src="https://assets.openhands.dev/logos/external/black/vmware.svg" alt="VMware" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/roche.svg">
<img src="https://assets.openhands.dev/logos/external/black/roche.svg" alt="Roche" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/amazon.svg">
<img src="https://assets.openhands.dev/logos/external/black/amazon.svg" alt="Amazon" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/c3-ai.svg">
<img src="https://assets.openhands.dev/logos/external/black/c3-ai.svg" alt="C3 AI" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/netflix.svg">
<img src="https://assets.openhands.dev/logos/external/black/netflix.svg" alt="Netflix" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mastercard.svg">
<img src="https://assets.openhands.dev/logos/external/black/mastercard.svg" alt="Mastercard" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/red-hat.svg">
<img src="https://assets.openhands.dev/logos/external/black/red-hat.svg" alt="Red Hat" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mongodb.svg">
<img src="https://assets.openhands.dev/logos/external/black/mongodb.svg" alt="MongoDB" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/apple.svg">
<img src="https://assets.openhands.dev/logos/external/black/apple.svg" alt="Apple" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/nvidia.svg">
<img src="https://assets.openhands.dev/logos/external/black/nvidia.svg" alt="NVIDIA" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/google.svg">
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
</picture>
</div>
</div>

View File

@@ -296,7 +296,7 @@ classpath = "my_package.my_module.MyCustomAgent"
#user_id = 1000
# Container image to use for the sandbox
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22"
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22-slim"
# Use host network
#use_host_network = false

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.2-trixie-slim AS frontend-builder
FROM node:25.8-trixie-slim AS frontend-builder
WORKDIR /app
@@ -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
@@ -50,7 +52,7 @@ RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE
RUN apt-get update -y \
&& apt-get install -y curl ssh sudo \
&& apt-get install -y curl git ssh sudo \
&& rm -rf /var/lib/apt/lists/*
# Default is 1000, but OSX is often 501
@@ -73,6 +75,17 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
# Pin pip to a known-good version (reproducible builds) and fix CVE-2025-8869
# Pin both venv pip and system pip (Trivy scans both)
# - `python -m pip` uses the venv because `PATH` is prefixed with `${VIRTUAL_ENV}/bin`
# - `/usr/local/bin/python3 -m pip` uses the system interpreter regardless of `PATH`
ARG PIP_VERSION=26.0.1
RUN python -m pip install --no-cache-dir "pip==${PIP_VERSION}"
USER root
RUN /usr/local/bin/python3 -m pip install --no-cache-dir "pip==${PIP_VERSION}" --break-system-packages
USER openhands
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins

View File

@@ -8,15 +8,17 @@ push=0
load=0
tag_suffix=""
dry_run=0
platform_override=""
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--dry]"
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"
exit 1
}
@@ -29,6 +31,7 @@ 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 ;;
*) usage ;;
esac
@@ -134,8 +137,10 @@ fi
echo "Args: $args"
# Modify the platform selection based on --load flag
if [[ $load -eq 1 ]]; then
# Determine the platform(s) to build for
if [[ -n "$platform_override" ]]; then
platform="$platform_override"
elif [[ $load -eq 1 ]]; then
# When loading, build only for the current platform
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
else

View File

@@ -13,7 +13,7 @@ services:
- DOCKER_HOST_ADDR=host.docker.internal
#
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -58,6 +58,8 @@ repos:
types-Markdown,
pydantic,
lxml,
"openhands-sdk==1.14",
"openhands-tools==1.14",
]
# 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

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

View File

@@ -10,7 +10,7 @@ LABEL com.datadoghq.tags.env="${DD_ENV}"
# Apply security updates to fix CVEs
RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
apt-get install -y nodejs && \
apt-get install -y jq gettext && \
# Apply security updates for packages with available fixes

View File

@@ -51,6 +51,6 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
## User ID vs User Token
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completely ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.

View File

@@ -723,11 +723,13 @@
"https://$WEB_HOST/slack/keycloak-callback",
"https://$WEB_HOST/oauth/device/keycloak-callback",
"https://$WEB_HOST/api/email/verified",
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*",
"https://laminar.$WEB_HOST/api/auth/callback/keycloak"
],
"webOrigins": [
"https://$WEB_HOST",
"https://$AUTH_WEB_HOST"
"https://$AUTH_WEB_HOST",
"https://laminar.$WEB_HOST"
],
"notBefore": 0,
"bearerOnly": false,

View File

@@ -0,0 +1,13 @@
# Enterprise Architecture Documentation
Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment.
## Documentation
- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment
- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations
## Related Documentation
For core OpenHands architecture (applicable to all deployments), see:
- [Core Architecture Documentation](../../../openhands/architecture/README.md)

View File

@@ -0,0 +1,58 @@
# Authentication Flow (SaaS Deployment)
OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services:
```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant App as App Server
participant KC as Keycloak
participant IdP as Identity Provider<br/>(GitHub, Google, etc.)
participant DB as User Database
Note over User,DB: OAuth 2.0 / OIDC Authentication Flow
User->>App: Access OpenHands
App->>User: Redirect to Keycloak
User->>KC: Login request
KC->>User: Show login options
User->>KC: Select provider (e.g., GitHub)
KC->>IdP: OAuth redirect
User->>IdP: Authenticate
IdP-->>KC: OAuth callback + tokens
Note over KC: Create/update user session
KC-->>User: Redirect with auth code
User->>App: Auth code
App->>KC: Exchange code for tokens
KC-->>App: Access token + Refresh token
Note over App: Create signed JWT cookie
App->>DB: Store/update user record
App-->>User: Set keycloak_auth cookie
Note over User,DB: Subsequent Requests
User->>App: Request with cookie
Note over App: Verify JWT signature
App->>KC: Validate token (if needed)
KC-->>App: Token valid
Note over App: Extract user context
App-->>User: Authorized response
```
### Authentication Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Keycloak** | Identity provider, SSO, token management | External service |
| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` |
| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` |
| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` |
| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` |
### Token Flow
1. **Keycloak Access Token**: Short-lived token for API access
2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens
3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens
4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations)

View File

@@ -0,0 +1,88 @@
# External Integrations
OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling:
```mermaid
sequenceDiagram
autonumber
participant Ext as External Service<br/>(GitHub/Slack/Jira)
participant App as App Server
participant IntRouter as Integration Router
participant Manager as Integration Manager
participant Conv as Conversation Service
participant Sandbox as Sandbox
Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created)
Ext->>App: POST /api/integration/{service}/events
App->>IntRouter: Route to service handler
Note over IntRouter: Verify signature (HMAC)
IntRouter->>Manager: Parse event payload
Note over Manager: Extract context (repo, issue, user)
Note over Manager: Map external user → OpenHands user
Manager->>Conv: Create conversation (with issue context)
Conv->>Sandbox: Provision sandbox
Sandbox-->>Conv: Ready
Manager->>Sandbox: Start agent with task
Note over Ext,Sandbox: Agent Works on Task...
Sandbox-->>Manager: Task complete
Manager->>Ext: POST result<br/>(PR, comment, etc.)
Note over Ext,Sandbox: Callback Flow (Agent → External Service)
Sandbox->>App: Webhook callback<br/>/api/v1/webhooks
App->>Manager: Process callback
Manager->>Ext: Update external service
```
### Supported Integrations
| Integration | Trigger Events | Agent Actions |
|-------------|----------------|---------------|
| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits |
| **GitLab** | Issue created, MR opened | Create MR, comment, push commits |
| **Slack** | @mention in channel | Reply in thread, create tasks |
| **Jira** | Issue created/updated | Update ticket, add comments |
| **Linear** | Issue created | Update status, add comments |
### Integration Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` |
| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` |
| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` |
| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` |
### Integration Authentication
```
External Service (e.g., GitHub)
┌─────────────────────────────────┐
│ GitHub App Installation │
│ - Webhook secret for signature │
│ - App private key for API calls │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ User Account Linking │
│ - Keycloak user ID │
│ - GitHub user ID │
│ - Stored OAuth tokens │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Agent Execution │
│ - Uses linked tokens for API │
│ - Can push, create PRs, comment │
└─────────────────────────────────┘
```

View File

@@ -1,6 +1,7 @@
from types import MappingProxyType
from github import Auth, Github, GithubIntegration
from lmnr import Laminar
from integrations.github.data_collector import GitHubDataCollector
from integrations.github.github_solvability import summarize_issue_solvability
from integrations.github.github_view import (
@@ -22,6 +23,8 @@ from integrations.utils import (
CONVERSATION_URL,
ENABLE_SOLVABILITY_ANALYSIS,
HOST_URL,
LAMINAR_ENABLED,
LAMINAR_PROJECT_API_KEY,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_session_expired_message,
get_user_not_found_message,
@@ -329,6 +332,16 @@ class GithubManager(Manager[GithubViewType]):
GithubCallbackProcessor,
)
# Initialize Laminar if enabled
laminar_span_context = None
if LAMINAR_ENABLED:
try:
Laminar.initialize(project_api_key=LAMINAR_PROJECT_API_KEY)
laminar_span_context = Laminar.get_laminar_span_context()
logger.info('[Github] Laminar initialized for observability')
except Exception as e:
logger.warning(f'[Github] Failed to initialize Laminar: {e}')
try:
msg_info: str = ''
@@ -389,12 +402,42 @@ class GithubManager(Manager[GithubViewType]):
github_view.user_info.keycloak_user_id, self.token_manager
)
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
# Set up Laminar tracing if enabled
if LAMINAR_ENABLED and laminar_span_context:
try:
with Laminar.start_as_current_span(
name='github-resolver',
parent_span_context=laminar_span_context,
):
Laminar.set_trace_metadata({
'source': 'github',
'repo': github_view.full_repo_name,
'issue_number': str(github_view.issue_number),
'username': user_info.username,
'conversation_id': github_view.conversation_id,
})
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
except Exception as e:
logger.warning(f'[Github] Laminar span error: {e}')
# Fall back to non-Laminar execution
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
else:
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
conversation_id = github_view.conversation_id
@@ -458,3 +501,10 @@ class GithubManager(Manager[GithubViewType]):
await self.data_collector.save_data(github_view)
except Exception:
logger.warning('[Github]: Error saving interaction data', exc_info=True)
# Flush Laminar traces if enabled
if LAMINAR_ENABLED:
try:
Laminar.flush()
except Exception as e:
logger.warning(f'[Github] Error flushing Laminar traces: {e}')

View File

@@ -43,15 +43,20 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitHub V1 integration."""
# Only handle ConversationStateUpdateEvent
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
if event.key != 'execution_status':
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[GitHub V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info(
'[GitHub V1] Should request summary: %s', self.should_request_summary
)

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

@@ -41,15 +41,20 @@ class GitlabV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitLab V1 integration."""
# Only handle ConversationStateUpdateEvent
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
if event.key != 'execution_status':
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[GitLab V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info(
'[GitLab V1] Should request summary: %s', self.should_request_summary
)

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

@@ -7,6 +7,7 @@ Views are responsible for:
"""
from dataclasses import dataclass, field
from uuid import uuid4
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
@@ -15,18 +16,25 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
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 server.config import get_config
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 storage.saas_conversation_store import SaasConversationStore
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.server.services.conversation_service import start_conversation
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
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
@@ -166,20 +174,68 @@ class JiraNewConversationView(JiraViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
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,
user_id = self.jira_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'[Jira] 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'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
)
# Create the conversation store with resolver org routing
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.JIRA,
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(
'[Jira] Created conversation',
@@ -187,6 +243,9 @@ class JiraNewConversationView(JiraViewInterface):
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
'resolved_org_id': str(resolved_org_id)
if resolved_org_id
else None,
},
)

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:
@@ -60,7 +64,9 @@ class ResolverUserContext(UserContext):
return provider_token.token.get_secret_value()
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
async def get_provider_tokens(
self, as_env_vars: bool = False
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, SecretSource]:
@@ -79,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

@@ -40,16 +40,20 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for Slack V1 integration."""
# Only handle ConversationStateUpdateEvent
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
if event.key != 'execution_status':
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[Slack V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
try:
summary = await self._request_summary(conversation_id)
await self._post_summary_to_slack(summary)

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

@@ -100,27 +100,25 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
return bool(payment_methods.data)
async def migrate_customer(user_id: str, org: Org):
async with a_session_maker() as session:
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
async def migrate_customer(session, user_id: str, org: Org):
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
await session.commit()
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)

View File

@@ -98,6 +98,10 @@ ENABLE_V1_SLACK_RESOLVER = (
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
)
# Laminar observability settings
LAMINAR_ENABLED = os.environ.get('LMNR_PROJECT_API_KEY', '') != ''
LAMINAR_PROJECT_API_KEY = os.environ.get('LMNR_PROJECT_API_KEY', '')
# Toggle for V1 GitLab resolver feature
ENABLE_V1_GITLAB_RESOLVER = (
os.getenv('ENABLE_V1_GITLAB_RESOLVER', 'false').lower() == 'true'

View File

@@ -8,7 +8,7 @@ logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy import create_engine, text # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata
@@ -109,6 +109,10 @@ def run_migrations_online() -> None:
version_table_schema=target_metadata.schema,
)
# Lock number must be unique — md5 hash of 'openhands_enterprise_migrations'
# Lock is released when the connection context manager exits
connection.execute(text('SELECT pg_advisory_lock(3617572382373537863)'))
with context.begin_transaction():
context.run_migrations()

View File

@@ -0,0 +1,33 @@
"""Add sandbox_grouping_strategy column to user, org, and user_settings tables.
Revision ID: 100
Revises: 099
Create Date: 2025-03-12
"""
import sqlalchemy as sa
from alembic import op
revision = '100'
down_revision = '099'
def upgrade() -> None:
op.add_column(
'user',
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
)
op.add_column(
'org',
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
)
op.add_column(
'user_settings',
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
)
def downgrade() -> None:
op.drop_column('user_settings', 'sandbox_grouping_strategy')
op.drop_column('org', 'sandbox_grouping_strategy')
op.drop_column('user', 'sandbox_grouping_strategy')

View File

@@ -0,0 +1,39 @@
"""Add pending_messages table for server-side message queuing
Revision ID: 101
Revises: 100
Create Date: 2025-03-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '101'
down_revision: Union[str, None] = '100'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create pending_messages table for storing messages before conversation is ready.
Messages are stored temporarily until the conversation becomes ready, then
delivered and deleted regardless of success or failure.
"""
op.create_table(
'pending_messages',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('conversation_id', sa.String(), nullable=False, index=True),
sa.Column('role', sa.String(20), nullable=False, server_default='user'),
sa.Column('content', sa.JSON, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
"""Remove pending_messages table."""
op.drop_table('pending_messages')

View File

@@ -0,0 +1,28 @@
"""Add disabled_skills to user_settings.
Revision ID: 102
Revises: 101
Create Date: 2026-02-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '102'
down_revision: Union[str, None] = '101'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'user_settings', sa.Column('disabled_skills', sa.JSON(), nullable=True)
)
def downgrade() -> None:
op.drop_column('user_settings', 'disabled_skills')

View File

@@ -0,0 +1,42 @@
"""Add mcp_config to org_member for user-specific MCP settings.
Revision ID: 103
Revises: 102
Create Date: 2026-03-26
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '103'
down_revision: Union[str, None] = '102'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
# 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)},
)
def downgrade() -> None:
op.drop_column('org_member', 'mcp_config')

View File

@@ -0,0 +1,29 @@
"""Add disabled_skills column to user table.
Migration 102 added disabled_skills to the legacy user_settings table,
but the active SaaS flow (SaasSettingsStore) reads from/writes to the
user table. This migration adds the column where it is actually needed.
Revision ID: 104
Revises: 103
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 = '104'
down_revision: Union[str, None] = '103'
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('disabled_skills', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'disabled_skills')

View File

@@ -0,0 +1,37 @@
"""Create org_git_claim table for tracking Git organization claims.
Revision ID: 105
Revises: 104
Create Date: 2026-04-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '105'
down_revision: Union[str, None] = '104'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'org_git_claim',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('org_id', sa.UUID(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('git_organization', sa.String(), nullable=False),
sa.Column('claimed_by', sa.UUID(), nullable=False),
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['org.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['claimed_by'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
)
def downgrade() -> None:
op.drop_table('org_git_claim')

View File

@@ -0,0 +1,32 @@
"""Add tags column to conversation_metadata table.
Tags store key-value pairs for automation context (trigger type, automation_id),
skills used, and other metadata. This enables querying conversations by
automation source and associating SDK-provided context with conversations.
Revision ID: 106
Revises: 105
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 = '106'
down_revision: Union[str, None] = '105'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'conversation_metadata',
sa.Column('tags', sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column('conversation_metadata', 'tags')

4898
enterprise/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,7 @@ pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
flake8 = "*"
freezegun = "^1.5.1"
openai = "*"
opencv-python = "*"
pandas = "*"

View File

@@ -1,71 +0,0 @@
"""Entry point for the automation executor.
Usage: python -m run_automation_executor
This runs as a Kubernetes Deployment (long-running). It polls the automation_events
inbox, matches events to automations, claims and executes runs, and monitors
conversation completion.
Environment variables:
OPENHANDS_API_URL Base URL for the V1 API (default: http://openhands-service:3000)
MAX_CONCURRENT_RUNS Max concurrent runs per executor (default: 5)
RUN_TIMEOUT_SECONDS Max time for a single run (default: 7200)
POLL_INTERVAL_SECONDS Fallback poll interval (default: 30)
HEARTBEAT_INTERVAL_SECONDS Heartbeat update interval (default: 60)
"""
import asyncio
import logging
import signal
import sys
logger = logging.getLogger('saas.automation.executor')
def _setup_logging() -> None:
"""Configure logging, deferring to enterprise logger if available."""
try:
from server.logger import setup_all_loggers
setup_all_loggers()
except ImportError:
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(name)s %(levelname)s %(message)s',
stream=sys.stdout,
)
def _install_signal_handlers(loop: asyncio.AbstractEventLoop) -> None:
"""Install signal handlers for graceful shutdown."""
from services.automation_executor import request_shutdown
def _handle_signal(signum: int, _frame: object) -> None:
sig_name = signal.Signals(signum).name
logger.info('Received %s, initiating graceful shutdown...', sig_name)
request_shutdown()
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, _handle_signal)
async def main() -> None:
from services.automation_executor import executor_main
await executor_main()
if __name__ == '__main__':
_setup_logging()
loop = asyncio.new_event_loop()
_install_signal_handlers(loop)
logger.info('Starting automation executor')
try:
loop.run_until_complete(main())
except KeyboardInterrupt:
logger.info('Interrupted by user')
finally:
loop.close()
logger.info('Automation executor process exiting')

View File

@@ -46,8 +46,12 @@ from server.routes.org_invitations import ( # noqa: E402
)
from server.routes.orgs import org_router # noqa: E402
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,
)
@@ -112,6 +116,7 @@ if GITLAB_APP_CLIENT_ID:
base_app.include_router(gitlab_integration_router)
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(service_router) # Add routes for internal service API
base_app.include_router(org_router) # Add routes for organization management
base_app.include_router(
verified_models_router
@@ -121,6 +126,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)

View File

@@ -35,13 +35,13 @@ Usage:
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth import get_user_auth, get_user_id
class Permission(str, Enum):
@@ -84,6 +84,9 @@ class Permission(str, Enum):
# Temporary permissions until we finish the API updates.
EDIT_ORG_SETTINGS = 'edit_org_settings'
# Git organization claims
MANAGE_ORG_CLAIMS = 'manage_org_claims'
class RoleName(str, Enum):
"""Role names used in the system."""
@@ -118,6 +121,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Organization Management (Owner only)
Permission.CHANGE_ORGANIZATION_NAME,
Permission.DELETE_ORGANIZATION,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
]
),
RoleName.ADMIN: frozenset(
@@ -139,6 +144,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Organization Management
Permission.VIEW_ORG_SETTINGS,
Permission.EDIT_ORG_SETTINGS,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
]
),
RoleName.MEMBER: frozenset(
@@ -214,6 +221,19 @@ def has_permission(user_role: Role, permission: Permission) -> bool:
return permission in permissions
async def get_api_key_org_id_from_request(request: Request) -> UUID | None:
"""Get the org_id bound to the API key used for authentication.
Returns None if:
- Not authenticated via API key (cookie auth)
- API key is a legacy key without org binding
"""
user_auth = getattr(request.state, 'user_auth', None)
if user_auth and hasattr(user_auth, 'get_api_key_org_id'):
return user_auth.get_api_key_org_id()
return None
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
@@ -221,8 +241,9 @@ def require_permission(permission: Permission):
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
3. Validates API key org binding (if using API key auth)
4. Checks if the user has the required permission in the organization
5. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
@@ -240,6 +261,7 @@ def require_permission(permission: Permission):
"""
async def permission_checker(
request: Request,
org_id: UUID | None = None,
user_id: str | None = Depends(get_user_id),
) -> str:
@@ -249,6 +271,23 @@ def require_permission(permission: Permission):
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None and org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
user_role = await get_user_org_role(user_id, org_id)
if not user_role:
@@ -279,3 +318,96 @@ def require_permission(permission: Permission):
return user_id
return permission_checker
async def require_financial_data_access(
request: Request,
org_id: UUID,
user_id: str | None = Depends(get_user_id),
) -> str:
"""
Authorization dependency for accessing organization financial data.
Allows access if ANY of these conditions are met:
1. User has Admin or Owner role in the organization
2. User has @openhands.dev email domain
This is used for the organization members financial data endpoint.
Args:
request: FastAPI request object
org_id: Organization UUID from path parameter
user_id: User ID from authentication
Returns:
str: User ID if authorized
Raises:
HTTPException: 401 if not authenticated, 403 if not authorized
"""
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch for financial data access',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
# Check if user has @openhands.dev email
user_auth = await get_user_auth(request)
user_email = await user_auth.get_user_email()
if user_email and user_email.endswith('@openhands.dev'):
logger.debug(
'Financial data access granted via @openhands.dev email',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
return user_id
# Check if user has Admin or Owner role in the organization
user_role = await get_user_org_role(user_id, org_id)
if not user_role:
logger.warning(
'Financial data access denied - user not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
if user_role.name not in (RoleName.OWNER.value, RoleName.ADMIN.value):
logger.warning(
'Financial data access denied - insufficient role',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Access restricted to organization admins, owners, or OpenHands members',
)
logger.debug(
'Financial data access granted via admin/owner role',
extra={'user_id': user_id, 'org_id': str(org_id), 'role': user_role.name},
)
return user_id

View File

@@ -6,7 +6,6 @@ GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
GITHUB_APP_PRIVATE_KEY = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace('\\n', '\n')
KEYCLOAK_SERVER_URL = os.getenv('KEYCLOAK_SERVER_URL', '').rstrip('/')
KEYCLOAK_REALM_NAME = os.getenv('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.getenv('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_SERVER_URL_EXT = os.getenv(

View File

@@ -4,7 +4,6 @@ from server.auth.constants import (
KEYCLOAK_ADMIN_PASSWORD,
KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_PROVIDER_NAME,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL,
KEYCLOAK_SERVER_URL_EXT,
@@ -12,7 +11,7 @@ from server.auth.constants import (
from server.logger import logger
logger.debug(
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_PROVIDER_NAME:{KEYCLOAK_PROVIDER_NAME}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
)
_keycloak_instances = {}

View File

@@ -1,6 +1,7 @@
import time
from dataclasses import dataclass
from types import MappingProxyType
from uuid import UUID
import jwt
from fastapi import Request
@@ -13,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
@@ -22,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 (
@@ -59,6 +66,25 @@ class SaasUserAuth(UserAuth):
_secrets: Secrets | None = None
accepted_tos: bool | None = None
auth_type: AuthType = AuthType.COOKIE
# API key context fields - populated when authenticated via API key
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.
Returns:
The org_id if authenticated via API key with org binding, None otherwise
(cookie auth or legacy API keys without org binding).
"""
return self.api_key_org_id
async def get_user_id(self) -> str | None:
return self.user_id
@@ -228,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')
@@ -283,14 +375,19 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
return None
api_key_store = ApiKeyStore.get_instance()
user_id = await api_key_store.validate_api_key(api_key)
if not user_id:
validation_result = await api_key_store.validate_api_key(api_key)
if not validation_result:
return None
offline_token = await token_manager.load_offline_token(user_id)
offline_token = await token_manager.load_offline_token(
validation_result.user_id
)
saas_user_auth = SaasUserAuth(
user_id=user_id,
user_id=validation_result.user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
api_key_org_id=validation_result.org_id,
api_key_id=validation_result.key_id,
api_key_name=validation_result.key_name,
)
await saas_user_auth.refresh()
return saas_user_auth

View File

@@ -77,6 +77,9 @@ PERMITTED_CORS_ORIGINS = [
)
]
# Controls whether new orgs/users default to V1 API (env: DEFAULT_V1_ENABLED)
DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true')
def build_litellm_proxy_model_path(model_name: str) -> str:
"""Build the LiteLLM proxy model path based on model name.

View File

@@ -80,10 +80,11 @@ def setup_json_logger(
handler.setLevel(level)
formatter = JsonFormatter(
'{message}{levelname}',
style='{',
'%(message)s%(levelname)s%(module)s%(funcName)s%(lineno)d',
rename_fields={'levelname': 'severity'},
json_serializer=custom_json_serializer,
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)
timestamp='ts' if not LOG_JSON_FOR_CONSOLE else False,
)
handler.setFormatter(formatter)

View File

@@ -12,11 +12,8 @@ from server.auth.auth_error import (
)
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.saas_user_auth import SaasUserAuth, token_manager
from server.routes.auth import (
get_cookie_domain,
get_cookie_samesite,
set_response_cookie,
)
from server.routes.auth import set_response_cookie
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
@@ -93,8 +90,8 @@ class SetAuthCookieMiddleware:
if keycloak_auth_cookie:
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
)
return response
@@ -185,6 +182,10 @@ class SetAuthCookieMiddleware:
if path.startswith('/api/v1/webhooks/'):
return False
# Service API uses its own authentication (X-Service-API-Key header)
if path.startswith('/api/service/'):
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp

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

@@ -1,7 +1,9 @@
from datetime import UTC, datetime
from typing import cast
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, field_validator
from server.auth.saas_user_auth import SaasUserAuth
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
from storage.lite_llm_manager import LiteLlmManager
@@ -11,7 +13,8 @@ from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth import get_user_auth, get_user_id
from openhands.server.user_auth.user_auth import AuthType
# Helper functions for BYOR API key management
@@ -150,6 +153,16 @@ class MessageResponse(BaseModel):
message: str
class CurrentApiKeyResponse(BaseModel):
"""Response model for the current API key endpoint."""
id: int
name: str | None
org_id: str
user_id: str
auth_type: str
def api_key_to_response(key: ApiKey) -> ApiKeyResponse:
"""Convert an ApiKey model to an ApiKeyResponse."""
return ApiKeyResponse(
@@ -262,6 +275,46 @@ async def delete_api_key(
)
@api_router.get('/current', tags=['Keys'])
async def get_current_api_key(
request: Request,
user_id: str = Depends(get_user_id),
) -> CurrentApiKeyResponse:
"""Get information about the currently authenticated API key.
This endpoint returns metadata about the API key used for the current request,
including the org_id associated with the key. This is useful for API key
callers who need to know which organization context their key operates in.
Returns 400 if not authenticated via API key (e.g., using cookie auth).
"""
user_auth = await get_user_auth(request)
# Check if authenticated via API key
if user_auth.get_auth_type() != AuthType.BEARER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This endpoint requires API key authentication. Not available for cookie-based auth.',
)
# In SaaS context, bearer auth always produces SaasUserAuth
saas_user_auth = cast(SaasUserAuth, user_auth)
if saas_user_auth.api_key_org_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This API key was created before organization support. Please regenerate your API key to use this endpoint.',
)
return CurrentApiKeyResponse(
id=saas_user_auth.api_key_id,
name=saas_user_auth.api_key_name,
org_id=str(saas_user_auth.api_key_org_id),
user_id=user_id,
auth_type=saas_user_auth.auth_type.value,
)
@api_router.get('/llm/byor', tags=['Keys'])
async def get_llm_api_key_for_byor(
user_id: str = Depends(get_user_id),

View File

@@ -3,7 +3,7 @@ import json
import uuid
import warnings
from datetime import datetime, timezone
from typing import Annotated, Literal, Optional, cast
from typing import Annotated, Optional, cast
from urllib.parse import quote, urlencode
from uuid import UUID as parse_uuid
@@ -27,7 +27,7 @@ from server.auth.user.user_authorizer import (
depends_user_authorizer,
)
from server.config import sign_token
from server.constants import IS_FEATURE_ENV
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from server.services.org_invitation_service import (
EmailMismatchError,
@@ -37,12 +37,12 @@ from server.services.org_invitation_service import (
UserAlreadyMemberError,
)
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite, get_web_url
from sqlalchemy import select
from storage.database import a_session_maker
from storage.user import User
from storage.user_store import UserStore
from openhands.app_server.config import get_global_config
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import ProviderType, TokenResponse
@@ -77,7 +77,7 @@ def set_response_cookie(
signed_token = sign_token(cookie_data, config.jwt_secret.get_secret_value()) # type: ignore
# Set secure cookie with signed token
domain = get_cookie_domain(request)
domain = get_cookie_domain()
if domain:
response.set_cookie(
key='keycloak_auth',
@@ -85,7 +85,7 @@ def set_response_cookie(
domain=domain,
httponly=True,
secure=secure,
samesite=get_cookie_samesite(request),
samesite=get_cookie_samesite(),
)
else:
response.set_cookie(
@@ -93,30 +93,10 @@ def set_response_cookie(
value=signed_token,
httponly=True,
secure=secure,
samesite=get_cookie_samesite(request),
samesite=get_cookie_samesite(),
)
def get_cookie_domain(request: Request) -> str | None:
# for now just use the full hostname except for staging stacks.
return (
None
if not request.url.hostname
or request.url.hostname.endswith('staging.all-hands.dev')
else request.url.hostname
)
def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
return (
'lax'
if request.url.hostname == 'localhost'
or (request.url.hostname or '').endswith('staging.all-hands.dev')
else 'strict'
)
def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None]:
"""Extract redirect URL, reCAPTCHA token, and invitation token from OAuth state.
@@ -140,19 +120,6 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
return state, None, None
# Keep alias for backward compatibility
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
"""Extract redirect URL and reCAPTCHA token from OAuth state.
Deprecated: Use _extract_oauth_state instead.
Returns:
Tuple of (redirect_url, recaptcha_token). Token may be None.
"""
redirect_url, recaptcha_token, _ = _extract_oauth_state(state)
return redirect_url, recaptcha_token
@oauth_router.get('/keycloak/callback')
async def keycloak_callback(
request: Request,
@@ -183,10 +150,7 @@ async def keycloak_callback(
detail='Missing code in request params',
)
web_url = get_global_config().web_url
if not web_url:
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
web_url = f'{scheme}://{request.url.netloc}'
web_url = get_web_url(request)
redirect_uri = web_url + request.url.path
(
@@ -208,6 +172,23 @@ async def keycloak_callback(
authorization = await user_authorizer.authorize_user(user_info)
if not authorization.success:
# For duplicate_email errors, clean up the newly created Keycloak user
# (only if they're not already in our UserStore, i.e., they're a new user)
if authorization.error_detail == 'duplicate_email':
try:
existing_user = await UserStore.get_user_by_id(user_info.sub)
if not existing_user:
# New user created during OAuth should be deleted from Keycloak
await token_manager.delete_keycloak_user(user_info.sub)
logger.info(
f'Deleted orphaned Keycloak user {user_info.sub} '
'after duplicate_email rejection'
)
except Exception as e:
# Log but don't fail - user should still get 401 response
logger.warning(
f'Failed to clean up orphaned Keycloak user {user_info.sub}: {e}'
)
# Return unauthorized
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -313,7 +294,9 @@ async def keycloak_callback(
else:
raise
verification_redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
verification_redirect_url = (
f'{web_url}/login?email_verification_required=true&user_id={user_id}'
)
if rate_limited:
verification_redirect_url = f'{verification_redirect_url}&rate_limited=true'
@@ -474,9 +457,7 @@ async def keycloak_callback(
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:
encoded_redirect_url = quote(redirect_url, safe='')
tos_redirect_url = (
f'{request.base_url}accept-tos?redirect_url={encoded_redirect_url}'
)
tos_redirect_url = f'{web_url}/accept-tos?redirect_url={encoded_redirect_url}'
if invitation_token:
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
response = RedirectResponse(tos_redirect_url, status_code=302)
@@ -508,10 +489,9 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
status_code=status.HTTP_400_BAD_REQUEST,
content={'error': 'Missing code in request params'},
)
scheme = 'https'
if request.url.hostname == 'localhost':
scheme = 'http'
redirect_uri = f'{scheme}://{request.url.netloc}{request.url.path}'
web_url = get_web_url(request)
redirect_uri = web_url + request.url.path
logger.debug(f'code: {code}, redirect_uri: {redirect_uri}')
(
@@ -533,15 +513,14 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
)
redirect_url, _, _ = _extract_oauth_state(state)
return RedirectResponse(
redirect_url if redirect_url else request.base_url, status_code=302
)
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
@oauth_router.get('/github/callback')
async def github_dummy_callback(request: Request):
"""Callback for GitHub that just forwards the user to the app base URL."""
return RedirectResponse(request.base_url, status_code=302)
web_url = get_web_url(request)
return RedirectResponse(web_url, status_code=302)
@api_router.post('/authenticate')
@@ -563,8 +542,8 @@ async def authenticate(request: Request):
if keycloak_auth_cookie:
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
)
return response
@@ -588,7 +567,8 @@ async def accept_tos(request: Request):
# Get redirect URL from request body
body = await request.json()
redirect_url = body.get('redirect_url', str(request.base_url))
web_url = get_web_url(request)
redirect_url = body.get('redirect_url', str(web_url))
# Update user settings with TOS acceptance
accepted_tos: datetime = datetime.now(timezone.utc).replace(tzinfo=None)
@@ -618,7 +598,7 @@ 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=False if request.url.hostname == 'localhost' else True,
secure=not IS_LOCAL_ENV,
accepted_tos=True,
)
return response
@@ -635,8 +615,8 @@ async def logout(request: Request):
# Always delete the cookie regardless of what happens
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
)
# Try to properly logout from Keycloak, but don't fail if it doesn't work

View File

@@ -11,8 +11,8 @@ from integrations import stripe_service
from pydantic import BaseModel
from server.constants import STRIPE_API_KEY
from server.logger import logger
from server.utils.url_utils import get_web_url
from sqlalchemy import select
from starlette.datastructures import URL
from storage.billing_session import BillingSession
from storage.database import a_session_maker
from storage.lite_llm_manager import LiteLlmManager
@@ -151,7 +151,7 @@ async def create_customer_setup_session(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Could not find or create customer for user',
)
base_url = _get_base_url(request)
base_url = get_web_url(request)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_info['customer_id'],
mode='setup',
@@ -170,7 +170,7 @@ async def create_checkout_session(
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
await validate_billing_enabled()
base_url = _get_base_url(request)
base_url = get_web_url(request)
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
if not customer_info:
raise HTTPException(
@@ -198,8 +198,8 @@ async def create_checkout_session(
saved_payment_method_options={
'payment_method_save': 'enabled',
},
success_url=f'{base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
success_url=f'{base_url}/api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{base_url}/api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
)
logger.info(
'created_stripe_checkout_session',
@@ -300,7 +300,7 @@ async def success_callback(session_id: str, request: Request):
await session.commit()
return RedirectResponse(
f'{_get_base_url(request)}settings/billing?checkout=success', status_code=302
f'{get_web_url(request)}/settings/billing?checkout=success', status_code=302
)
@@ -325,17 +325,9 @@ async def cancel_callback(session_id: str, request: Request):
)
billing_session.status = 'cancelled'
billing_session.updated_at = datetime.now(UTC)
session.merge(billing_session)
await session.merge(billing_session)
await session.commit()
return RedirectResponse(
f'{_get_base_url(request)}settings/billing?checkout=cancel', status_code=302
f'{get_web_url(request)}/settings/billing?checkout=cancel', status_code=302
)
def _get_base_url(request: Request) -> URL:
# Never send any part of the credit card process over a non secure connection
base_url = request.base_url
if base_url.hostname != 'localhost':
base_url = base_url.replace(scheme='https')
return base_url

View File

@@ -7,8 +7,10 @@ from pydantic import BaseModel, field_validator
from server.auth.constants import KEYCLOAK_CLIENT_ID
from server.auth.keycloak_manager import get_keycloak_admin
from server.auth.saas_user_auth import SaasUserAuth
from server.constants import IS_LOCAL_ENV
from server.routes.auth import set_response_cookie
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_web_url
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
@@ -87,7 +89,7 @@ async def update_email(
response=response,
keycloak_access_token=user_auth.access_token.get_secret_value(),
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
secure=False if request.url.hostname == 'localhost' else True,
secure=not IS_LOCAL_ENV,
accepted_tos=user_auth.accepted_tos or False,
)
@@ -156,8 +158,8 @@ async def verified_email(request: Request):
await user_auth.refresh() # refresh so access token has updated email
user_auth.email_verified = True
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
redirect_uri = f'{scheme}://{request.url.netloc}/settings/user'
redirect_uri = f'{get_web_url(request)}/settings/user'
response = RedirectResponse(redirect_uri, status_code=302)
# need to set auth cookie to the new tokens
@@ -180,11 +182,10 @@ async def verified_email(request: Request):
async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False):
keycloak_admin = get_keycloak_admin()
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
if is_auth_flow:
redirect_uri = f'{scheme}://{request.url.netloc}/login?email_verified=true'
redirect_uri = f'{get_web_url(request)}/login?email_verified=true'
else:
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
redirect_uri = f'{get_web_url(request)}/api/email/verified'
logger.info(f'Redirect URI: {redirect_uri}')
await keycloak_admin.a_send_verify_email(
user_id=user_id,

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

@@ -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

@@ -6,6 +6,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from server.utils.url_utils import get_web_url
from storage.api_key_store import ApiKeyStore
from storage.device_code_store import DeviceCodeStore
@@ -93,7 +94,7 @@ async def device_authorization(
expires_in=DEVICE_CODE_EXPIRES_IN,
)
base_url = str(http_request.base_url).rstrip('/')
base_url = get_web_url(http_request)
verification_uri = f'{base_url}/oauth/device/verify'
verification_uri_complete = (
f'{verification_uri}?user_code={device_code_entry.user_code}'

View File

@@ -120,3 +120,18 @@ class BatchInvitationResponse(BaseModel):
successful: list[InvitationResponse]
failed: list[InvitationFailure]
class AcceptInvitationRequest(BaseModel):
"""Request model for accepting an invitation via POST."""
token: str
class AcceptInvitationResponse(BaseModel):
"""Response model for successful invitation acceptance."""
success: bool
org_id: str
org_name: str
role: str

View File

@@ -5,6 +5,8 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from server.routes.org_invitation_models import (
AcceptInvitationRequest,
AcceptInvitationResponse,
BatchInvitationResponse,
EmailMismatchError,
InsufficientPermissionError,
@@ -17,10 +19,11 @@ from server.routes.org_invitation_models import (
)
from server.services.org_invitation_service import OrgInvitationService
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.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth.user_auth import get_user_auth
# Router for invitation operations on an organization (requires org_id)
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
@@ -123,70 +126,93 @@ async def create_invitation(
@accept_router.get('/accept')
async def accept_invitation(
async def accept_invitation_redirect(
token: str,
request: Request,
):
"""Accept an organization invitation via token.
"""Redirect invitation acceptance to frontend.
This endpoint is accessed via the link in the invitation email.
It always redirects to the home page with the invitation token,
allowing the frontend to handle the acceptance flow via a modal.
Flow:
1. If user is authenticated: Accept invitation directly and redirect to home
2. If user is not authenticated: Redirect to login page with invitation token
- Frontend stores token and includes it in OAuth state during login
- After authentication, keycloak_callback processes the invitation
This approach works with SameSite='strict' cookies because:
- Cross-site navigation (clicking email link) doesn't send cookies
- But same-origin POST requests (from frontend) DO send cookies
Args:
token: The invitation token from the email link
request: FastAPI request
Returns:
RedirectResponse: Redirect to home page on success, or login page if not authenticated,
or home page with error query params on failure
RedirectResponse: Redirect to home page with invitation_token query param
"""
base_url = str(request.base_url).rstrip('/')
# Try to get user_id from auth (may not be authenticated)
user_id = None
try:
user_auth = await get_user_auth(request)
if user_auth:
user_id = await user_auth.get_user_id()
except Exception:
pass
logger.info(
'Invitation accept: redirecting to frontend for acceptance',
extra={'token_prefix': token[:10] + '...'},
)
if not user_id:
# User not authenticated - redirect to login page with invitation token
# Frontend will store the token and include it in OAuth state during login
logger.info(
'Invitation accept: redirecting unauthenticated user to login',
extra={'token_prefix': token[:10] + '...'},
)
login_url = f'{base_url}/login?invitation_token={token}'
return RedirectResponse(login_url, status_code=302)
return RedirectResponse(f'{base_url}/?invitation_token={token}', status_code=302)
@accept_router.post('/accept', response_model=AcceptInvitationResponse)
async def accept_invitation(
request_data: AcceptInvitationRequest,
user_id: str = Depends(get_user_id),
):
"""Accept an organization invitation via authenticated POST request.
This endpoint is called by the frontend after displaying the acceptance modal.
Requires authentication - cookies are sent because this is a same-origin request.
Args:
request_data: Contains the invitation token
user_id: Authenticated user ID (from dependency)
Returns:
AcceptInvitationResponse: Success response with organization details
Raises:
HTTPException 400: Invalid or expired token
HTTPException 403: Email mismatch
HTTPException 409: User already a member
"""
token = request_data.token
# User is authenticated - process the invitation directly
try:
await OrgInvitationService.accept_invitation(token, UUID(user_id))
invitation = await OrgInvitationService.accept_invitation(token, UUID(user_id))
# Get organization and role details for response
org = await OrgStore.get_org_by_id(invitation.org_id)
role = await RoleStore.get_role_by_id(invitation.role_id)
logger.info(
'Invitation accepted successfully',
'Invitation accepted via API',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'org_id': str(invitation.org_id),
},
)
# Redirect to home page on success
return RedirectResponse(f'{base_url}/', status_code=302)
return AcceptInvitationResponse(
success=True,
org_id=str(invitation.org_id),
org_name=org.name if org else '',
role=role.name if role else '',
)
except InvitationExpiredError:
logger.warning(
'Invitation accept failed: expired',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
return RedirectResponse(f'{base_url}/?invitation_expired=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='invitation_expired',
)
except InvitationInvalidError as e:
logger.warning(
@@ -197,14 +223,20 @@ async def accept_invitation(
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?invitation_invalid=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='invitation_invalid',
)
except UserAlreadyMemberError:
logger.info(
'Invitation accept: user already member',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
return RedirectResponse(f'{base_url}/?already_member=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='already_member',
)
except EmailMismatchError as e:
logger.warning(
@@ -215,15 +247,21 @@ async def accept_invitation(
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?email_mismatch=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='email_mismatch',
)
except Exception as e:
logger.exception(
'Unexpected error accepting invitation',
'Unexpected error accepting invitation via API',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?invitation_error=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)

View File

@@ -241,7 +241,6 @@ class OrgUpdate(BaseModel):
enable_proactive_conversation_starters: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
mcp_config: dict | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = Field(default=None, gt=0)
enable_solvability_analysis: bool | None = None
@@ -484,3 +483,72 @@ class OrgAppSettingsUpdate(BaseModel):
if v is not None and v <= 0:
raise ValueError('max_budget_per_task must be greater than 0')
return v
VALID_GIT_PROVIDERS = {'github', 'gitlab', 'bitbucket'}
class GitOrgClaimRequest(BaseModel):
"""Request model for claiming a Git organization."""
provider: str
git_organization: str
@field_validator('provider')
@classmethod
def validate_provider(cls, v: str) -> str:
v = v.lower().strip()
if v not in VALID_GIT_PROVIDERS:
raise ValueError(
f'Invalid provider: "{v}". Must be one of: {", ".join(sorted(VALID_GIT_PROVIDERS))}'
)
return v
@field_validator('git_organization')
@classmethod
def validate_git_organization(cls, v: str) -> str:
v = v.strip().lower()
if not v:
raise ValueError('git_organization must not be empty')
return v
class GitOrgClaimResponse(BaseModel):
"""Response model for a Git organization claim."""
id: str
org_id: str
provider: str
git_organization: str
claimed_by: str
claimed_at: str
class GitOrgAlreadyClaimedError(Exception):
"""Raised when a Git organization is already claimed by another OpenHands org."""
def __init__(self, provider: str, git_organization: str):
self.provider = provider
self.git_organization = git_organization
super().__init__(
f'Git organization "{git_organization}" on {provider} is already claimed by another organization'
)
class OrgMemberFinancialResponse(BaseModel):
"""Financial data for a single organization member."""
user_id: str
email: str | None
lifetime_spend: float # Total amount spent (from LiteLLM)
current_budget: float # Remaining budget (max_budget - spend)
max_budget: float | None # Total allocated budget (None = unlimited)
class OrgMemberFinancialPage(BaseModel):
"""Paginated response for organization member financial data."""
items: list[OrgMemberFinancialResponse]
current_page: int = 1
per_page: int = 10
next_page_id: str | None = None

View File

@@ -4,11 +4,15 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from server.auth.authorization import (
Permission,
require_financial_data_access,
require_permission,
)
from server.email_validation import get_admin_user_id
from server.routes.org_models import (
CannotModifySelfError,
GitOrgAlreadyClaimedError,
GitOrgClaimRequest,
GitOrgClaimResponse,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
@@ -22,6 +26,7 @@ from server.routes.org_models import (
OrgDatabaseError,
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgMemberFinancialPage,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
@@ -42,7 +47,10 @@ from server.services.org_llm_settings_service import (
OrgLLMSettingsService,
OrgLLMSettingsServiceInjector,
)
from server.services.org_member_financial_service import OrgMemberFinancialService
from server.services.org_member_service import OrgMemberService
from sqlalchemy.exc import IntegrityError
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_service import OrgService
from storage.user_store import UserStore
@@ -68,7 +76,7 @@ async def list_user_orgs(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
Query(title='The max number of results in the page', gt=0, le=100),
] = 100,
user_id: str = Depends(get_user_id),
) -> OrgPage:
@@ -734,7 +742,7 @@ async def get_org_members(
Query(
title='The max number of results in the page',
gt=0,
lte=100,
le=100,
),
] = 10,
email: Annotated[
@@ -883,6 +891,104 @@ async def get_org_members_count(
)
@org_router.get(
'/{org_id}/members/financial',
response_model=OrgMemberFinancialPage,
)
async def get_org_members_financial(
org_id: UUID,
page_id: Annotated[
str | None,
Query(
title='Pagination offset encoded as string',
description='Offset for pagination (e.g., "0", "10", "20")',
),
] = None,
limit: Annotated[
int,
Query(
title='Maximum items per page',
gt=0,
le=100,
),
] = 10,
email: Annotated[
str | None,
Query(
title='Filter members by email (case-insensitive partial match)',
min_length=1,
max_length=255,
),
] = None,
user_id: str = Depends(require_financial_data_access),
) -> OrgMemberFinancialPage:
"""Get paginated financial data for organization members.
Returns financial information (lifetime spend, current budget) for all members
within the specified organization. Access is restricted to:
- Organization Admins
- Organization Owners
- OpenHands members (users with @openhands.dev emails)
Args:
org_id: Organization ID (UUID)
page_id: Optional pagination offset encoded as string
limit: Maximum items per page (1-100, default 10)
email: Optional email filter (case-insensitive partial match)
user_id: Authenticated user ID (injected by require_financial_data_access)
Returns:
OrgMemberFinancialPage: Paginated response with member financial data
- items: List of members with user_id, email, lifetime_spend,
current_budget, and max_budget
- current_page: Current page number (1-indexed)
- per_page: Items per page
- next_page_id: Offset for next page, or None if no more pages
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks access (not admin/owner and not @openhands.dev)
HTTPException: 400 if page_id is invalid
HTTPException: 500 if retrieval fails
"""
logger.info(
'Getting financial data for organization members',
extra={
'org_id': str(org_id),
'user_id': user_id,
'page_id': page_id,
'limit': limit,
'email_filter': email,
},
)
try:
return await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id=page_id,
limit=limit,
email_filter=email,
)
except ValueError as e:
logger.warning(
'Invalid page_id for financial data request',
extra={'org_id': str(org_id), 'page_id': page_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception:
logger.exception(
'Error retrieving organization member financial data',
extra={'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve member financial data',
)
@org_router.delete('/{org_id}/members/{user_id}')
async def remove_org_member(
org_id: UUID,
@@ -1111,3 +1217,181 @@ async def update_org_member(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)
@org_router.get(
'/{org_id}/git-claims',
response_model=list[GitOrgClaimResponse],
)
async def get_git_claims(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> list[GitOrgClaimResponse]:
"""Get all Git organization claims for an OpenHands organization.
Only admin and owner roles can view Git organization claims.
Args:
org_id: OpenHands organization UUID
user_id: Authenticated user ID (injected by permission check)
Returns:
List of GitOrgClaimResponse with claim details
"""
try:
claims = await OrgGitClaimStore.get_claims_by_org_id(org_id=org_id)
return [
GitOrgClaimResponse(
id=str(claim.id),
org_id=str(claim.org_id),
provider=claim.provider,
git_organization=claim.git_organization,
claimed_by=str(claim.claimed_by),
claimed_at=claim.claimed_at.isoformat(),
)
for claim in claims
]
except Exception:
logger.exception('Error fetching Git organization claims')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to fetch Git organization claims',
)
@org_router.post(
'/{org_id}/git-claims',
response_model=GitOrgClaimResponse,
status_code=status.HTTP_201_CREATED,
)
async def claim_git_organization(
org_id: UUID,
request: GitOrgClaimRequest,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> GitOrgClaimResponse:
"""Claim a Git organization for an OpenHands organization.
Only admin and owner roles can claim Git organizations.
A Git organization can only be claimed by one OpenHands organization at a time.
Args:
org_id: OpenHands organization UUID
request: Claim request with provider and git_organization
user_id: Authenticated user ID (injected by permission check)
Returns:
GitOrgClaimResponse with the created claim details
Raises:
HTTPException 409: If the Git organization is already claimed
HTTPException 403: If user lacks permission
"""
try:
# Check if this Git org is already claimed (early feedback for the common case)
existing_claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider=request.provider,
git_organization=request.git_organization,
)
if existing_claim:
raise GitOrgAlreadyClaimedError(
provider=request.provider,
git_organization=request.git_organization,
)
# Create the claim — the DB unique constraint handles the race condition
# where two concurrent requests both pass the check above.
claim = await OrgGitClaimStore.create_claim(
org_id=org_id,
provider=request.provider,
git_organization=request.git_organization,
claimed_by=UUID(user_id),
)
return GitOrgClaimResponse(
id=str(claim.id),
org_id=str(claim.org_id),
provider=claim.provider,
git_organization=claim.git_organization,
claimed_by=str(claim.claimed_by),
claimed_at=claim.claimed_at.isoformat(),
)
except GitOrgAlreadyClaimedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
except IntegrityError as e:
# Only treat the unique constraint violation as a duplicate claim.
# Other integrity errors (e.g. FK violations) should surface as 500s.
if 'uq_provider_git_org' in str(e.orig):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(
GitOrgAlreadyClaimedError(
provider=request.provider,
git_organization=request.git_organization,
)
),
)
logger.exception('Integrity error claiming Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to claim Git organization',
)
except Exception:
logger.exception('Error claiming Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to claim Git organization',
)
@org_router.delete(
'/{org_id}/git-claims/{claim_id}',
status_code=status.HTTP_200_OK,
)
async def disconnect_git_organization(
org_id: UUID,
claim_id: UUID,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> dict:
"""Remove a Git organization claim from an OpenHands organization.
Only admin and owner roles can disconnect Git organization claims.
Args:
org_id: OpenHands organization UUID
claim_id: Claim UUID to remove
user_id: Authenticated user ID (injected by permission check)
Returns:
dict: Confirmation message on successful deletion
Raises:
HTTPException 404: If the claim is not found for this organization
HTTPException 403: If user lacks permission
"""
try:
deleted = await OrgGitClaimStore.delete_claim(
claim_id=claim_id,
org_id=org_id,
)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Git organization claim not found',
)
return {'message': 'Git organization claim removed successfully'}
except HTTPException:
raise
except Exception:
logger.exception('Error disconnecting Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to disconnect Git organization',
)

View File

@@ -0,0 +1,270 @@
"""
Service API routes for internal service-to-service communication.
This module provides endpoints for trusted internal services (e.g., automations service)
to perform privileged operations like creating API keys on behalf of users.
Authentication is via a shared secret (X-Service-API-Key header) configured
through the AUTOMATIONS_SERVICE_KEY environment variable.
"""
import os
from uuid import UUID
from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel, field_validator
from storage.api_key_store import ApiKeyStore
from storage.org_member_store import OrgMemberStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
# Environment variable for the service API key
AUTOMATIONS_SERVICE_KEY = os.getenv('AUTOMATIONS_SERVICE_KEY', '').strip()
service_router = APIRouter(prefix='/api/service', tags=['Service'])
class CreateUserApiKeyRequest(BaseModel):
"""Request model for creating an API key on behalf of a user."""
name: str # Required - used to identify the key
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('name is required and cannot be empty')
return v.strip()
class CreateUserApiKeyResponse(BaseModel):
"""Response model for created API key."""
key: str
user_id: str
org_id: str
name: str
class ServiceInfoResponse(BaseModel):
"""Response model for service info endpoint."""
service: str
authenticated: bool
async def validate_service_api_key(
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> str:
"""
Validate the service API key from the request header.
Args:
x_service_api_key: The service API key from the X-Service-API-Key header
Returns:
str: Service identifier for audit logging
Raises:
HTTPException: 401 if key is missing or invalid
HTTPException: 503 if service auth is not configured
"""
if not AUTOMATIONS_SERVICE_KEY:
logger.warning(
'Service authentication not configured (AUTOMATIONS_SERVICE_KEY not set)'
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Service authentication not configured',
)
if not x_service_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='X-Service-API-Key header is required',
)
if x_service_api_key != AUTOMATIONS_SERVICE_KEY:
logger.warning('Invalid service API key attempted')
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid service API key',
)
return 'automations-service'
@service_router.get('/health')
async def service_health() -> dict:
"""Health check endpoint for the service API.
This endpoint does not require authentication and can be used
to verify the service routes are accessible.
"""
return {
'status': 'ok',
'service_auth_configured': bool(AUTOMATIONS_SERVICE_KEY),
}
@service_router.post('/users/{user_id}/orgs/{org_id}/api-keys')
async def get_or_create_api_key_for_user(
user_id: str,
org_id: UUID,
request: CreateUserApiKeyRequest,
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> CreateUserApiKeyResponse:
"""
Get or create an API key for a user on behalf of the automations service.
If a key with the given name already exists for the user/org and is not expired,
returns the existing key. Otherwise, creates a new key.
The created/returned keys are system keys and are:
- Not visible to the user in their API keys list
- Not deletable by the user
- Never expire
Args:
user_id: The user ID
org_id: The organization ID
request: Request body containing name (required)
x_service_api_key: Service API key header for authentication
Returns:
CreateUserApiKeyResponse: The API key and metadata
Raises:
HTTPException: 401 if service key is invalid
HTTPException: 404 if user not found
HTTPException: 403 if user is not a member of the specified org
"""
# Validate service API key
service_id = await validate_service_api_key(x_service_api_key)
# Verify user exists
user = await UserStore.get_user_by_id(user_id)
if not user:
logger.warning(
'Service attempted to create key for non-existent user',
extra={'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'User {user_id} not found',
)
# Verify user is a member of the specified org
org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id))
if not org_member:
logger.warning(
'Service attempted to create key for user not in org',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'User {user_id} is not a member of org {org_id}',
)
# Get or create the system API key
api_key_store = ApiKeyStore.get_instance()
try:
api_key = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name=request.name,
)
except Exception as e:
logger.exception(
'Failed to get or create system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to get or create API key',
)
logger.info(
'Service created API key for user',
extra={
'service_id': service_id,
'user_id': user_id,
'org_id': str(org_id),
'key_name': request.name,
},
)
return CreateUserApiKeyResponse(
key=api_key,
user_id=user_id,
org_id=str(org_id),
name=request.name,
)
@service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}')
async def delete_user_api_key(
user_id: str,
org_id: UUID,
key_name: str,
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> dict:
"""
Delete a system API key created by the service.
This endpoint allows the automations service to clean up API keys
it previously created for users.
Args:
user_id: The user ID
org_id: The organization ID
key_name: The name of the key to delete (without __SYSTEM__: prefix)
x_service_api_key: Service API key header for authentication
Returns:
dict: Success message
Raises:
HTTPException: 401 if service key is invalid
HTTPException: 404 if key not found
"""
# Validate service API key
service_id = await validate_service_api_key(x_service_api_key)
api_key_store = ApiKeyStore.get_instance()
# Delete the key by name (wrap with system key prefix since service creates system keys)
system_key_name = api_key_store.make_system_key_name(key_name)
success = await api_key_store.delete_api_key_by_name(
user_id=user_id,
org_id=org_id,
name=system_key_name,
allow_system=True,
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}',
)
logger.info(
'Service deleted API key for user',
extra={
'service_id': service_id,
'user_id': user_id,
'org_id': str(org_id),
'key_name': key_name,
},
)
return {'message': 'API key deleted successfully'}

View File

@@ -7,8 +7,10 @@ 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,
)
from openhands.integrations.service_types import (
Branch,
@@ -22,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,
@@ -44,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),
@@ -67,7 +73,59 @@ async def saas_get_user_installations(
)
@saas_user_router.get('/repositories', response_model=list[Repository])
@saas_user_router.get('/git-organizations')
async def saas_get_user_git_organizations(
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),
):
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value={},
)
if retval is not None:
return retval
# _check_idp returned None (tokens refreshed on Keycloak side),
# but provider_tokens is still None for this request.
return JSONResponse(
content='Git provider token required.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
# SaaS users sign in with one provider at a time
provider = next(iter(provider_tokens))
if provider == ProviderType.GITHUB:
orgs = await client.get_github_organizations()
elif provider == ProviderType.GITLAB:
orgs = await client.get_gitlab_groups()
elif provider == ProviderType.BITBUCKET:
orgs = await client.get_bitbucket_workspaces()
else:
return JSONResponse(
content=f"Provider {provider.value} doesn't support git organizations",
status_code=status.HTTP_400_BAD_REQUEST,
)
return {
'provider': provider.value,
'organizations': orgs,
}
@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,
@@ -98,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,106 @@
"""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 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
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()
@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)
return JSONResponse( # type: ignore[return-value]
content=user_info.model_dump(mode='json', context={'expose_secrets': True})
)
return user_info
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

@@ -365,14 +365,12 @@ class OrgInvitationService:
'Failed to set up organization access. Please try again.'
)
# Step 5: Add user to organization
from storage.org_member_store import OrgMemberStore as OMS
org_member_kwargs = OMS.get_kwargs_from_settings(settings)
# Don't override with org defaults - use invitation-specified role
org_member_kwargs.pop('llm_model', None)
org_member_kwargs.pop('llm_base_url', None)
# Step 4.5: Fetch organization to get its LLM settings
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)
llm_api_key = (
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
@@ -384,6 +382,9 @@ 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,
)
# Step 6: Mark invitation as accepted

View File

@@ -0,0 +1,171 @@
"""Service for managing organization member financial data."""
from uuid import UUID
import httpx
from server.routes.org_models import (
OrgMemberFinancialPage,
OrgMemberFinancialResponse,
)
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
class OrgMemberFinancialService:
"""Service for organization member financial data operations."""
@staticmethod
async def get_org_members_financial_data(
org_id: UUID,
page_id: str | None = None,
limit: int = 10,
email_filter: str | None = None,
) -> OrgMemberFinancialPage:
"""Get paginated financial data for organization members.
Fetches member list from database and joins with financial data from LiteLLM.
Args:
org_id: Organization UUID
page_id: Offset encoded as string (e.g., "0", "10", "20")
limit: Maximum items per page (default 10)
email_filter: Optional case-insensitive partial email match
Returns:
OrgMemberFinancialPage: Paginated response with financial data
Raises:
ValueError: If page_id is invalid
"""
# Parse page_id to get offset
offset = 0
if page_id is not None:
try:
offset = int(page_id)
if offset < 0:
raise ValueError('page_id must be non-negative')
except ValueError as e:
raise ValueError(f'Invalid page_id: {page_id}') from e
# Fetch paginated members from database
members, total_count = await OrgMemberStore.get_org_members_paginated(
org_id=org_id,
offset=offset,
limit=limit,
email_filter=email_filter,
)
if not members:
return OrgMemberFinancialPage(
items=[],
current_page=(offset // limit) + 1,
per_page=limit,
next_page_id=None,
)
# Fetch financial data from LiteLLM for the entire team
# This is a single API call that returns all team members' data
try:
financial_data = await LiteLlmManager.get_team_members_financial_data(
str(org_id)
)
except httpx.HTTPStatusError as e:
# Re-raise auth errors - these indicate configuration issues that need fixing
if e.response.status_code in (401, 403):
logger.error(
'LiteLLM authentication/authorization failed',
extra={
'org_id': str(org_id),
'status_code': e.response.status_code,
'error': str(e),
},
)
raise
# For other HTTP errors (404, 500, etc.), use graceful degradation
logger.warning(
'Failed to fetch financial data from LiteLLM',
extra={
'org_id': str(org_id),
'status_code': e.response.status_code,
'error_type': type(e).__name__,
'error': str(e),
},
)
financial_data = {}
except Exception as e:
# For network errors, timeouts, etc., use graceful degradation
logger.warning(
'Failed to fetch financial data from LiteLLM',
extra={
'org_id': str(org_id),
'error_type': type(e).__name__,
'error': str(e),
},
)
financial_data = {}
# Extract team-level data for shared budget calculation
team_spend = financial_data.get('team_spend', 0) or 0
members_financial = financial_data.get('members', {})
# Build response items by joining DB members with LiteLLM financial data
items: list[OrgMemberFinancialResponse] = []
for member in members:
user = member.user
user_id_str = str(member.user_id)
# Get financial data for this user (or defaults if not found)
user_financial = members_financial.get(user_id_str, {})
individual_spend = user_financial.get('spend', 0) or 0
max_budget = user_financial.get('max_budget')
uses_shared_budget = user_financial.get('uses_shared_budget', False)
# Calculate current budget (remaining)
# For shared team budgets, use team_spend to calculate remaining budget
# This ensures all members see the same remaining budget
if max_budget is not None:
if uses_shared_budget:
# Shared budget - use team's total spend
current_budget = max(max_budget - team_spend, 0)
else:
# Individual budget - use individual spend
current_budget = max(max_budget - individual_spend, 0)
else:
# If no max_budget, current_budget is unlimited (represented as 0)
current_budget = 0
items.append(
OrgMemberFinancialResponse(
user_id=user_id_str,
email=user.email if user else None,
lifetime_spend=individual_spend,
current_budget=current_budget,
max_budget=max_budget,
)
)
# Calculate current page (1-indexed)
current_page = (offset // limit) + 1
# Calculate next_page_id
next_offset = offset + limit
next_page_id = str(next_offset) if next_offset < total_count else None
logger.debug(
'OrgMemberFinancialService:get_org_members_financial_data:success',
extra={
'org_id': str(org_id),
'items_count': len(items),
'current_page': current_page,
'total_count': total_count,
},
)
return OrgMemberFinancialPage(
items=items,
current_page=current_page,
per_page=limit,
next_page_id=next_page_id,
)

View File

@@ -0,0 +1,171 @@
"""Implementation of SharedEventService for AWS S3.
This implementation provides read-only access to events from shared conversations:
- Validates that the conversation is shared before returning events
- Uses existing EventService for actual event retrieval
- Uses SharedConversationInfoService for shared conversation validation
Uses role-based authentication (no credentials needed).
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, AsyncGenerator
from uuid import UUID
import boto3
from fastapi import Request
from pydantic import Field
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoService,
)
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event.aws_event_service import AwsEventService
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
logger = logging.getLogger(__name__)
@dataclass
class AwsSharedEventService(SharedEventService):
"""Implementation of SharedEventService for AWS S3 that validates shared access.
Uses role-based authentication (no credentials needed).
"""
shared_conversation_info_service: SharedConversationInfoService
s3_client: Any
bucket_name: str
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
return None
return AwsEventService(
s3_client=self.s3_client,
bucket_name=self.bucket_name,
prefix=Path('users'),
user_id=shared_conversation_info.created_by_user_id,
app_conversation_info_service=None,
app_conversation_info_load_tasks={},
)
async def get_shared_event(
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
return None
# If conversation is shared, get the event
return await event_service.get_event(conversation_id, event_id)
async def search_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await event_service.search_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
async def count_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return 0
# If conversation is shared, count events for this conversation
return await event_service.count_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
)
class AwsSharedEventServiceInjector(SharedEventServiceInjector):
bucket_name: str | None = Field(
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import get_db_session
async with get_db_session(state, request) as db_session:
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
bucket_name = self.bucket_name
if bucket_name is None:
raise ValueError(
'bucket_name is required. Set FILE_STORE_PATH environment variable.'
)
# Use role-based authentication - boto3 will automatically
# use IAM role credentials when running in AWS
s3_client = boto3.client(
's3',
endpoint_url=os.getenv('AWS_S3_ENDPOINT'),
)
service = AwsSharedEventService(
shared_conversation_info_service=shared_conversation_info_service,
s3_client=s3_client,
bucket_name=bucket_name,
)
yield service

View File

@@ -0,0 +1,143 @@
"""Implementation of SharedEventService.
This implementation provides read-only access to events from shared conversations:
- Validates that the conversation is shared before returning events
- Uses existing EventService for actual event retrieval
- Uses SharedConversationInfoService for shared conversation validation
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoService,
)
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.config import get_global_config
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event.filesystem_event_service import FilesystemEventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
logger = logging.getLogger(__name__)
@dataclass
class FilesystemSharedEventService(SharedEventService):
"""Implementation of SharedEventService that validates shared access."""
shared_conversation_info_service: SharedConversationInfoService
persistence_dir: Path
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
return None
return FilesystemEventService(
prefix=self.persistence_dir,
user_id=shared_conversation_info.created_by_user_id,
app_conversation_info_service=None,
app_conversation_info_load_tasks={},
)
async def get_shared_event(
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
return None
# If conversation is shared, get the event
return await event_service.get_event(conversation_id, event_id)
async def search_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await event_service.search_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
async def count_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return 0
# If conversation is shared, count events for this conversation
return await event_service.count_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
)
class FilesystemSharedEventServiceInjector(SharedEventServiceInjector):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import get_db_session
async with get_db_session(state, request) as db_session:
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
service = FilesystemSharedEventService(
shared_conversation_info_service=shared_conversation_info_service,
persistence_dir=get_global_config().persistence_dir,
)
yield service

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
@@ -60,7 +60,7 @@ async def search_shared_conversations(
Query(
title='The max number of results in the page',
gt=0,
lte=100,
le=100,
),
] = 100,
include_sub_conversations: Annotated[
@@ -72,8 +72,6 @@ async def search_shared_conversations(
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> SharedConversationPage:
"""Search / List shared conversations."""
assert limit > 0
assert limit <= 100
return await shared_conversation_service.search_shared_conversation_info(
title__contains=title__contains,
created_at__gte=created_at__gte,
@@ -127,7 +125,11 @@ async def batch_get_shared_conversations(
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> list[SharedConversation | None]:
"""Get a batch of shared conversations given their ids. Return None for any missing or non-shared."""
assert len(ids) <= 100
if len(ids) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 conversations at once, got {len(ids)}',
)
uuids = [UUID(id_) for id_ in ids]
shared_conversation_info = (
await shared_conversation_service.batch_get_shared_conversation_info(uuids)

View File

@@ -4,20 +4,52 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventServiceInjector,
from fastapi import APIRouter, Depends, HTTPException, Query
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
)
from server.sharing.shared_event_service import SharedEventService
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.utils.environment import StorageProvider, get_storage_provider
def get_shared_event_service_injector() -> SharedEventServiceInjector:
"""Get the appropriate SharedEventServiceInjector based on configuration.
Uses get_storage_provider() to determine the storage backend.
See openhands.utils.environment for supported environment variables.
Note: Shared events only support AWS and GCP storage. Filesystem storage
falls back to GCP for shared events.
"""
provider = get_storage_provider()
if provider == StorageProvider.AWS:
from server.sharing.aws_shared_event_service import (
AwsSharedEventServiceInjector,
)
return AwsSharedEventServiceInjector()
elif provider == StorageProvider.FILESYSTEM:
from server.sharing.filesystem_shared_event_service import (
FilesystemSharedEventServiceInjector,
)
return FilesystemSharedEventServiceInjector()
else:
# GCP is the default for shared events (including filesystem fallback)
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventServiceInjector,
)
return GoogleCloudSharedEventServiceInjector()
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
shared_event_service_dependency = Depends(
GoogleCloudSharedEventServiceInjector().depends
)
shared_event_service_dependency = Depends(get_shared_event_service_injector().depends)
# Read methods
@@ -51,13 +83,11 @@ async def search_shared_events(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
Query(title='The max number of results in the page', gt=0, le=100),
] = 100,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> EventPage:
"""Search / List events for a shared conversation."""
assert limit > 0
assert limit <= 100
return await shared_event_service.search_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
@@ -108,7 +138,11 @@ async def batch_get_shared_events(
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> list[Event | None]:
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
assert len(id) <= 100
if len(id) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 events at once, got {len(id)}',
)
event_ids = [UUID(id_) for id_ in id]
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids

View File

@@ -119,6 +119,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sandbox_id__eq: str | None = None,
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
@@ -141,6 +142,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
sandbox_id__eq=sandbox_id__eq,
)
# Add sort order
@@ -198,6 +200,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sandbox_id__eq: str | None = None,
) -> int:
"""Count conversations matching the given filters with SAAS metadata."""
query = (
@@ -220,6 +223,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
sandbox_id__eq=sandbox_id__eq,
)
result = await self.db_session.execute(query)
@@ -234,6 +238,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sandbox_id__eq: str | None = None,
):
"""Apply filters to query that includes SAAS metadata."""
# Apply the same filters as the base class
@@ -259,6 +264,9 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
StoredConversationMetadata.last_updated_at < updated_at__lt
)
if sandbox_id__eq is not None:
conditions.append(StoredConversationMetadata.sandbox_id == sandbox_id__eq)
if conditions:
query = query.where(*conditions)
return query
@@ -334,7 +342,10 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
await super().save_app_conversation_info(info)
# Get current user_id for SAAS metadata
# Fall back to info.created_by_user_id for webhook callbacks (which use ADMIN context)
user_id_str = await self.user_context.get_user_id()
if not user_id_str and info.created_by_user_id:
user_id_str = info.created_by_user_id
if user_id_str:
# Convert string user_id to UUID
user_id_uuid = UUID(user_id_str)
@@ -343,6 +354,20 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
user = result.scalar_one_or_none()
assert user
# Determine org_id: prefer API key's org_id if authenticated via API key
org_id = user.current_org_id # Default fallback
if hasattr(self.user_context, 'user_auth'):
user_auth = self.user_context.user_auth
if hasattr(user_auth, 'get_api_key_org_id'):
api_key_org_id = user_auth.get_api_key_org_id()
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)
@@ -351,16 +376,15 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
existing_saas_metadata = result.scalar_one_or_none()
assert existing_saas_metadata is None or (
existing_saas_metadata.user_id == user_id_uuid
and existing_saas_metadata.org_id == user.current_org_id
and existing_saas_metadata.org_id == org_id
)
if not existing_saas_metadata:
# Create new SAAS metadata
# Set org_id to user_id as specified in requirements
# Create new SAAS metadata with the determined org_id
saas_metadata = StoredConversationMetadataSaas(
conversation_id=str(info.id),
user_id=user_id_uuid,
org_id=user.current_org_id,
org_id=org_id,
)
self.db_session.add(saas_metadata)

View File

@@ -0,0 +1,172 @@
"""Enterprise injector for PendingMessageService with SAAS filtering."""
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from sqlalchemy import select
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from storage.user import User
from openhands.agent_server.models import ImageContent, TextContent
from openhands.app_server.errors import AuthError
from openhands.app_server.pending_messages.pending_message_models import (
PendingMessageResponse,
)
from openhands.app_server.pending_messages.pending_message_service import (
PendingMessageService,
PendingMessageServiceInjector,
SQLPendingMessageService,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import ADMIN
from openhands.app_server.user.user_context import UserContext
class SaasSQLPendingMessageService(SQLPendingMessageService):
"""Extended SQLPendingMessageService with user and organization-based filtering.
This enterprise version ensures that:
- Users can only queue messages for conversations they own
- Organization isolation is enforced for multi-tenant deployments
"""
def __init__(self, db_session, user_context: UserContext):
super().__init__(db_session=db_session)
self.user_context = user_context
async def _get_current_user(self) -> User | None:
"""Get the current user using the existing db_session.
Returns:
User object or None if no user_id is available
"""
user_id_str = await self.user_context.get_user_id()
if not user_id_str:
return None
user_id_uuid = UUID(user_id_str)
result = await self.db_session.execute(
select(User).where(User.id == user_id_uuid)
)
return result.scalars().first()
async def _validate_conversation_ownership(self, conversation_id: str) -> None:
"""Validate that the current user owns the conversation.
This ensures multi-tenant isolation by checking:
- The conversation belongs to the current user
- The conversation belongs to the user's current organization
Args:
conversation_id: The conversation ID to validate (can be task-id or UUID)
Raises:
AuthError: If user doesn't own the conversation or authentication fails
"""
# For internal operations (e.g., processing pending messages during startup)
# we need a mode that bypasses filtering. The ADMIN context enables this.
if self.user_context == ADMIN:
return
user_id_str = await self.user_context.get_user_id()
if not user_id_str:
raise AuthError('User authentication required')
user_id_uuid = UUID(user_id_str)
# Check conversation ownership via SAAS metadata
query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == conversation_id
)
result = await self.db_session.execute(query)
saas_metadata = result.scalar_one_or_none()
# If no SAAS metadata exists, the conversation might be a new task-id
# that hasn't been linked to a conversation yet. Allow access in this case
# as the message will be validated when the conversation is created.
if saas_metadata is None:
return
# Verify user ownership
if saas_metadata.user_id != user_id_uuid:
raise AuthError('You do not have access to this conversation')
# Verify organization ownership if applicable
user = await self._get_current_user()
if user and user.current_org_id is not None:
if saas_metadata.org_id != user.current_org_id:
raise AuthError('Conversation belongs to a different organization')
async def add_message(
self,
conversation_id: str,
content: list[TextContent | ImageContent],
role: str = 'user',
) -> PendingMessageResponse:
"""Queue a message with ownership validation.
Args:
conversation_id: The conversation ID to queue the message for
content: Message content
role: Message role (default: 'user')
Returns:
PendingMessageResponse with the queued message info
Raises:
AuthError: If user doesn't own the conversation
"""
await self._validate_conversation_ownership(conversation_id)
return await super().add_message(conversation_id, content, role)
async def get_pending_messages(self, conversation_id: str):
"""Get pending messages with ownership validation.
Args:
conversation_id: The conversation ID to get messages for
Returns:
List of pending messages
Raises:
AuthError: If user doesn't own the conversation
"""
await self._validate_conversation_ownership(conversation_id)
return await super().get_pending_messages(conversation_id)
async def count_pending_messages(self, conversation_id: str) -> int:
"""Count pending messages with ownership validation.
Args:
conversation_id: The conversation ID to count messages for
Returns:
Number of pending messages
Raises:
AuthError: If user doesn't own the conversation
"""
await self._validate_conversation_ownership(conversation_id)
return await super().count_pending_messages(conversation_id)
class SaasPendingMessageServiceInjector(PendingMessageServiceInjector):
"""Enterprise injector for PendingMessageService with SAAS filtering."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[PendingMessageService, None]:
from openhands.app_server.config import (
get_db_session,
get_user_context,
)
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
service = SaasSQLPendingMessageService(
db_session=db_session, user_context=user_context
)
yield service

View File

@@ -0,0 +1,41 @@
from typing import Literal
from fastapi import Request
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV, IS_STAGING_ENV
from starlette.datastructures import URL
from openhands.app_server.config import get_global_config
def get_web_url(request: Request):
web_url = get_global_config().web_url
if not web_url:
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
web_url = f'{scheme}://{request.url.netloc}'
else:
web_url = web_url.rstrip('/')
return web_url
def get_cookie_domain() -> str | None:
config = get_global_config()
web_url = config.web_url
# for now just use the full hostname except for staging stacks.
return (
URL(web_url).hostname
if web_url and not (IS_FEATURE_ENV or IS_STAGING_ENV or IS_LOCAL_ENV)
else None
)
def get_cookie_samesite() -> Literal['lax', 'strict']:
# Use 'strict' in production for maximum CSRF protection
# Use 'lax' for local development and staging environments
# Note: For invitation links from emails, the frontend handles acceptance via
# an authenticated POST request (same-origin), which works with 'strict' cookies
web_url = get_global_config().web_url
return (
'strict'
if web_url and not (IS_FEATURE_ENV or IS_STAGING_ENV or IS_LOCAL_ENV)
else 'lax'
)

View File

@@ -17,7 +17,7 @@ from server.verified_models.verified_model_service import (
from openhands.app_server.config import get_db_session
from openhands.server.routes import public
from openhands.utils.llm import get_supported_llm_models
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
@@ -117,7 +117,7 @@ async def delete_verified_model(
)
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
async def get_saas_llm_models_dependency(request: Request) -> ModelsResponse:
"""SaaS implementation for the LLM models endpoint."""
async with get_db_session(request.state, request) as db_session:
# Prevent circular import

View File

@@ -1,555 +0,0 @@
"""Automation executor — processes events, claims and executes runs.
The executor is a long-running process with three phases:
1. Process inbox: match NEW events to automations, create PENDING runs
2. Claim and execute: claim PENDING runs, submit to V1 API, heartbeat
3. Stale recovery: recover RUNNING runs with expired heartbeats
"""
import asyncio
import logging
import os
import socket
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from services.openhands_api_client import OpenHandsAPIClient
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from storage.automation import Automation, AutomationRun
from storage.automation_event import AutomationEvent
logger = logging.getLogger('saas.automation.executor')
# Environment-configurable settings
POLL_INTERVAL_SECONDS = float(os.getenv('POLL_INTERVAL_SECONDS', '30'))
HEARTBEAT_INTERVAL_SECONDS = float(os.getenv('HEARTBEAT_INTERVAL_SECONDS', '60'))
RUN_TIMEOUT_SECONDS = float(os.getenv('RUN_TIMEOUT_SECONDS', '7200'))
MAX_CONCURRENT_RUNS = int(os.getenv('MAX_CONCURRENT_RUNS', '5'))
STALE_THRESHOLD_MINUTES = 5
MAX_EVENTS_PER_BATCH = 50
MAX_RETRIES_DEFAULT = 3
# Terminal conversation statuses
TERMINAL_STATUSES = frozenset({'STOPPED', 'ERROR', 'COMPLETED', 'CANCELLED'})
# Shutdown flag — set by signal handlers
_shutdown_event: asyncio.Event | None = None
# Background task tracking for graceful shutdown
_pending_tasks: set[asyncio.Task] = set()
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def get_shutdown_event() -> asyncio.Event:
global _shutdown_event
if _shutdown_event is None:
_shutdown_event = asyncio.Event()
return _shutdown_event
def should_continue() -> bool:
return not get_shutdown_event().is_set()
def request_shutdown() -> None:
get_shutdown_event().set()
# ---------------------------------------------------------------------------
# Phase 1: Process inbox (event matching)
# ---------------------------------------------------------------------------
async def find_matching_automations(
session: AsyncSession, event: AutomationEvent
) -> list[Automation]:
"""Find automations that match the given event.
Phase 1 supports cron and manual triggers only — both carry
``automation_id`` in the event payload.
"""
source_type = event.source_type
payload = event.payload
if payload is None:
logger.error('Event %s has None payload — possible data corruption', event.id)
return []
if source_type in ('cron', 'manual'):
automation_id = payload.get('automation_id')
if not automation_id:
logger.warning(
'Event %s (source=%s) missing automation_id in payload',
event.id,
source_type,
)
return []
result = await session.execute(
select(Automation).where(
Automation.id == automation_id,
Automation.enabled.is_(True),
)
)
automation = result.scalar_one_or_none()
return [automation] if automation else []
logger.debug('Unhandled event source_type=%s for event %s', source_type, event.id)
return []
async def process_new_events(session: AsyncSession) -> int:
"""Claim NEW events from inbox, match to automations, create runs.
Returns the number of events processed.
"""
result = await session.execute(
select(AutomationEvent)
.where(AutomationEvent.status == 'NEW')
.order_by(AutomationEvent.created_at)
.limit(MAX_EVENTS_PER_BATCH)
.with_for_update(skip_locked=True)
)
events = list(result.scalars())
processed = 0
for event in events:
try:
automations = await find_matching_automations(session, event)
if not automations:
event.status = 'NO_MATCH'
event.processed_at = utc_now()
else:
for automation in automations:
run = AutomationRun(
id=uuid4().hex,
automation_id=automation.id,
event_id=event.id,
status='PENDING',
event_payload=event.payload,
)
session.add(run)
event.status = 'PROCESSED'
event.processed_at = utc_now()
processed += 1
except Exception as e:
logger.exception('Error processing event %s', event.id)
event.status = 'ERROR'
event.error_detail = f'Failed during event matching: {type(e).__name__}: {e}'
event.processed_at = utc_now()
if processed:
await session.commit()
logger.info('Processed %d events', processed)
return processed
# ---------------------------------------------------------------------------
# Phase 2: Claim and execute runs
# ---------------------------------------------------------------------------
async def resolve_user_api_key(session: AsyncSession, user_id: str) -> str | None:
"""Look up a user's API key from the api_keys table.
Returns the first active key found, or None.
"""
from storage.api_key import ApiKey
result = await session.execute(
select(ApiKey.key).where(ApiKey.user_id == user_id).limit(1)
)
row = result.scalar_one_or_none()
return row
async def download_automation_file(file_store_key: str) -> bytes:
"""Download the automation .py file from object storage."""
try:
from openhands.server.shared import file_store
except ImportError as exc:
raise RuntimeError(
'file_store is not available — ensure the enterprise server '
'has been initialised before calling download_automation_file'
) from exc
content = file_store.read(file_store_key)
if isinstance(content, str):
return content.encode('utf-8')
return content
def is_terminal(conversation: dict) -> bool:
"""Check if a conversation has reached a terminal status."""
status = (conversation.get('status') or '').upper()
return status in TERMINAL_STATUSES
async def _prepare_run(
run: AutomationRun,
automation: Automation,
session_factory: object,
) -> tuple[str, bytes]:
"""Resolve the user's API key and download the automation file.
Returns:
(api_key, automation_file) tuple ready for submission.
Raises:
ValueError: If no API key is found.
RuntimeError: If file_store is unavailable.
"""
async with session_factory() as key_session:
api_key = await resolve_user_api_key(key_session, automation.user_id)
if not api_key:
raise ValueError(f'No API key found for user {automation.user_id}')
automation_file = await download_automation_file(automation.file_store_key)
return api_key, automation_file
async def _monitor_conversation(
run: AutomationRun,
conversation_id: str,
api_client: OpenHandsAPIClient,
api_key: str,
session_factory: object,
) -> bool:
"""Monitor a conversation until completion or timeout.
Returns True if completed successfully, False if shutdown requested.
Raises:
TimeoutError: If the run exceeds RUN_TIMEOUT_SECONDS.
"""
start_time = utc_now()
while should_continue():
elapsed = (utc_now() - start_time).total_seconds()
if elapsed > RUN_TIMEOUT_SECONDS:
raise TimeoutError(f'Run exceeded {RUN_TIMEOUT_SECONDS}s timeout')
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
# Update heartbeat
async with session_factory() as session:
run_obj = await session.get(AutomationRun, run.id)
if run_obj:
run_obj.heartbeat_at = utc_now()
await session.commit()
# Check conversation status
conversation = (
await api_client.get_conversation(api_key, conversation_id) or {}
)
if is_terminal(conversation):
return True
return False # shutdown requested
async def _submit_and_monitor(
run: AutomationRun,
api_key: str,
automation_file: bytes,
automation: Automation,
api_client: OpenHandsAPIClient,
session_factory: object,
) -> None:
"""Submit the automation to the V1 API and monitor until completion.
Updates the run's conversation_id, sends heartbeats, and marks the
final status when the conversation reaches a terminal state.
"""
conversation = await api_client.start_conversation(
api_key=api_key,
automation_file=automation_file,
title=f'Automation: {automation.name}',
event_payload=run.event_payload,
)
conversation_id = conversation.get('app_conversation_id') or conversation.get(
'conversation_id'
)
# Persist conversation ID
async with session_factory() as update_session:
run_obj = await update_session.get(AutomationRun, run.id)
if run_obj:
run_obj.conversation_id = conversation_id
await update_session.commit()
# Monitor with heartbeats
completed = await _monitor_conversation(
run, conversation_id, api_client, api_key, session_factory
)
# Update final status
async with session_factory() as final_session:
run_obj = await final_session.get(AutomationRun, run.id)
if run_obj:
if not completed:
# Leave as RUNNING — stale recovery will handle it if needed.
# The conversation may still be running on the API side.
logger.info(
'Run %s left as RUNNING due to executor shutdown', run.id
)
else:
run_obj.status = 'COMPLETED'
run_obj.completed_at = utc_now()
logger.info('Run %s completed successfully', run.id)
await final_session.commit()
async def execute_run(
run: AutomationRun,
automation: Automation,
api_client: OpenHandsAPIClient,
session_factory: object,
) -> None:
"""Execute a single automation run end-to-end.
Orchestrates preparation (API key + file download) and submission/monitoring.
On failure, marks the run for retry or dead-letter.
"""
try:
api_key, automation_file = await _prepare_run(
run, automation, session_factory
)
await _submit_and_monitor(
run, api_key, automation_file, automation, api_client, session_factory
)
except Exception as e:
logger.exception('Run %s failed: %s', run.id, e)
await _mark_run_failed(run, str(e), session_factory)
async def _mark_run_failed(
run: AutomationRun, error: str, session_factory: object
) -> None:
"""Mark a run as FAILED or return to PENDING for retry."""
async with session_factory() as session:
run_obj = await session.get(AutomationRun, run.id)
if not run_obj:
return
run_obj.retry_count = (run_obj.retry_count or 0) + 1
run_obj.error_detail = error
if run_obj.retry_count >= (run_obj.max_retries or MAX_RETRIES_DEFAULT):
run_obj.status = 'DEAD_LETTER'
run_obj.completed_at = utc_now()
logger.error(
'Run %s moved to DEAD_LETTER after %d retries',
run.id,
run_obj.retry_count,
)
else:
run_obj.status = 'PENDING'
run_obj.claimed_by = None
backoff_seconds = 30 * (2 ** (run_obj.retry_count - 1))
run_obj.next_retry_at = utc_now() + timedelta(seconds=backoff_seconds)
logger.warning(
'Run %s returned to PENDING, retry %d/%d in %ds',
run.id,
run_obj.retry_count,
run_obj.max_retries or MAX_RETRIES_DEFAULT,
backoff_seconds,
)
await session.commit()
async def claim_and_execute_runs(
session: AsyncSession,
executor_id: str,
api_client: OpenHandsAPIClient,
session_factory: object,
) -> bool:
"""Claim a PENDING run and start executing it.
Returns True if a run was claimed, False otherwise.
"""
result = await session.execute(
select(AutomationRun)
.where(
AutomationRun.status == 'PENDING',
or_(
AutomationRun.next_retry_at.is_(None),
AutomationRun.next_retry_at <= utc_now(),
),
)
.order_by(AutomationRun.created_at)
.limit(1)
.with_for_update(skip_locked=True)
)
run = result.scalar_one_or_none()
if not run:
return False
# Claim the run
run.status = 'RUNNING'
run.claimed_by = executor_id
run.claimed_at = utc_now()
run.heartbeat_at = utc_now()
run.started_at = utc_now()
await session.commit()
# Load automation for the run
auto_result = await session.execute(
select(Automation).where(Automation.id == run.automation_id)
)
automation = auto_result.scalar_one_or_none()
if not automation:
logger.error('Automation %s not found for run %s', run.automation_id, run.id)
await _mark_run_failed(
run, f'Automation {run.automation_id} not found', session_factory
)
return True
# Execute in background (long-running) with task tracking
task = asyncio.create_task(
execute_run(run, automation, api_client, session_factory),
name=f'execute-run-{run.id}',
)
_pending_tasks.add(task)
task.add_done_callback(_pending_tasks.discard)
logger.info(
'Claimed run %s (automation=%s) by executor %s',
run.id,
run.automation_id,
executor_id,
)
return True
# ---------------------------------------------------------------------------
# Phase 3: Stale run recovery
# ---------------------------------------------------------------------------
async def recover_stale_runs(session: AsyncSession) -> int:
"""Mark RUNNING runs with expired heartbeats as PENDING for retry.
Returns the number of recovered runs.
"""
stale_threshold = utc_now() - timedelta(minutes=STALE_THRESHOLD_MINUTES)
timeout_threshold = utc_now() - timedelta(seconds=RUN_TIMEOUT_SECONDS)
# Recover stale runs (heartbeat expired)
result = await session.execute(
update(AutomationRun)
.where(
AutomationRun.status == 'RUNNING',
AutomationRun.heartbeat_at < stale_threshold,
AutomationRun.heartbeat_at >= timeout_threshold,
)
.values(
status='PENDING',
claimed_by=None,
retry_count=AutomationRun.retry_count + 1,
next_retry_at=utc_now() + timedelta(seconds=30),
)
.returning(AutomationRun.id)
)
recovered_rows = result.fetchall()
# Mark truly timed-out runs as DEAD_LETTER
timeout_result = await session.execute(
update(AutomationRun)
.where(
AutomationRun.status == 'RUNNING',
AutomationRun.heartbeat_at < timeout_threshold,
)
.values(
status='DEAD_LETTER',
error_detail='Run exceeded timeout',
completed_at=utc_now(),
)
.returning(AutomationRun.id)
)
timed_out_rows = timeout_result.fetchall()
await session.commit()
recovered_count = len(recovered_rows)
timed_out_count = len(timed_out_rows)
if recovered_count:
logger.warning('Recovered %d stale automation runs', recovered_count)
if timed_out_count:
logger.warning(
'Marked %d automation runs as DEAD_LETTER (timeout)', timed_out_count
)
return recovered_count + timed_out_count
# ---------------------------------------------------------------------------
# Main executor loop
# ---------------------------------------------------------------------------
async def executor_main(session_factory: object | None = None) -> None:
"""Main executor loop.
Args:
session_factory: Async context manager that yields AsyncSession instances.
If None, uses the default ``a_session_maker`` from database module.
"""
if session_factory is None:
from storage.database import a_session_maker
session_factory = a_session_maker
executor_id = f'executor-{socket.gethostname()}-{os.getpid()}'
api_url = os.getenv('OPENHANDS_API_URL', 'http://openhands-service:3000')
api_client = OpenHandsAPIClient(base_url=api_url)
logger.info(
'Automation executor %s starting (api_url=%s, poll=%ss, heartbeat=%ss)',
executor_id,
api_url,
POLL_INTERVAL_SECONDS,
HEARTBEAT_INTERVAL_SECONDS,
)
try:
while should_continue():
try:
async with session_factory() as session:
await process_new_events(session)
async with session_factory() as session:
await claim_and_execute_runs(
session, executor_id, api_client, session_factory
)
async with session_factory() as session:
await recover_stale_runs(session)
except Exception:
logger.exception('Error in executor main loop iteration')
# Wait for next poll interval (or early wakeup on shutdown)
try:
await asyncio.wait_for(
get_shutdown_event().wait(),
timeout=POLL_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
pass # Normal — poll interval elapsed
finally:
if _pending_tasks:
logger.info(
'Waiting for %d running tasks to complete...', len(_pending_tasks)
)
await asyncio.gather(*_pending_tasks, return_exceptions=True)
await api_client.close()
logger.info('Automation executor %s shut down', executor_id)

View File

@@ -1,93 +0,0 @@
"""HTTP client for the main OpenHands V1 API (internal cluster calls).
Used by the automation executor to create and monitor conversations
in the main OpenHands server.
"""
import base64
import logging
import httpx
logger = logging.getLogger('saas.automation.api_client')
def _raise_with_body(resp: httpx.Response) -> None:
"""Call raise_for_status, enriching the error with the response body."""
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = resp.text[:500] if resp.text else 'no response body'
raise httpx.HTTPStatusError(
f'{e.args[0]} — Response: {error_body}',
request=e.request,
response=e.response,
) from e
class OpenHandsAPIClient:
"""Async HTTP client for the OpenHands V1 API."""
def __init__(self, base_url: str = 'http://openhands-service:3000'):
self.base_url = base_url.rstrip('/')
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0)
async def start_conversation(
self,
api_key: str,
automation_file: bytes,
title: str,
event_payload: dict | None = None,
) -> dict:
"""Submit an SDK script for sandboxed execution via V1 API.
Args:
api_key: User's API key for authentication.
automation_file: Raw bytes of the .py automation script.
title: Display title for the conversation.
event_payload: Optional trigger event data (injected as env var).
Returns:
Parsed JSON response containing conversation details.
Raises:
httpx.HTTPStatusError: If the API returns a non-2xx status.
"""
resp = await self.client.post(
'/api/v1/app-conversations',
json={
'automation_file': base64.b64encode(automation_file).decode(),
'trigger': 'automation',
'title': title,
'event_payload': event_payload,
},
headers={'Authorization': f'Bearer {api_key}'},
)
_raise_with_body(resp)
return resp.json()
async def get_conversation(self, api_key: str, conversation_id: str) -> dict | None:
"""Get conversation status.
Args:
api_key: User's API key for authentication.
conversation_id: The conversation ID to look up.
Returns:
Conversation data dict, or None if not found.
Raises:
httpx.HTTPStatusError: If the API returns a non-2xx status.
"""
resp = await self.client.get(
'/api/v1/app-conversations',
params={'ids': [conversation_id]},
headers={'Authorization': f'Bearer {api_key}'},
)
_raise_with_body(resp)
conversations = resp.json()
return conversations[0] if conversations else None
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self.client.aclose()

View File

@@ -19,6 +19,7 @@ from storage.linear_workspace import LinearWorkspace
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from storage.openhands_pr import OpenhandsPR
from storage.org import Org
from storage.org_git_claim import OrgGitClaim
from storage.org_invitation import OrgInvitation
from storage.org_member import OrgMember
from storage.proactive_convos import ProactiveConversation
@@ -65,6 +66,7 @@ __all__ = [
'MaintenanceTaskStatus',
'OpenhandsPR',
'Org',
'OrgGitClaim',
'OrgInvitation',
'OrgMember',
'ProactiveConversation',

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