Compare commits

..

119 Commits

Author SHA1 Message Date
dependabot[bot]
a815ad2c10 chore(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 21:41:44 +00:00
Tim O'Farrell
e86067c15b Removed V0 runtime (#14117) 2026-04-24 15:40:37 -06:00
aivong-openhands
137bede1f5 APP-1325: show GitLab/Slack sections without GitHub App configured (#14097) 2026-04-24 15:10:38 -04:00
Tim O'Farrell
8a1d80ac8f Removed Architecture diagrams (#14120) 2026-04-24 12:45:02 -06:00
Tim O'Farrell
77043da280 Removed V0 third party runtimes (#14119) 2026-04-24 12:23:01 -06:00
Tim O'Farrell
180a35f013 Removed V0 controller (#14060) 2026-04-24 11:05:17 -06:00
Tim O'Farrell
18365e0323 APP-1359 Removed V0 microagent Package (#14053) 2026-04-24 09:28:19 -06:00
aivong-openhands
9a743ff51a APP-1325: register GitlabV1CallbackProcessor for deserialization (#14110) 2026-04-24 11:01:06 -04:00
Graham Neubig
29577935b4 fix: preserve LLM and MCP settings in migration 108 (#14112)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 14:36:12 +00:00
Tim O'Farrell
7498353ed5 APP-1360 Removed V0 memory package (#14057) 2026-04-24 08:22:16 -06:00
Tim O'Farrell
b62bdfd143 chore: delete unused Python code identified by vulture analysis (#14111)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 07:36:57 -06:00
Tim O'Farrell
fb98faf4ac refactor: remove external dependencies on V0 packages (controller, memory, microagent) (#14106)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 17:09:46 -06:00
John-Mason P. Shackelford
a8f62aa30c feat: add secrets field to AppConversationStartRequest for direct API secret passing (#14009)
Add the ability for API callers to pass secrets directly when starting
a conversation, without requiring them to be pre-stored in the database.

Changes:
- Add optional `secrets: dict[str, SecretStr]` field to
  AppConversationStartRequest model
- Update `_build_start_conversation_request_for_user()` to merge
  API-provided secrets with existing secrets (from git providers/database)
- API-provided secrets take precedence over existing secrets with same name
- Add new `openhands/app_server/constants.py` with secret validation:
  - Blocked names: container config vars (OH_*, WORKER_*, etc.)
  - Blocked prefixes: LLM_* (to enforce app-server LLM controls)
  - Configurable size limits via environment variables
- Add warning log when API secrets override existing secrets
- Bump agent-server image to 1.18.1-python (SDK v1.18.1 with MCP
  secrets expansion support)

Closes #14007
2026-04-23 18:23:31 -04:00
Tim O'Farrell
1a7449b03a Remove dead code. (#14103) 2026-04-23 13:42:40 -06:00
Rohit Malhotra
1091901be2 Fix: Register SetTitleCallbackProcessor for webhook-created conversations (#14102)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 14:53:37 -04:00
Hiep Le
15160f6733 fix(frontend): show members a read-only badge on org-defaults pages (#14098) 2026-04-23 23:52:43 +07:00
Graham Neubig
13dba59bb8 Fix enterprise migration 108 settings mapping (#14088)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 12:47:37 -04:00
Tim O'Farrell
478c998f04 APP-1363 : Remove V0 io Package (#14094) 2026-04-23 09:31:01 -06:00
Tim O'Farrell
a9fc93ffbf More pieces of V0 carved off (#14089) 2026-04-23 08:26:40 -06:00
Tim O'Farrell
cc100c0d10 Removed the V0 resolver (#14062) 2026-04-23 07:48:32 -06:00
Rohit Malhotra
7bc3300981 Add missing SqlAlchemy type stub to mypy (#13413)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:52:27 +00:00
Rohit Malhotra
3e0283796e fix: add return type annotation for ConversationMetadata conversion (SQLAlchemy typing PR7) (#14081)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:37:18 +00:00
Rohit Malhotra
cd0175d83e fix: correct return types and remove unreachable code (SQLAlchemy typing PR6) (#14079)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:17:11 +00:00
Rohit Malhotra
f313cfceb9 fix: correct SQLAlchemy type annotations in DbSessionInjector (#14075)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 16:13:39 -04:00
Rohit Malhotra
fb0108f946 fix: handle nullable arguments in enterprise code (#14078)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 16:10:08 -04:00
Rohit Malhotra
6b29a82de3 fix: correct SQLAlchemy Result and Table type annotations (#14076)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 19:43:14 +00:00
Rohit Malhotra
033c6202b7 fix: handle nullable datetime in _fix_timezone methods (#14073)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 19:16:26 +00:00
Graham Neubig
d64d0d6bf6 Hide All toggle on SaaS LLM settings (#14013)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-22 15:13:57 -04:00
aivong-openhands
b357c0c3bb Fix CVE-2026-39892: Update cryptography to 46.0.7 (#13968)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-22 21:07:29 +02:00
Rohit Malhotra
16374dc9c0 fix: add ColumnElement type annotation for SQLAlchemy filter conditions (#14072)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 19:06:08 +00:00
Graham Neubig
a8926068ff fix: restore org settings payload contract (#14051)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-22 15:03:40 -04:00
chuckbutkus
f318792a17 security: Invalidate SESSION_API_KEY on pause and require RUNNING status (#14001)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-04-22 12:49:28 -06:00
Rohit Malhotra
505095d50a fix: migrate core SQLAlchemy models to SQLAlchemy 2.0 mapped_column (#14065)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 17:54:08 +00:00
Rohit Malhotra
51f9266abb fix(enterprise): migrate StoredVerifiedModel to SQLAlchemy 2.0 mapped_column (#14064)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 17:53:48 +00:00
Rohit Malhotra
439fa8fc30 fix(enterprise): migrate storage models to SQLAlchemy 2.0 [13/13] (#13859)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 10:41:38 -07:00
Rohit Malhotra
c1ae41acb9 fix(enterprise): migrate org-related models to SQLAlchemy 2.0 [12/13] (#13858)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 17:16:29 +00:00
Rohit Malhotra
270d9b1cce fix(enterprise): migrate Slack models to SQLAlchemy 2.0 [8/13] (#13854)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 12:57:38 -04:00
Rohit Malhotra
3b0e201a4e fix(enterprise): migrate Jira models to SQLAlchemy 2.0 [9/13] (#13855)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 12:57:20 -04:00
Rohit Malhotra
cd24b5838b fix(enterprise): migrate Git provider models to SQLAlchemy 2.0 [11/13] (#13857)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 12:53:53 -04:00
Rohit Malhotra
1509018ee2 fix(enterprise): migrate Linear models to SQLAlchemy 2.0 [10/13] (#13856)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 12:53:45 -04:00
Tim O'Farrell
1605e97d80 APP-1374 Removed V0 sessions (#14061)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 08:47:17 -06:00
Xingyao Wang
06d0320e5c fix(frontend): restore notification sound and browser tab flash on agent completion (#14049)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 15:22:33 +01:00
Graham Neubig
f7dce9c6c0 Allow supported Python interpreters in Makefile setup (#14012)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2026-04-22 08:25:56 -04:00
Tim O'Farrell
13e9d7584a Remove openhands.server.conversation_manager package (V0 to V1 migration) (#14034)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 20:17:11 -06:00
Tim O'Farrell
e0a4c35c9c APP-1362 Remove V0 linter Package (#14055) 2026-04-21 20:05:20 -06:00
Tim O'Farrell
701231cbf3 APP-1361 Remove V0 security Package (#14054) 2026-04-21 20:05:10 -06:00
simonrosenberg
f8a43f9937 chore: use OPENHANDS_BOT_GITHUB_PAT_PUBLIC (#14041)
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-21 20:19:21 -03:00
Tim O'Farrell
c49ed64b64 APP-1366 Removed V0 Critic (#14056) 2026-04-21 15:35:30 -06:00
aivong-openhands
3b17f27dee PLTF-139: Add Analytics redirect URI to Keycloak allhands client (#14052)
Co-authored-by: Test Admin <ai.vong+bdctestadmin@openhands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 15:14:44 -05:00
Rohit Malhotra
ae2f13ecba fix(enterprise): migrate auth models to SQLAlchemy 2.0 [7/13] (#13853)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 15:07:12 -04:00
Rohit Malhotra
6d1850e94b fix(enterprise): migrate user models to SQLAlchemy 2.0 [6/13] (#13852)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 14:49:19 -04:00
chuckbutkus
cf7e88c8c3 security: Require RUNNING status for session API key validation (#14002)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 18:48:22 +00:00
Rohit Malhotra
6420f1cd7c fix(enterprise): migrate conversation_callback model to SQLAlchemy 2.0 [5/13] (#13851)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 14:38:18 -04:00
Rohit Malhotra
c7de3dfc91 fix(enterprise): migrate org models to SQLAlchemy 2.0 [4/13] (#13850)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 14:38:10 -04:00
Rohit Malhotra
393a6bb8f8 feat: add automation event forwarding for GitHub webhooks (#13793)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: chuckbutkus <chuck@openhands.dev>
Co-authored-by: chuckbutkus <chuck@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-21 13:41:53 -04:00
Hiep Le
d8c67a4d3d fix(backend): repair org-defaults LLM save flow and sync managed keys to members (#14044) 2026-04-22 00:38:52 +07:00
Rohit Malhotra
237e9f530e feat: pass WEB_HOST to agent context in V1 conversations (#14050)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 12:12:14 -04:00
OpenHands Bot
93ae8aae43 Regenerate poetry.lock with Poetry 2.3.3 after rebase (#13911)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 16:29:15 +01:00
Xingyao Wang
595bb4749d fix: invalidate sandbox and VS Code URL caches on conversation resume (#13988)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-21 16:18:01 +01:00
Joe Laverty
b43d9b1929 feat: Tag images instead of rebuilding on git tags; stop publishing v0 runtime image (#14005)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-04-21 14:58:04 +00:00
Xingyao Wang
3fa9b84aa4 fix(frontend): Fix ThinkAction rendering showing raw JSON in chat (#13981)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-21 15:57:46 +01:00
dependabot[bot]
db8ab2715e chore(deps): bump the security-all group across 1 directory with 3 updates (#13932)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 16:40:51 +02:00
Graham Neubig
fa0da8f3bd fix: prevent infinite redirect loop on org-defaults settings pages (#14042)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-20 18:55:49 -04:00
Hiep Le
0da1f70b91 fix(frontend): show org-wide settings badge beside title on org-defaults pages (#14031) 2026-04-21 02:18:17 +07:00
Hiep Le
3892ab2b67 fix(frontend): hide and block personal LLM/Condenser/Verification settings in team orgs (#14030) 2026-04-21 00:52:26 +07:00
Rohit Malhotra
30dc1655b1 fix(enterprise): migrate telemetry models to SQLAlchemy 2.0 [3/13] (#13849)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-20 13:00:42 -04:00
Graham Neubig
71ce61acd2 Remove agent from the LLM settings page (#14033)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-20 12:16:01 -04:00
Hiep Le
b2df428eff fix(backend): restore git-organizations endpoint for git conversation routing (#14032) 2026-04-20 22:48:24 +07:00
Graham Neubig
7bbef99771 Move Tavily search key to MCP settings (#14000)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-20 13:45:02 +00:00
Tim O'Farrell
fd014e8e23 Removing the deprecated agenthub package (#14024)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-19 21:42:22 -06:00
OpenHands Bot
89f3dceeb8 fix(security): redact session_api_key from WebSocket access logs (#14019)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-19 16:54:31 +00:00
OpenHands Bot
dcb6ac3599 fix(security): redact API keys from MCP config logging (#14020)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-19 13:53:40 -03:00
Tim O'Farrell
3b264dd419 Remove deprecated V0 FastAPI endpoints (#13952)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-19 08:41:16 -06:00
dependabot[bot]
f212e0e856 chore(deps): bump node from 25.8-trixie-slim to 25.9-trixie-slim in /containers/app (#13829)
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-18 20:29:14 +02:00
dependabot[bot]
918b0a8b59 chore(deps-dev): bump pytest from 9.0.2 to 9.0.3 (#13935)
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-04-18 20:29:11 +02:00
dependabot[bot]
119b0c99a8 chore(deps): bump pygments from 2.19.2 to 2.20.0 (#13934)
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-04-18 20:14:59 +02:00
dependabot[bot]
0628679307 chore(deps): bump docker/build-push-action from 6 to 7 (#13961)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-18 15:50:00 +02:00
xsf
e8249f00a8 docs: align AgentHub task tracking references (#13708)
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-04-18 15:40:14 +02:00
dependabot[bot]
1651edf8c9 chore(deps): bump docker/login-action from 3 to 4 (#13960)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-18 15:34:16 +02:00
dependabot[bot]
1fd94675d0 chore(deps): bump actions/download-artifact from 7 to 8 (#13700)
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: Engel Nyst <engel.nyst@gmail.com>
2026-04-18 15:34:13 +02:00
dependabot[bot]
b841e1acb0 chore(deps): bump docker/metadata-action from 5 to 6 (#13959)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-18 15:34:10 +02:00
JL2001
1af04f2833 docs: fix app_server README to reflect actual module structure (#13890) 2026-04-18 15:26:24 +02:00
aivong-openhands
b87f08f651 docs: add note about updating enterprise poetry lockfile (#13761)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-18 15:24:56 +02:00
aivong-openhands
e23af62a57 PLTF-1269: add docstrings to mock classes in maintenance runner tests (#13931)
Co-authored-by: Test Admin <ai.vong+bdctestadmin@openhands.dev>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-18 15:23:45 +02:00
Graham Neubig
9db83a1555 Refresh git settings after provider updates (#13979)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-18 07:47:44 -04:00
Octopus
8f5b3ceb6c fix(settings): align MCP settings layout with other settings pages (#13996)
Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2026-04-18 11:19:48 +00:00
buyua9
5bb9e4a567 docs: fix broken React Router test doc links (#13802) 2026-04-17 22:47:27 +00:00
dependabot[bot]
a5a7a86600 chore(deps): bump actions/github-script from 7 to 9 (#13958)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 23:06:40 +02:00
aivong-openhands
5c8d7c4c2d Fix CVE-2026-40347: Update python-multipart to 0.0.26 (#13965)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-17 12:32:15 -05:00
Xingyao Wang
2068694ea0 fix(enterprise): Apply deployment LLM proxy URL override in /api/v1/users/me (#13980)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-17 12:23:42 +07:00
Tim O'Farrell
385122e260 refactor: Replace load_custom_secrets_names with search_custom_secrets (#13985)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 18:50:38 -06:00
Ash Clarke
97343ebe9a Fix V1 resumed conversation status sync (#13989)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 15:49:44 -06:00
Joe Laverty
926f25a74b bugfix(enterprise): Remove shared conversation enumerator endpoints (#13976) 2026-04-16 18:11:44 +00:00
Tim O'Farrell
52c4d0d9d9 Fix get_latest_token to return str instead of SecretStr (#13974)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 11:41:43 -06:00
Hiep Le
f1ff98b2fc fix: add flat SDK compat fields to /api/v1/users/me response (#13957) 2026-04-16 23:11:51 +07:00
dependabot[bot]
26c43d1955 chore(deps): bump pillow from 12.1.1 to 12.2.0 (#13933)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 10:47:35 -05:00
Graham Neubig
d81c2bc0a6 Fix duplicate enterprise migration revision (#13950)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 11:02:08 -04:00
Tim O'Farrell
fdf5c398fd frontend: Remove deprecated V0 conversation APIs (#13963)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 08:57:25 -06:00
HeyItsChloe
c78b923468 feat(frontend): Remove onboarding feature flags (#13947)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-16 12:28:27 +07:00
Tim O'Farrell
db78925d77 Fix merge error in migrations (#13951) 2026-04-16 00:40:19 +00:00
Graham Neubig
b4da0e1c69 settings: expose SDK settings schema to OpenHands (#13306)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-04-15 17:00:35 -06:00
Vasco Schiavo
d548665bcf feat(frontend): add /btw side-channel command for asking the agent mid-task (#13918)
Co-authored-by: Juan Michelini <juan@juan.com.uy>
2026-04-15 18:47:44 -03:00
Engel Nyst
eb940ea5e7 Use SDK package public exports where available (#13402)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-15 22:05:08 +02:00
HeyItsChloe
22b91976fd feat(frontend): removed PROJ_USER_JOURNEY feature flag (#13940)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-16 01:35:19 +07:00
Hiep Le
dcf044f8c3 feat(frontend): add automations button to sidebar (#13941) 2026-04-16 01:34:55 +07:00
OpenHands Bot
d58106b29b fix(security): extend RedactURLParamsFilter to cover all uvicorn log formats (#13914)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com>
2026-04-15 15:24:24 -03:00
OpenHands Bot
e11faa6dd1 fix(security): redact MCP config secrets before logging (#13913)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com>
2026-04-15 15:22:50 -03:00
HeyItsChloe
b4b77fbc31 feat: Add DEPLOYMENT_MODE detection for conditional onboarding (#13675)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-16 00:57:57 +07:00
Tim O'Farrell
ef452b6544 Fix concurrent GET /api/v1/web-client/config calls (#13905)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-14 15:02:45 -06:00
Tim O'Farrell
0eafa9fd15 APP-1170: Jira : add V1 conversation support and multiple bug fixes (#13909)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-14 14:39:00 -06:00
Joe Laverty
ab64a65f25 feat(enterprise): Build ARM64 image for enterprise, use native runners for all images (#13921) 2026-04-14 16:28:18 -04:00
Engel Nyst
4cdf88d480 Update LICENSE (#13924) 2026-04-14 19:03:41 +00:00
Xingyao Wang
eab9d9e3c7 fix: paginate through backend pages when filtering shared events (#13910)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-14 14:20:33 +01:00
Hiep Le
58df84e16c feat: add manage automations permission and broadcast org selection (#13908) 2026-04-14 18:05:02 +07:00
Vasco Schiavo
3cd74d3bac fix(jira): use markdown_to_jira_markup for proper wiki markup rendering (#13879)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-13 15:40:01 -06:00
simonrosenberg
20018842a4 fix(security): suppress SQLAlchemy DEBUG logging that leaks credentials (#13719)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:49:25 -03:00
Ricardo-M-L
cce2080ae0 fix: correct wrong variable in max_size validation error message in condensers (#13816) 2026-04-13 09:44:41 -06:00
Ricardo-M-L
a0304b9e4c fix: correct logger format args and -0 slice bug (#13817) 2026-04-13 09:43:30 -06:00
Tim O'Farrell
de492b792f refactor(llm-settings): Use V1 providers/models endpoints, lazy-load models per provider (#13892)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-12 22:42:57 -06:00
879 changed files with 29052 additions and 123534 deletions

View File

@@ -0,0 +1,47 @@
---
name: custom-codereview-guide
description: Repo-specific code review guidelines for All-Hands-AI/OpenHands. Provides frontend and backend review rules in addition to the default code review skill.
triggers:
- /codereview
---
# All-Hands-AI/OpenHands Code Review Guidelines
You are an expert code reviewer for the **All-Hands-AI/OpenHands** repository. This skill provides repo-specific review guidelines.
## Frontend: i18n / Translation Key Usage
**Never dynamically construct i18n keys via string interpolation or template literals.**
All translation keys must come from the `I18nKey` enum (`frontend/src/i18n/declaration.ts`) or from canonical mapping objects like `AGENT_STATUS_MAP` (`frontend/src/utils/status.ts`). Dynamically constructed keys (e.g., `` t(`STATUS$${value.toUpperCase()}`) ``) will silently fall back to the raw key string at runtime because `i18next` returns the key itself when a translation is missing — this produces broken UI text with no build-time or test-time error.
### What to flag
- Any call to `t(...)` or `i18next.t(...)` where the key is built at runtime via template literals, string concatenation, or helper functions rather than referencing `I18nKey` or a known mapping
- Any new i18n key referenced in code that does not exist in `frontend/src/i18n/translation.json`
### Correct pattern
```ts
import { AGENT_STATUS_MAP } from "#/utils/status";
const i18nKey = AGENT_STATUS_MAP[agentState];
const message = i18nKey ? t(i18nKey) : fallback;
```
### Incorrect pattern
```ts
// BAD: constructs a key that may not exist in translation.json
const message = t(`STATUS$${agentState.toUpperCase()}`);
```
## Frontend: Data Fetching Architecture
UI components must never call API client methods (`frontend/src/api/`) directly. All data access must go through TanStack Query hooks:
```
UI components → TanStack Query hooks (frontend/src/hooks/query/ or mutation/) → API client (frontend/src/api/) → API endpoints
```
Flag any component that imports directly from `#/api/` and calls fetch/mutation functions without a TanStack Query wrapper.

View File

@@ -95,13 +95,13 @@ git tag X.Y.Z
Create a `saas-rel-X.Y.Z` branch from the tagged commit for the SaaS deployment pipeline.
#### Step 3: CI builds Docker images automatically
#### Step 3: Images get tagged automatically
The `ghcr-build.yml` workflow triggers on tag pushes and produces:
- `ghcr.io/openhands/openhands:X.Y.Z`, `X.Y`, `X`, `latest`
- `ghcr.io/openhands/runtime:X.Y.Z-nikolaik`, `X.Y-nikolaik`
Every push to `main` / `saas-rel-*` / `oss-rel-*` builds and publishes `ghcr.io/openhands/openhands` and `ghcr.io/openhands/enterprise-server` images for that commit (tagged by SHA, short SHA, and branch name).
The tagging logic lives in `containers/build.sh` — when `GITHUB_REF_NAME` matches a semver pattern (`^[0-9]+\.[0-9]+\.[0-9]+$`), it auto-generates major, major.minor, and `latest` tags.
Pushing a git tag `X.Y.Z` then tags the images for that commit with `X.Y.Z`, `X.Y`, `X`, and `latest`. Non-semver tags just get their literal name applied.
Requires the commit to already be built. If you push the tag too early, the retag CI job fails loudly — re-run it from the Actions UI once the build completes.
## Development: Pin SDK to an Unreleased Commit

View File

@@ -46,39 +46,16 @@ These files contain image tags that **must** be updated whenever the SDK version
### `openhands/version.py`
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
### `openhands/resolver/issue_resolver.py`
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
### `openhands/runtime/utils/runtime_build.py`
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
### `.github/scripts/update_pr_description.sh`
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
### `enterprise/Dockerfile`
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
## V0 Legacy Files (separate update cadence)
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
### `Development.md`
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
### `openhands/runtime/impl/kubernetes/README.md`
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
### `enterprise/enterprise_local/README.md`
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
### `third_party/runtime/impl/daytona/README.md`
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
## Image Registries
| Registry | Usage |
|----------|-------|
| `ghcr.io/openhands/agent-server` | V1 agent-server (sandbox) — built by SDK repo CI |
| `ghcr.io/openhands/openhands` | Main app image — built by `ghcr-build.yml` |
| `ghcr.io/openhands/runtime` | V0 runtime sandbox — built by `ghcr-build.yml` |
| `docker.openhands.dev/openhands/*` | Mirror/CDN for the above images |

1
.gitattributes vendored
View File

@@ -4,4 +4,5 @@
* text eol=lf
# Git incorrectly thinks some media is text
*.png -text
*.gif -text
*.mp4 -text

View File

@@ -0,0 +1,51 @@
name: Compute Docker image tags
description: Produce the canonical OpenHands Docker tag set (ref name, short SHA, full SHA — each in bare and `sha-` prefixed form) for a given image, with optional suffix and extra raw tags.
inputs:
image:
description: Fully qualified image name (e.g. ghcr.io/owner/openhands).
required: true
ref-name:
description: Git ref name to emit as a tag (e.g. main, pr-123, saas-rel-1.2.3).
required: true
suffix:
description: Suffix appended to every tag (e.g. -amd64, -nikolaik-arm64). Leave empty for base (multi-arch manifest) tags.
required: false
default: ""
extra-tags:
description: Additional newline-separated metadata-action tag rules (e.g. extra `type=raw,value=...` lines).
required: false
default: ""
outputs:
tags:
description: Newline-separated list of fully qualified image tags.
value: ${{ steps.meta.outputs.tags }}
labels:
description: Image labels emitted by docker/metadata-action.
value: ${{ steps.meta.outputs.labels }}
version:
description: Sanitized version string (ref-name with any suffix applied). Safe to use in docker tags.
value: ${{ steps.meta.outputs.version }}
runs:
using: composite
steps:
- name: Compute tags
id: meta
uses: docker/metadata-action@v6
env:
# Use the PR head SHA (not the merge SHA) for sha-prefixed tags.
DOCKER_METADATA_PR_HEAD_SHA: "true"
with:
images: ${{ inputs.image }}
flavor: |
latest=false
suffix=${{ inputs.suffix }}
tags: |
type=raw,value=${{ inputs.ref-name }}
type=sha,prefix=sha-
type=sha,prefix=
type=sha,format=long,prefix=sha-
type=sha,format=long,prefix=
${{ inputs.extra-tags }}

View File

@@ -0,0 +1,43 @@
name: Merge multi-arch Docker manifest
description: Build a multi-arch manifest from per-arch image tags pushed by an earlier build step.
inputs:
base-tags:
description: Newline-separated list of base tags (without architecture suffix).
required: true
archs:
description: Space-separated list of architectures (e.g. "amd64 arm64").
required: true
runs:
using: composite
steps:
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create multi-arch manifests
shell: bash
env:
BASE_TAGS: ${{ inputs.base-tags }}
ARCHS: ${{ inputs.archs }}
run: |
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
sources=""
for arch in $ARCHS; do
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
echo "::error::Missing image ${tag}-${arch}"
exit 1
fi
sources+=" ${tag}-${arch}"
done
echo "Creating manifest for $tag from:$sources"
docker buildx imagetools create -t "$tag" $sources
done <<< "$BASE_TAGS"

View File

@@ -13,7 +13,6 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
--name openhands-app-${SHORT_SHA} \
docker.openhands.dev/openhands/openhands:${SHORT_SHA}"

116
.github/workflows/_build-image.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
# Reusable workflow: build a multi-arch Docker image and publish a merged manifest.
# Called per image from .github/workflows/ghcr-build.yml.
name: Build and push multi-arch image
on:
workflow_call:
inputs:
image:
description: Fully-qualified image name (e.g. "ghcr.io/all-hands-ai/openhands").
required: true
type: string
context:
description: Docker build context.
required: false
type: string
default: "."
dockerfile:
description: Path to the Dockerfile.
required: true
type: string
extra-build-args:
description: Additional build-args (newline-separated). OPENHANDS_BUILD_VERSION is added automatically.
required: false
type: string
default: ""
provenance:
description: Value passed to docker/build-push-action provenance.
required: false
type: boolean
default: false
sbom:
description: Value passed to docker/build-push-action sbom.
required: false
type: boolean
default: false
buildx-driver-opts:
description: Extra buildx driver-opts (e.g. "network=host" for enterprise).
required: false
type: string
default: ""
env:
RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
RELEVANT_REF_NAME: ${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}
jobs:
build:
name: Build ${{ inputs.image }} (${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
permissions:
contents: read
packages: write
strategy:
matrix:
arch: [amd64, arm64]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: ${{ inputs.buildx-driver-opts }}
- name: Compute per-arch tags
id: meta
uses: ./.github/actions/docker-image-tags
with:
image: ${{ inputs.image }}
ref-name: ${{ env.RELEVANT_REF_NAME }}
suffix: -${{ matrix.arch }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/${{ matrix.arch }}
build-args: |
OPENHANDS_BUILD_VERSION=${{ env.RELEVANT_REF_NAME }}
${{ inputs.extra-build-args }}
cache-from: |
type=registry,ref=${{ inputs.image }}:buildcache-${{ steps.meta.outputs.version }}
type=registry,ref=${{ inputs.image }}:buildcache-main-${{ matrix.arch }}
cache-to: type=registry,ref=${{ inputs.image }}:buildcache-${{ steps.meta.outputs.version }},mode=max
provenance: ${{ inputs.provenance }}
sbom: ${{ inputs.sbom }}
merge:
name: Merge ${{ inputs.image }} manifest
runs-on: ubuntu-22.04
needs: build
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Compute base tags
id: meta_base
uses: ./.github/actions/docker-image-tags
with:
image: ${{ inputs.image }}
ref-name: ${{ env.RELEVANT_REF_NAME }}
- name: Merge manifests
uses: ./.github/actions/docker-merge-manifest
with:
base-tags: ${{ steps.meta_base.outputs.tags }}
archs: "amd64 arm64"

View File

@@ -1,228 +0,0 @@
name: End-to-End Tests
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
branches:
- main
- develop
workflow_dispatch:
jobs:
e2e-tests:
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
GITHUB_REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4
with:
poetry-version: 2.1.3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'poetry'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
- name: Setup environment for end-to-end tests
run: |
# Create test results directory
mkdir -p test-results
# Create downloads directory for OpenHands (use a directory in the home folder)
mkdir -p $HOME/downloads
sudo chown -R $USER:$USER $HOME/downloads
sudo chmod -R 755 $HOME/downloads
- name: Build OpenHands
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
INSTALL_DOCKER: 1
RUNTIME: docker
FRONTEND_PORT: 12000
FRONTEND_HOST: 0.0.0.0
BACKEND_HOST: 0.0.0.0
BACKEND_PORT: 3000
ENABLE_BROWSER: true
INSTALL_PLAYWRIGHT: 1
run: |
# Fix poetry.lock file if needed
echo "Fixing poetry.lock file if needed..."
poetry lock
# Build OpenHands using make build
echo "Running make build..."
make build
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
echo "Installing Chromium Headless Shell for Playwright..."
poetry run playwright install chromium-headless-shell
# Verify Playwright browsers are installed (for e2e tests only)
echo "Verifying Playwright browsers installation for e2e tests..."
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
echo "ERROR: Chromium browser not found or not working for e2e tests"
echo "$BROWSER_CHECK"
exit 1
else
echo "Playwright browsers are properly installed for e2e tests."
fi
# Docker runtime will handle workspace directory creation
# Start the application using make run with custom parameters and reduced logging
echo "Starting OpenHands using make run..."
# Set environment variables to reduce logging verbosity
export PYTHONUNBUFFERED=1
export LOG_LEVEL=WARNING
export UVICORN_LOG_LEVEL=warning
export OPENHANDS_LOG_LEVEL=WARNING
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
# Store the PID of the make run process
MAKE_PID=$!
echo "OpenHands started with PID: $MAKE_PID"
# Wait for the application to start
echo "Waiting for OpenHands to start..."
max_attempts=15
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
# Check if the process is still running
if ! ps -p $MAKE_PID > /dev/null; then
echo "ERROR: OpenHands process has terminated unexpectedly"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Check if frontend port is open
if nc -z localhost 12000; then
# Verify we can get HTML content
if curl -s http://localhost:12000 | grep -q "<html"; then
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
break
else
echo "Port 12000 is open but not serving HTML content yet"
fi
else
echo "Frontend port 12000 is not open yet"
fi
# Show log output on each attempt
echo "Recent log output:"
tail -n 20 /tmp/openhands-e2e-test.log
# Wait before next attempt
echo "Waiting 10 seconds before next check..."
sleep 10
attempt=$((attempt + 1))
# Exit if we've reached the maximum number of attempts
if [ $attempt -gt $max_attempts ]; then
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
done
# Final verification that the app is running
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
echo "ERROR: OpenHands is not running properly on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Print success message
echo "OpenHands is running successfully on port 12000"
- name: Run end-to-end tests
env:
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
# Check if the application is running
if ! nc -z localhost 12000; then
echo "ERROR: OpenHands is not running on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Run the tests with detailed output
cd tests/e2e
poetry run python -m pytest \
test_settings.py::test_github_token_configuration \
test_conversation.py::test_conversation_start \
test_browsing_catchphrase.py::test_browsing_catchphrase \
test_multi_conversation_resume.py::test_multi_conversation_resume \
-v --no-header --capture=no --timeout=900
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: tests/e2e/test-results/
retention-days: 30
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v7
with:
name: openhands-logs
path: |
/tmp/openhands-e2e-test.log
/tmp/openhands-e2e-build.log
/tmp/openhands-backend.log
/tmp/openhands-frontend.log
/tmp/backend-health-check.log
/tmp/frontend-check.log
/tmp/vite-config.log
/tmp/makefile-contents.log
retention-days: 30
- name: Cleanup
if: always()
run: |
# Stop OpenHands processes
echo "Stopping OpenHands processes..."
pkill -f "python -m openhands.server" || true
pkill -f "npm run dev" || true
pkill -f "make run" || true
# Print process status for debugging
echo "Checking if any OpenHands processes are still running:"
ps aux | grep -E "openhands|npm run dev" || true

View File

@@ -1,17 +1,13 @@
# Workflow that builds, tests and then pushes the OpenHands and runtime docker images to the ghcr.io repository
# Workflow that builds and pushes the OpenHands app and enterprise Docker images to ghcr.io.
# Per-image build logic lives in .github/workflows/_build-image.yml.
name: Docker
# Always run on "main"
# Always run on tags
# Always run on PRs
# Can also be triggered manually
on:
push:
branches:
- main
- "saas-rel-*"
tags:
- "*"
- "oss-rel-*"
pull_request:
workflow_dispatch:
inputs:
@@ -20,243 +16,37 @@ on:
required: true
default: ""
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
# PR events share a group so pushes supersede each other; each commit on a release branch gets its own group.
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
env:
RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
jobs:
define-matrix:
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
platforms="linux/amd64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
]')
else
platforms="linux/amd64,linux/arm64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
]')
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: 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@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- 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 -p ${{ needs.define-matrix.outputs.platforms }}
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
runs-on: ubuntu-22.04
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
needs: define-matrix
strategy:
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Short SHA
run: |
echo SHORT_SHA=$(git rev-parse --short "$RELEVANT_SHA") >> $GITHUB_ENV
- name: Determine docker build params
if: github.event.pull_request.head.repo.fork != true
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ env.DOCKER_TAGS }}
platforms: ${{ env.DOCKER_PLATFORM }}
# Caching directives to boost performance
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }},mode=max
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
provenance: false
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: docker/build-push-action@v6
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v7
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: ubuntu-22.04
permissions:
contents: read
packages: write
needs: [define-matrix, ghcr_build_app]
# Do not build enterprise in forks
build_app:
name: App
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
uses: ./.github/workflows/_build-image.yml
with:
image: ghcr.io/openhands/openhands
dockerfile: containers/app/Dockerfile
# Set up Docker Buildx for better performance
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/openhands/enterprise-server
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=sha,format=long
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=match,pattern=cloud-\d+\.\d+\.\d+
flavor: |
latest=auto
prefix=
suffix=
env:
DOCKER_METADATA_PR_HEAD_SHA: true
- name: Determine app image tag
shell: bash
run: |
# 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: docker/build-push-action@v6
with:
context: .
file: enterprise/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
platforms: linux/amd64
# Add build provenance
provenance: true
# Add build attestations for better security
sbom: true
# "All Runtime Tests Passed" is a required job for PRs to merge
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
runs-on: ubuntu-22.04
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
build_enterprise:
name: Enterprise
if: github.event.pull_request.head.repo.fork != true
needs: build_app
uses: ./.github/workflows/_build-image.yml
with:
image: ghcr.io/openhands/enterprise-server
dockerfile: enterprise/Dockerfile
extra-build-args: OPENHANDS_VERSION=sha-${{ github.event.pull_request.head.sha || github.sha }}
provenance: true
sbom: true
buildx-driver-opts: network=host
update_pr_description:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
needs: [ghcr_build_runtime]
needs: build_app
runs-on: ubuntu-22.04
steps:
- name: Checkout
@@ -264,7 +54,7 @@ jobs:
- name: Get short SHA
id: short_sha
run: echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
run: echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> "$GITHUB_OUTPUT"
- name: Update PR Description
env:
@@ -275,4 +65,4 @@ jobs:
shell: bash
run: |
echo "Updating PR description with Docker and uvx commands"
bash ${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh
bash "${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh"

View File

@@ -72,7 +72,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: 3.12
cache: "pip"

View File

@@ -47,7 +47,7 @@ jobs:
with:
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: 3.12
cache: "pip"
@@ -64,7 +64,7 @@ jobs:
with:
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: 3.12
cache: "pip"

View File

@@ -1,433 +0,0 @@
name: Auto-Fix Tagged Issue with OpenHands
on:
workflow_call:
inputs:
max_iterations:
required: false
type: number
default: 50
macro:
required: false
type: string
default: "@openhands-agent"
target_branch:
required: false
type: string
default: "main"
description: "Target branch to pull and create PR against"
pr_type:
required: false
type: string
default: "draft"
description: "The PR type that is going to be created (draft, ready)"
LLM_MODEL:
required: false
type: string
default: "anthropic/claude-sonnet-4-20250514"
LLM_API_VERSION:
required: false
type: string
default: ""
base_container_image:
required: false
type: string
default: ""
description: "Custom sandbox env"
runner:
required: false
type: string
default: "ubuntu-latest"
secrets:
LLM_MODEL:
required: false
LLM_API_KEY:
required: true
LLM_BASE_URL:
required: false
PAT_TOKEN:
required: false
PAT_USERNAME:
required: false
issues:
types: [labeled]
pull_request:
types: [labeled]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
permissions:
contents: write
pull-requests: write
issues: write
jobs:
auto-fix:
if: |
github.event_name == 'workflow_call' ||
github.event.label.name == 'fix-me' ||
github.event.label.name == 'fix-me-experimental' ||
(
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
contains(github.event.comment.body, inputs.macro || '@openhands-agent') &&
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')
) ||
(github.event_name == 'pull_request_review' &&
contains(github.event.review.body, inputs.macro || '@openhands-agent') &&
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
)
)
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Upgrade pip
run: |
python -m pip install --upgrade pip
- name: Get latest versions and create requirements.txt
run: |
python -m pip index versions openhands-ai > openhands_versions.txt
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
# Create a new requirements.txt locally within the workflow, ensuring no reference to the repo's file
echo "openhands-ai==${OPENHANDS_VERSION}" > /tmp/requirements.txt
cat /tmp/requirements.txt
- name: Cache pip dependencies
if: |
!(
github.event.label.name == 'fix-me-experimental' ||
(
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
contains(github.event.comment.body, '@openhands-agent-exp')
) ||
(
github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@openhands-agent-exp')
)
)
uses: actions/cache@v5
with:
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
- name: Check required environment variables
env:
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
GITHUB_TOKEN: ${{ github.token }}
run: |
required_vars=("LLM_API_KEY")
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo "Error: Required environment variable $var is not set."
exit 1
fi
done
# Check optional variables and warn about fallbacks
if [ -z "$LLM_BASE_URL" ]; then
echo "Warning: LLM_BASE_URL is not set, will use default API endpoint"
fi
if [ -z "$PAT_TOKEN" ]; then
echo "Warning: PAT_TOKEN is not set, falling back to GITHUB_TOKEN"
fi
if [ -z "$PAT_USERNAME" ]; then
echo "Warning: PAT_USERNAME is not set, will use openhands-agent"
fi
- name: Set environment variables
env:
REVIEW_BODY: ${{ github.event.review.body || '' }}
run: |
# Handle pull request events first
if [ -n "${{ github.event.pull_request.number }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle pull request review events
elif [ -n "$REVIEW_BODY" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle issue comment events that reference a PR
elif [ -n "${{ github.event.issue.pull_request }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle regular issue events
else
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
fi
if [ -n "$REVIEW_BODY" ]; then
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
else
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV
fi
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.PAT_TOKEN || github.token }}" >> $GITHUB_ENV
echo "SANDBOX_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
# Set branch variables
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
- name: Comment on issue with start message
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const issueType = process.env.ISSUE_TYPE;
github.rest.issues.createComment({
issue_number: ${{ env.ISSUE_NUMBER }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `[OpenHands](https://github.com/OpenHands/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
});
- name: Install OpenHands
id: install_openhands
uses: actions/github-script@v7
env:
COMMENT_BODY: ${{ github.event.comment.body || '' }}
REVIEW_BODY: ${{ github.event.review.body || '' }}
LABEL_NAME: ${{ github.event.label.name || '' }}
EVENT_NAME: ${{ github.event_name }}
with:
script: |
const commentBody = process.env.COMMENT_BODY.trim();
const reviewBody = process.env.REVIEW_BODY.trim();
const labelName = process.env.LABEL_NAME.trim();
const eventName = process.env.EVENT_NAME.trim();
// Check conditions
const isExperimentalLabel = labelName === "fix-me-experimental";
const isIssueCommentExperimental =
(eventName === "issue_comment" || eventName === "pull_request_review_comment") &&
commentBody.includes("@openhands-agent-exp");
const isReviewCommentExperimental =
eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp");
// Set output variable
core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental);
// Perform package installation
if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
console.log("Installing experimental OpenHands...");
await exec.exec("pip install git+https://github.com/openhands/openhands.git");
} else {
console.log("Installing from requirements.txt...");
await exec.exec("pip install -r /tmp/requirements.txt");
}
- name: Attempt to resolve issue
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
PYTHONPATH: ""
run: |
cd /tmp && python -m openhands.resolver.resolve_issue \
--selected-repo ${{ github.repository }} \
--issue-number ${{ env.ISSUE_NUMBER }} \
--issue-type ${{ env.ISSUE_TYPE }} \
--max-iterations ${{ env.MAX_ITERATIONS }} \
--comment-id ${{ env.COMMENT_ID }} \
--is-experimental ${{ steps.install_openhands.outputs.isExperimental }}
- name: Check resolution result
id: check_result
run: |
if cd /tmp && grep -q '"success":true' output/output.jsonl; then
echo "RESOLUTION_SUCCESS=true" >> $GITHUB_OUTPUT
else
echo "RESOLUTION_SUCCESS=false" >> $GITHUB_OUTPUT
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v7
if: always() # Upload even if the previous steps fail
with:
name: resolver-output
path: /tmp/output/output.jsonl
retention-days: 30 # Keep the artifact for 30 days
- name: Create draft PR or push branch
if: always() # Create PR or branch even if the previous steps fail
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
PYTHONPATH: ""
run: |
if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
cd /tmp && python -m openhands.resolver.send_pull_request \
--issue-number ${{ env.ISSUE_NUMBER }} \
--target-branch ${{ env.TARGET_BRANCH }} \
--pr-type ${{ inputs.pr_type || 'draft' }} \
--reviewer ${{ github.actor }} | tee pr_result.txt && \
grep "PR created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
else
cd /tmp && python -m openhands.resolver.send_pull_request \
--issue-number ${{ env.ISSUE_NUMBER }} \
--pr-type branch \
--send-on-failure | tee branch_result.txt && \
grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt
fi
# Step leaves comment for when agent is invoked on PR
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
uses: actions/github-script@v7
if: always()
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const fs = require('fs');
const issueNumber = process.env.ISSUE_NUMBER;
let logContent = '';
try {
logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
} catch (error) {
console.error('Error reading pr_result.txt file:', error);
}
const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
// Check logs from send_pull_request.py (pushes code to GitHub)
if (logContent.includes("Updated pull request")) {
console.log("Updated pull request found. Skipping comment.");
process.env.AGENT_RESPONDED = 'true';
} else if (logContent.includes(noChangesMessage)) {
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
});
process.env.AGENT_RESPONDED = 'true';
}
# Step leaves comment for when agent is invoked on issue
- name: Comment on issue # Comment link to either PR or branch created by agent
uses: actions/github-script@v7
if: always() # Comment on issue even if the previous steps fail
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const fs = require('fs');
const path = require('path');
const issueNumber = process.env.ISSUE_NUMBER;
const success = process.env.RESOLUTION_SUCCESS === 'true';
let prNumber = '';
let branchName = '';
let resultExplanation = '';
try {
if (success) {
prNumber = fs.readFileSync('/tmp/pr_number.txt', 'utf8').trim();
} else {
branchName = fs.readFileSync('/tmp/branch_name.txt', 'utf8').trim();
}
} catch (error) {
console.error('Error reading file:', error);
}
try {
if (!success){
// Read result_explanation from JSON file for failed resolution
const outputFilePath = path.resolve('/tmp/output/output.jsonl');
if (fs.existsSync(outputFilePath)) {
const outputContent = fs.readFileSync(outputFilePath, 'utf8');
const jsonLines = outputContent.split('\n').filter(line => line.trim() !== '');
if (jsonLines.length > 0) {
// First entry in JSON lines has the key 'result_explanation'
const firstEntry = JSON.parse(jsonLines[0]);
resultExplanation = firstEntry.result_explanation || '';
}
}
}
} catch (error){
console.error('Error reading file:', error);
}
// Check "success" log from resolver output
if (success && prNumber) {
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.`
});
process.env.AGENT_RESPONDED = 'true';
} else if (!success && branchName) {
let commentBody = `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`;
if (resultExplanation) {
commentBody += `\n\nAdditional details about the failure:\n${resultExplanation}`;
}
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
process.env.AGENT_RESPONDED = 'true';
}
# Leave error comment when both PR/Issue comment handling fail
- name: Fallback Error Comment
uses: actions/github-script@v7
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
env:
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const issueNumber = process.env.ISSUE_NUMBER;
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
});

View File

@@ -35,7 +35,7 @@ jobs:
if: steps.check-fork.outputs.is_fork == 'false'
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
- name: Remove .pr/ directory
id: remove
@@ -59,7 +59,7 @@ jobs:
- name: Update PR comment after cleanup
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
@@ -107,7 +107,7 @@ jobs:
- name: Post or update PR comment
if: steps.check.outputs.exists == 'true'
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';

View File

@@ -44,5 +44,5 @@ jobs:
llm-base-url: https://llm-proxy.app.all-hands.dev
review-style: roasted
llm-api-key: ${{ secrets.LLM_API_KEY }}
github-token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }}

View File

@@ -45,7 +45,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -60,10 +60,6 @@ jobs:
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v7
with:
@@ -84,7 +80,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -115,7 +111,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v8
id: download
with:
pattern: coverage-*

View File

@@ -24,7 +24,7 @@ jobs:
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: 3.12
- name: Install Poetry

59
.github/workflows/tag-image.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
# Adds a git-tag name to existing Docker images.
# Triggered when a tag is pushed: finds the images built at the tag's commit
# (tagged `sha-<full>`) and adds the tag name as an alias for the same manifest.
# Semver tags (X.Y.Z) also get X.Y, X, and latest aliases.
# No rebuild — pure registry-side retag via `docker buildx imagetools create`.
name: Tag Docker images
on:
push:
tags:
- "*"
jobs:
retag:
runs-on: ubuntu-22.04
permissions:
packages: write
strategy:
matrix:
image:
- ghcr.io/openhands/openhands
- ghcr.io/openhands/enterprise-server
steps:
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute tags
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ matrix.image }}
flavor: latest=auto
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Add tags to existing image
env:
SRC: ${{ matrix.image }}:sha-${{ github.sha }}
TAGS: ${{ steps.meta.outputs.tags }}
shell: bash
run: |
set -euo pipefail
if ! docker buildx imagetools inspect "$SRC" > /dev/null 2>&1; then
echo "::error::Source image $SRC does not exist. The Docker workflow for commit ${{ github.sha }} may not have completed successfully. Re-run this workflow once the build finishes."
exit 1
fi
args=()
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
args+=(-t "$tag")
done <<< "$TAGS"
docker buildx imagetools create "${args[@]}" "$SRC"

View File

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

4
.gitignore vendored
View File

@@ -254,10 +254,6 @@ run_instance_logs
runtime_*.tar
# docker build
containers/runtime/Dockerfile
containers/runtime/project.tar.gz
containers/runtime/code
**/node_modules/
# test results

View File

@@ -13,6 +13,14 @@ export RUNTIME=local
make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 &> /tmp/openhands-log.txt &
```
Local run troubleshooting notes:
- If the backend fails with `nc: command not found`, install `netcat-openbsd`.
- If local runtime startup fails with `duplicate session: test-session`, clear the stale tmux session on the default socket: `tmux -S /tmp/tmux-$(id -u)/default kill-session -t test-session`.
- Local runtime browser startup expects Playwright browsers under `~/.cache/playwright`; if needed run `PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/playwright poetry run playwright install chromium`.
- In this sandbox environment, an inherited `SESSION_API_KEY` can make `/api/v1/settings` return 401 in the browser. Unset it before `make run` when you want to use the local web UI directly.
- In this sandbox, `frontend`'s `npm run dev:mock` / `dev:mock:saas` can start but still be awkward to browse through the work-host proxy. For PR QA screenshots, a reliable fallback is to `npm run build` with the desired `VITE_MOCK_*` env, then serve `build/` with a tiny custom HTTP server that returns the minimal mock JSON endpoints needed by the settings page.
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
@@ -138,6 +146,8 @@ Frontend:
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
- For SaaS organization management screens, prefer deriving the selected organization from `useOrganizations()` plus the selected org ID store instead of adding a dedicated single-org fetch when only list-level fields (for example `name`) are needed.
VSCode Extension:
- Located in the `openhands/integrations/vscode` directory
@@ -226,6 +236,7 @@ Each integration follows a consistent pattern with service classes, storage mode
- Database changes require careful migration planning in `enterprise/migrations/`
- Always test changes in both OpenHands and enterprise contexts
- Use the enterprise-specific Makefile commands for development
- When the `openhands-ai` package (root project) version has been updated, run `poetry lock` in the `enterprise/` folder to update the version in the enterprise poetry lockfile.
**Enterprise Testing Best Practices:**

View File

@@ -36,8 +36,6 @@ Full details in our [Development Guide](./Development.md).
- **[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
## What Can You Build?

View File

@@ -16,7 +16,7 @@ open source community:
#### [Aider](https://github.com/paul-gauthier/aider)
- License: Apache License 2.0
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
- License: Apache License 2.0

View File

@@ -309,16 +309,6 @@ poetry run pytest ./tests/unit/test_*.py
---
## Using Existing Docker Images
To reduce build time, you can use an existing runtime image:
```bash
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
```
---
## Help
```bash
@@ -339,4 +329,3 @@ make help
- [/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/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

View File

@@ -11,7 +11,15 @@ DEFAULT_WORKSPACE_DIR = "./workspace"
DEFAULT_MODEL = "gpt-4o"
CONFIG_FILE = config.toml
PRE_COMMIT_CONFIG_PATH = "./dev_config/python/.pre-commit-config.yaml"
PYTHON_VERSION = 3.12
PYTHON_MIN_VERSION = 3.12
PYTHON_MAX_VERSION = 3.14
PYTHON_CANDIDATES ?= python3.13 python3.12 python3
PYTHON ?= $(shell for cmd in $(PYTHON_CANDIDATES); do \
if command -v $$cmd > /dev/null 2>&1 && $$cmd -c 'import sys; raise SystemExit(0 if ((3, 12) <= sys.version_info[:2] < (3, 14)) else 1)' > /dev/null 2>&1; then \
echo $$cmd; \
exit 0; \
fi; \
done)
KIND_CLUSTER_NAME = "local-hands"
# ANSI color codes
@@ -63,10 +71,10 @@ check-system:
check-python:
@echo "$(YELLOW)Checking Python installation...$(RESET)"
@if command -v python$(PYTHON_VERSION) > /dev/null; then \
echo "$(BLUE)$(shell python$(PYTHON_VERSION) --version) is already installed.$(RESET)"; \
@if [ -n "$(PYTHON)" ]; then \
echo "$(BLUE)$$($(PYTHON) --version) is already installed (using $(PYTHON)).$(RESET)"; \
else \
echo "$(RED)Python $(PYTHON_VERSION) is not installed. Please install Python $(PYTHON_VERSION) to continue.$(RESET)"; \
echo "$(RED)A compatible Python interpreter (>= $(PYTHON_MIN_VERSION), < $(PYTHON_MAX_VERSION)) is required. Please install Python 3.12 or 3.13 to continue.$(RESET)"; \
exit 1; \
fi
@@ -118,31 +126,34 @@ check-tmux:
check-poetry:
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
@if command -v poetry > /dev/null; then \
@if [ -z "$(PYTHON)" ]; then \
echo "$(RED)A compatible Python interpreter (>= $(PYTHON_MIN_VERSION), < $(PYTHON_MAX_VERSION)) is required. Please install Python 3.12 or 3.13 to continue.$(RESET)"; \
exit 1; \
elif command -v poetry > /dev/null; then \
POETRY_VERSION=$(shell poetry --version 2>&1 | sed -E 's/Poetry \(version ([0-9]+\.[0-9]+\.[0-9]+)\)/\1/'); \
IFS='.' read -r -a POETRY_VERSION_ARRAY <<< "$$POETRY_VERSION"; \
if [ $${POETRY_VERSION_ARRAY[0]} -gt 1 ] || ([ $${POETRY_VERSION_ARRAY[0]} -eq 1 ] && [ $${POETRY_VERSION_ARRAY[1]} -ge 8 ]); then \
echo "$(BLUE)$(shell poetry --version) is already installed.$(RESET)"; \
else \
echo "$(RED)Poetry 1.8 or later is required. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | $(PYTHON) -$(RESET)"; \
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
exit 1; \
fi; \
else \
echo "$(RED)Poetry is not installed. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | $(PYTHON) -$(RESET)"; \
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
exit 1; \
fi
install-python-dependencies:
install-python-dependencies: check-python
@echo "$(GREEN)Installing Python dependencies...$(RESET)"
@if [ -z "${TZ}" ]; then \
echo "Defaulting TZ (timezone) to UTC"; \
export TZ="UTC"; \
fi
poetry env use python$(PYTHON_VERSION)
poetry env use $(PYTHON)
@if [ "$(shell uname)" = "Darwin" ]; then \
echo "$(BLUE)Installing chroma-hnswlib...$(RESET)"; \
export HNSWLIB_NO_NATIVE=1; \

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.8-trixie-slim AS frontend-builder
FROM node:25.9-trixie-slim AS frontend-builder
WORKDIR /app
@@ -88,11 +88,8 @@ 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
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
RUN python openhands/core/download.py # No-op to download assets
# Add this line to set group ownership of all files/directories not already in "app" group
# openhands:openhands -> openhands:openhands
RUN find /app \! -group openhands -exec chgrp openhands {} +

View File

@@ -1,4 +0,0 @@
DOCKER_REGISTRY=ghcr.io
DOCKER_ORG=openhands
DOCKER_IMAGE=openhands
DOCKER_BASE_DIR="."

View File

@@ -23,18 +23,6 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
unset WORKSPACE_BASE
fi
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
echo "Downloading and installing third_party_runtimes..."
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
echo "third_party_runtimes installed successfully."
else
echo "Failed to install third_party_runtimes." >&2
exit 1
fi
fi
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
echo "Running OpenHands as root"
export RUN_AS_OPENHANDS=false

View File

@@ -1,187 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
# Initialize variables with default values
image_name=""
org_name=""
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>] [-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
}
# Parse command-line options
while [[ $# -gt 0 ]]; do
case $1 in
-i) image_name="$2"; shift 2 ;;
-o) org_name="$2"; shift 2 ;;
--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
done
# Check if required arguments are provided
if [[ -z "$image_name" ]]; then
echo "Error: Image name is required."
usage
fi
echo "Building: $image_name"
tags=()
OPENHANDS_BUILD_VERSION="dev"
cache_tag_base="buildcache"
cache_tag="$cache_tag_base"
if [[ -n $RELEVANT_SHA ]]; then
git_hash=$(git rev-parse --short "$RELEVANT_SHA")
tags+=("$git_hash")
tags+=("$RELEVANT_SHA")
fi
if [[ -n $GITHUB_REF_NAME ]]; then
# check if ref name is a version number
if [[ $GITHUB_REF_NAME =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
major_version=$(echo "$GITHUB_REF_NAME" | cut -d. -f1)
minor_version=$(echo "$GITHUB_REF_NAME" | cut -d. -f1,2)
tags+=("$major_version" "$minor_version")
tags+=("latest")
fi
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
tags+=("$sanitized_ref_name")
cache_tag+="-${sanitized_ref_name}"
fi
if [[ -n $tag_suffix ]]; then
cache_tag+="-${tag_suffix}"
for i in "${!tags[@]}"; do
tags[$i]="${tags[$i]}-$tag_suffix"
done
fi
echo "Tags: ${tags[@]}"
if [[ "$image_name" == "openhands" ]]; then
dir="./containers/app"
elif [[ "$image_name" == "runtime" ]]; then
dir="./containers/runtime"
else
dir="./containers/$image_name"
fi
if [[ (! -f "$dir/Dockerfile") && "$image_name" != "runtime" ]]; then
# Allow runtime to be built without a Dockerfile
echo "No Dockerfile found"
exit 1
fi
if [[ ! -f "$dir/config.sh" ]]; then
echo "No config.sh found for Dockerfile"
exit 1
fi
source "$dir/config.sh"
if [[ -n "$org_name" ]]; then
DOCKER_ORG="$org_name"
fi
# If $DOCKER_IMAGE_SOURCE_TAG is set, add it to the tags
if [[ -n "$DOCKER_IMAGE_SOURCE_TAG" ]]; then
tags+=("$DOCKER_IMAGE_SOURCE_TAG")
fi
# If $DOCKER_IMAGE_TAG is set, add it to the tags
if [[ -n "$DOCKER_IMAGE_TAG" ]]; then
tags+=("$DOCKER_IMAGE_TAG")
fi
DOCKER_REPOSITORY="$DOCKER_REGISTRY/$DOCKER_ORG/$DOCKER_IMAGE"
DOCKER_REPOSITORY=${DOCKER_REPOSITORY,,} # lowercase
echo "Repo: $DOCKER_REPOSITORY"
echo "Base dir: $DOCKER_BASE_DIR"
args=""
full_tags=()
for tag in "${tags[@]}"; do
args+=" -t $DOCKER_REPOSITORY:$tag"
full_tags+=("$DOCKER_REPOSITORY:$tag")
done
if [[ $push -eq 1 ]]; then
args+=" --push"
args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max"
fi
if [[ $load -eq 1 ]]; then
args+=" --load"
fi
echo "Args: $args"
# 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
# For push or without load, build for multiple platforms
platform="linux/amd64,linux/arm64"
fi
if [[ $dry_run -eq 1 ]]; then
echo "Dry Run is enabled. Writing build config to docker-build-dry.json"
jq -n \
--argjson tags "$(printf '%s\n' "${full_tags[@]}" | jq -R . | jq -s .)" \
--arg platform "$platform" \
--arg openhands_build_version "$OPENHANDS_BUILD_VERSION" \
--arg dockerfile "$dir/Dockerfile" \
'{
tags: $tags,
platform: $platform,
build_args: [
"OPENHANDS_BUILD_VERSION=" + $openhands_build_version
],
dockerfile: $dockerfile
}' > docker-build-dry.json
exit 0
fi
echo "Building for platform(s): $platform"
docker buildx build \
$args \
--build-arg OPENHANDS_BUILD_VERSION="$OPENHANDS_BUILD_VERSION" \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag_base-main \
--platform $platform \
--provenance=false \
-f "$dir/Dockerfile" \
"$DOCKER_BASE_DIR"
# If load was requested, print the loaded images
if [[ $load -eq 1 ]]; then
echo "Local images built:"
docker images "$DOCKER_REPOSITORY" --format "{{.Repository}}:{{.Tag}}"
fi

View File

@@ -1,12 +0,0 @@
# Dynamically constructed Dockerfile
This folder builds a runtime image (sandbox), which will use a dynamically generated `Dockerfile`
that depends on the `base_image` **AND** a [Python source distribution](https://docs.python.org/3.10/distutils/sourcedist.html) that is based on the current commit of `openhands`.
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `containers/runtime`:
```bash
poetry run python3 -m openhands.runtime.utils.runtime_build \
--base_image nikolaik/python-nodejs:python3.12-nodejs22 \
--build_folder containers/runtime
```

View File

@@ -1,7 +0,0 @@
DOCKER_REGISTRY=ghcr.io
DOCKER_ORG=openhands
DOCKER_BASE_DIR="./containers/runtime"
DOCKER_IMAGE=runtime
# These variables will be appended by the runtime_build.py script
# DOCKER_IMAGE_TAG=
# DOCKER_IMAGE_SOURCE_TAG=

View File

@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
- id: check-yaml
args: ["--allow-multiple-documents"]
- id: debug-statements
@@ -37,12 +37,12 @@ repos:
entry: ruff check --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix, --unsafe-fixes]
exclude: ^(third_party/|enterprise/)
exclude: ^(enterprise/)
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
exclude: ^(third_party/|enterprise/)
exclude: ^(enterprise/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
@@ -58,8 +58,9 @@ repos:
types-Markdown,
pydantic,
lxml,
"openhands-sdk==1.14",
"openhands-tools==1.14",
"openhands-sdk==1.17.0",
"openhands-tools==1.17.0",
"sqlalchemy>=2.0",
]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/

View File

@@ -10,10 +10,7 @@ strict_optional = True
disable_error_code = type-abstract
# Exclude third-party runtime directory from type checking
exclude = (third_party/|enterprise/)
[mypy-openhands.memory.condenser.impl.*]
disable_error_code = override
exclude = (enterprise/)
[mypy-openai.*]
follow_imports = skip

View File

@@ -1,5 +1,5 @@
# Exclude third-party runtime directory from linting
exclude = ["third_party/", "enterprise/"]
exclude = ["enterprise/"]
[lint]
select = [

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ repos:
- ./
- stripe==11.5.0
- pygithub==2.6.1
- sqlalchemy>=2.0
# Use -p (package) to avoid dual module name conflict when using MYPYPATH
# MYPYPATH=enterprise allows resolving bare imports like "from integrations.xxx"
# Note: tests package excluded to avoid conflict with core openhands tests

View File

@@ -61,13 +61,6 @@ export LITE_LLM_API_KEY=<your LLM API key>
python enterprise_local/convert_to_env.py
```
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
```
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
```
By default the application will log in json, you can override.
```
@@ -203,7 +196,6 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
@@ -237,7 +229,6 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",

View File

@@ -429,6 +429,11 @@ class GitHubDataCollector:
- Num openhands review comments
"""
pr_number = openhands_pr.pr_number
if openhands_pr.installation_id is None:
logger.warning(
f'Skipping PR {openhands_pr.repo_name}#{pr_number}: missing installation_id'
)
return
installation_id = int(openhands_pr.installation_id)
repo_id = openhands_pr.repo_id

View File

@@ -32,7 +32,6 @@ from pydantic import SecretStr
from server.auth.auth_error import ExpiredError
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderToken, ProviderType
@@ -318,17 +317,12 @@ class GithubManager(Manager[GithubViewType]):
return
async def start_job(self, github_view: GithubViewType) -> None:
"""Kick off a job with openhands agent.
"""Kick off a job with openhands agent using V1 app conversation system.
1. Get user credential
2. Initialize new conversation with repo
3. Save interaction data
"""
# Importing here prevents circular import
from server.conversation_callback_processor.github_callback_processor import (
GithubCallbackProcessor,
)
try:
msg_info: str = ''
@@ -402,19 +396,7 @@ class GithubManager(Manager[GithubViewType]):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
if not github_view.v1_enabled:
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
send_summary_instruction=True,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Github] Registered callback processor for conversation {conversation_id}'
)
# V1 callback processors are registered by the view during conversation creation
# Send message with conversation link
conversation_link = CONVERSATION_URL.format(conversation_id)

View File

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

View File

@@ -43,7 +43,6 @@ 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 start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
@@ -209,43 +208,9 @@ class GithubIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
else:
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
logger.info('[GitHub]: Creating V0 conversation')
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
await start_conversation(
user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
custom_secrets=custom_secrets,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_metadata.conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=conversation_instructions,
# V0 conversation path has been removed - all conversations use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
@@ -258,7 +223,6 @@ class GithubIssue(ResolverViewInterface):
comments, inline review comments) override this method to control ordering
(e.g., context first, then the triggering comment, then previous comments).
"""
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)

View File

@@ -24,7 +24,6 @@ from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
@@ -171,17 +170,11 @@ class GitlabManager(Manager[GitlabViewType]):
)
async def start_job(self, gitlab_view: GitlabViewType) -> None:
"""
Start a job for the GitLab view.
"""Start a job for the GitLab view using V1 app conversation system.
Args:
gitlab_view: The GitLab view object containing issue/PR/comment info
"""
# Importing here prevents circular import
from server.conversation_callback_processor.gitlab_callback_processor import (
GitlabCallbackProcessor,
)
try:
try:
user_info = gitlab_view.user_info
@@ -235,19 +228,7 @@ class GitlabManager(Manager[GitlabViewType]):
f'[GitLab] Created conversation {conversation_id} for user {user_info.username}'
)
if not gitlab_view.v1_enabled:
# Create a GitlabCallbackProcessor for this conversation
processor = GitlabCallbackProcessor(
gitlab_view=gitlab_view,
send_summary_instruction=True,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[GitLab] Created callback processor for conversation {conversation_id}'
)
# V1 callback processors are registered by the view during conversation creation
conversation_link = CONVERSATION_URL.format(conversation_id)
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"

View File

@@ -31,7 +31,6 @@ 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 start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
@@ -167,41 +166,9 @@ class GitlabIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
# v1_enabled is already set at construction time in the factory method
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
else:
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
logger.info('[GitLab]: Creating V0 conversation')
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
await start_conversation(
user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
custom_secrets=custom_secrets,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_metadata.conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=conversation_instructions,
# V0 conversation path has been removed - all conversations use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
async def _create_v1_conversation(

View File

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

View File

@@ -136,11 +136,10 @@ class JiraPayloadParser:
items = changelog.get('items', [])
# Extract labels that were added
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
labels = set()
for item in items:
if item.get('field') == 'labels' and item.get('toString'):
labels.update(item['toString'].split())
if self.oh_label not in labels:
return JiraPayloadSkipped(

View File

@@ -0,0 +1,238 @@
import logging
from uuid import UUID
import httpx
from integrations.utils import format_jira_comment_body, get_summary_instruction
from pydantic import Field
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.utils.http_session import httpx_verify_option
_logger = logging.getLogger(__name__)
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
class JiraV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Jira V1 integrations."""
should_request_summary: bool = Field(default=True)
svc_acc_email: str
decrypted_api_key: str
issue_key: str
jira_cloud_id: str
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Jira V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
return None
_logger.info('[Jira] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info('[Jira] Should request summary: %s', self.should_request_summary)
if not self.should_request_summary:
return None
self.should_request_summary = False
try:
_logger.info(f'[Jira] Requesting summary {conversation_id}')
summary = await self._request_summary(conversation_id)
_logger.info(
f'[Jira] Posting summary {conversation_id}',
extra={'summary': summary},
)
await self._post_summary_to_jira(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception(f'[Jira] Failed to post summary: {e}', stack_info=True)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
async def _request_summary(self, conversation_id: UUID) -> str:
"""Ask the agent to produce a summary of its work and return the agent response."""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f"{agent_server_url.rstrip('/')}"
f"/api/conversations/{conversation_id}/ask_agent"
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception:
pass
_logger.exception(
'[Jira] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.exception(
'[Jira] Timeout error: %s. Request payload: %s',
error_detail,
payload,
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
async def _post_summary_to_jira(self, summary: str):
"""Post the summary back to the Jira issue."""
if not all(
[
self.svc_acc_email,
self.decrypted_api_key,
self.issue_key,
self.jira_cloud_id,
]
):
_logger.warning('[Jira] Missing required data for posting summary')
return
# Add a comment to the Jira issue with the summary
comment_url = (
f'{JIRA_CLOUD_API_URL}/{self.jira_cloud_id}'
f'/rest/api/2/issue/{self.issue_key}/comment'
)
message = f'OpenHands resolved this issue:\n\n{summary}'
comment_body = format_jira_comment_body(message)
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
comment_url,
auth=(self.svc_acc_email, self.decrypted_api_key),
json=comment_body,
)
response.raise_for_status()
_logger.info(f'[Jira] Posted summary to {self.issue_key}')

View File

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

View File

@@ -20,11 +20,11 @@ from integrations.utils import (
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_session_expired_message,
markdown_to_jira_markup,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from storage.jira_dc_integration_store import JiraDcIntegrationStore
from storage.jira_dc_user import JiraDcUser
from storage.jira_dc_workspace import JiraDcWorkspace
@@ -354,12 +354,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
return False
async def start_job(self, jira_dc_view: JiraDcViewInterface) -> None:
"""Start a Jira DC job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_dc_callback_processor import (
JiraDcCallbackProcessor,
)
"""Start a Jira DC job/conversation using V1 app conversation system."""
try:
user_info: JiraDcUser = jira_dc_view.jira_dc_user
logger.info(
@@ -367,7 +362,15 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
f'issue {jira_dc_view.job_context.issue_key}',
)
# Create conversation
# Set decrypted API key for new conversations (needed for V1 callback processor)
if isinstance(jira_dc_view, JiraDcNewConversationView):
api_key = self.token_manager.decrypt_text(
jira_dc_view.jira_dc_workspace.svc_acc_api_key
)
jira_dc_view._decrypted_api_key = api_key
# Create conversation using V1 app conversation system
# The callback processor is registered automatically by the view
conversation_id = await jira_dc_view.create_or_update_conversation(
self.jinja_env
)
@@ -376,21 +379,6 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
f'[Jira DC] Created/Updated conversation {conversation_id} for issue {jira_dc_view.job_context.issue_key}'
)
if isinstance(jira_dc_view, JiraDcNewConversationView):
# Register callback processor for updates
processor = JiraDcCallbackProcessor(
issue_key=jira_dc_view.job_context.issue_key,
workspace_name=jira_dc_view.jira_dc_workspace.name,
base_api_url=jira_dc_view.job_context.base_api_url,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Jira DC] Created callback processor for conversation {conversation_id}'
)
# Send initial response
msg_info = jira_dc_view.get_response_msg()
@@ -468,7 +456,8 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
"""
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
data = {'body': message}
# Convert standard Markdown to Jira Wiki Markup for proper rendering
data = {'body': markdown_to_jira_markup(message)}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()

View File

@@ -0,0 +1,243 @@
"""Jira Data Center V1 callback processor.
This processor handles events from V1 conversations and posts
summaries back to Jira DC issues when the agent finishes work.
"""
import logging
from uuid import UUID
import httpx
from integrations.utils import get_summary_instruction, markdown_to_jira_markup
from pydantic import Field
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.utils.http_session import httpx_verify_option
_logger = logging.getLogger(__name__)
class JiraDcV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Jira Data Center V1 integrations."""
should_request_summary: bool = Field(default=True)
issue_key: str
workspace_name: str
base_api_url: str
svc_acc_api_key: str # Decrypted API key
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Jira DC V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
return None
_logger.info('[Jira DC] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info(
'[Jira DC] Should request summary: %s', self.should_request_summary
)
if not self.should_request_summary:
return None
self.should_request_summary = False
try:
_logger.info(f'[Jira DC] Requesting summary {conversation_id}')
summary = await self._request_summary(conversation_id)
_logger.info(
f'[Jira DC] Posting summary {conversation_id}',
extra={'summary': summary},
)
await self._post_summary_to_jira_dc(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception(f'[Jira DC] Failed to post summary: {e}', stack_info=True)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
async def _request_summary(self, conversation_id: UUID) -> str:
"""Ask the agent to produce a summary of its work and return the agent response."""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f"{agent_server_url.rstrip('/')}"
f"/api/conversations/{conversation_id}/ask_agent"
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception:
pass
_logger.exception(
'[Jira DC] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.exception(
'[Jira DC] Timeout error: %s. Request payload: %s',
error_detail,
payload,
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
async def _post_summary_to_jira_dc(self, summary: str):
"""Post the summary back to the Jira DC issue."""
if not all(
[
self.svc_acc_api_key,
self.issue_key,
self.base_api_url,
]
):
_logger.warning('[Jira DC] Missing required data for posting summary')
return
# Add a comment to the Jira DC issue with the summary
comment_url = f'{self.base_api_url}/rest/api/2/issue/{self.issue_key}/comment'
message = f'OpenHands resolved this issue:\n\n{summary}'
# Convert standard Markdown to Jira Wiki Markup for proper rendering
comment_body = {'body': markdown_to_jira_markup(message)}
headers = {'Authorization': f'Bearer {self.svc_acc_api_key}'}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
comment_url,
headers=headers,
json=comment_body,
)
response.raise_for_status()
_logger.info(f'[Jira DC] Posted summary to {self.issue_key}')

View File

@@ -1,34 +1,51 @@
from dataclasses import dataclass
"""Jira Data Center view implementations and factory.
Views are responsible for:
- Holding the webhook payload and auth context
- Creating conversations using V1 app conversation system
"""
from dataclasses import dataclass, field
from uuid import UUID, uuid4
from integrations.jira_dc.jira_dc_types import (
JiraDcViewInterface,
StartingConvoException,
)
from integrations.jira_dc.jira_dc_v1_callback_processor import JiraDcV1CallbackProcessor
from integrations.models import JobContext
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL
from jinja2 import Environment
from storage.jira_dc_conversation import JiraDcConversation
from storage.jira_dc_integration_store import JiraDcIntegrationStore
from storage.jira_dc_user import JiraDcUser
from storage.jira_dc_workspace import JiraDcWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
)
integration_store = JiraDcIntegrationStore.get_instance()
@dataclass
class JiraDcNewConversationView(JiraDcViewInterface):
"""View for creating a new Jira DC conversation."""
job_context: JobContext
saas_user_auth: UserAuth
jira_dc_user: JiraDcUser
@@ -36,9 +53,14 @@ class JiraDcNewConversationView(JiraDcViewInterface):
selected_repo: str | None
conversation_id: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
# Decrypted API key (set by manager)
_decrypted_api_key: str = field(default='', repr=False)
# Resolved org ID for V1 conversations
resolved_org_id: UUID | None = None
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized."""
instructions_template = jinja_env.get_template('jira_dc_instructions.j2')
instructions = instructions_template.render()
@@ -54,58 +76,148 @@ class JiraDcNewConversationView(JiraDcViewInterface):
return instructions, user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create a new Jira DC conversation"""
"""Create a new Jira DC conversation using V1 app conversation system.
Returns:
The conversation ID
Raises:
StartingConvoException: If conversation creation fails
"""
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Generate conversation ID
self.conversation_id = uuid4().hex
# Save the JiraDC conversation mapping
jira_dc_conversation = JiraDcConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
jira_dc_user_id=self.jira_dc_user.id,
)
await integration_store.create_conversation(jira_dc_conversation)
# Create V1 conversation
await self._create_v1_conversation(jinja_env)
return self.conversation_id
async def _create_v1_conversation(self, jinja_env: Environment):
"""Create conversation using the V1 app conversation system."""
logger.info('[Jira DC]: Creating V1 conversation')
instructions, user_msg = await self._get_instructions(jinja_env)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=user_msg)]
)
# Create the Jira DC V1 callback processor
jira_dc_callback_processor = self._create_jira_dc_v1_callback_processor()
# Resolve org ID for the V1 system
self.resolved_org_id = await self._get_resolved_org_id()
# Determine git provider
git_provider = await self._get_git_provider()
injector_state = InjectorState()
# Create the V1 conversation start request
start_request = AppConversationStartRequest(
conversation_id=UUID(self.conversation_id),
system_message_suffix=instructions if instructions else None,
initial_message=initial_message,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=git_provider,
title=f'Jira DC Issue {self.job_context.issue_key}: {self.job_context.issue_title or "Unknown"}',
trigger=ConversationTrigger.JIRA,
processors=[jira_dc_callback_processor],
)
# Set up the Jira DC user context for the V1 system
jira_dc_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, jira_dc_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
logger.info(f'[Jira DC]: Created new conversation: {self.conversation_id}')
def _create_jira_dc_v1_callback_processor(self) -> JiraDcV1CallbackProcessor:
"""Create a V1 callback processor for Jira DC integration."""
return JiraDcV1CallbackProcessor(
issue_key=self.job_context.issue_key,
workspace_name=self.jira_dc_workspace.name,
base_api_url=self.job_context.base_api_url,
svc_acc_api_key=self._decrypted_api_key,
)
async def _get_git_provider(self) -> ProviderType | None:
"""Determine the git provider from the selected repository."""
if not self.selected_repo:
return None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if not provider_tokens:
return None
try:
agent_loop_info = await create_new_conversation(
user_id=self.jira_dc_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_DC,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Jira DC] Created conversation {self.conversation_id}')
# Store Jira DC conversation mapping
jira_dc_conversation = JiraDcConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
jira_dc_user_id=self.jira_dc_user.id,
)
await integration_store.create_conversation(jira_dc_conversation)
return self.conversation_id
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
return repository.git_provider
except Exception as e:
logger.error(
f'[Jira DC] Failed to create conversation: {str(e)}', exc_info=True
logger.warning(
f'[Jira DC] Failed to determine git provider for {self.selected_repo}: {e}'
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
return None
async def _get_resolved_org_id(self) -> UUID | None:
"""Resolve the org ID for V1 conversations."""
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if not provider_tokens or not self.selected_repo:
return None
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
resolved_org_id = await resolve_org_for_repo(
provider=repository.git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.jira_dc_user.keycloak_user_id,
)
return resolved_org_id
except Exception as e:
logger.warning(
f'[Jira DC] Failed to resolve org for {self.selected_repo}: {e}'
)
return None
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira DC"""
"""Get the response message to send back to Jira DC."""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
@dataclass
class JiraDcExistingConversationView(JiraDcViewInterface):
"""View for sending messages to an existing Jira DC conversation."""
job_context: JobContext
saas_user_auth: UserAuth
jira_dc_user: JiraDcUser
@@ -114,8 +226,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
conversation_id: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
"""Instructions passed when conversation is updated."""
user_msg_template = jinja_env.get_template('jira_dc_existing_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
@@ -127,64 +238,107 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
return '', user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Update an existing Jira conversation"""
"""Send a message to an existing V1 conversation.
user_id = self.jira_dc_user.keycloak_user_id
Returns:
The conversation ID
"""
await self._send_message_to_v1_conversation(jinja_env)
return self.conversation_id
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
async def _send_message_to_v1_conversation(self, jinja_env: Environment):
"""Send a message to an existing V1 conversation using the agent server API."""
import httpx
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
get_agent_server_url_from_sandbox,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
_, user_msg = await self._get_instructions(jinja_env)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
conversation_uuid = UUID(self.conversation_id)
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_uuid
),
conversation_uuid,
)
# 2. Sandbox lookup + validation
sandbox = await sandbox_service.get_sandbox(
app_conversation_info.sandbox_id
)
if sandbox is None or sandbox.status != SandboxStatus.RUNNING:
logger.warning(
f'[Jira DC] Sandbox not running for conversation {self.conversation_id}'
)
return
if sandbox.session_api_key is None:
logger.warning(
f'[Jira DC] No session API key for sandbox: {sandbox.id}'
)
return
# 3. Build URL and send message
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
send_message_request = SendMessageRequest(
role='user', content=[TextContent(text=user_msg)]
)
url = (
f"{agent_server_url.rstrip('/')}"
f'/api/conversations/{self.conversation_id}/messages'
)
headers = {'X-Session-API-Key': sandbox.session_api_key}
payload = send_message_request.model_dump()
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens is None:
raise ValueError('Could not load provider tokens')
providers_set = list(provider_tokens.keys())
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
if agent_loop_info.event_store is None:
raise StartingConvoException('Event store not available')
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = await self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
)
return self.conversation_id
except Exception as e:
logger.error(
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
logger.info(
f'[Jira DC] Sent message to existing conversation {self.conversation_id}'
)
except httpx.HTTPStatusError as e:
logger.error(
f'[Jira DC] Failed to send message: HTTP {e.response.status_code}'
)
raise
except Exception as e:
logger.error(f'[Jira DC] Failed to send message: {e}')
raise
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira"""
"""Get the response message to send back to Jira."""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here|{conversation_link}]."
@@ -200,7 +354,6 @@ class JiraDcFactory:
jira_dc_workspace: JiraDcWorkspace,
) -> JiraDcViewInterface:
"""Create appropriate Jira DC view based on the payload."""
if not jira_dc_user or not saas_user_auth or not jira_dc_workspace:
raise StartingConvoException('User not authenticated with Jira integration')

View File

@@ -1,536 +0,0 @@
import hashlib
import hmac
from typing import Dict, Optional, Tuple
import httpx
from fastapi import Request
from integrations.linear.linear_types import LinearViewInterface
from integrations.linear.linear_view import (
LinearExistingConversationView,
LinearFactory,
LinearNewConversationView,
)
from integrations.manager import Manager
from integrations.models import JobContext, Message
from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from storage.linear_integration_store import LinearIntegrationStore
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
class LinearManager(Manager[LinearViewInterface]):
def __init__(self, token_manager: TokenManager):
self.token_manager = token_manager
self.integration_store = LinearIntegrationStore.get_instance()
self.api_url = 'https://api.linear.app/graphql'
self.jinja_env = Environment(
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'linear')
)
async def authenticate_user(
self, linear_user_id: str, workspace_id: int
) -> tuple[LinearUser | None, UserAuth | None]:
"""Authenticate Linear user and get their OpenHands user auth."""
# Find active Linear user by Linear user ID and workspace ID
linear_user = await self.integration_store.get_active_user(
linear_user_id, workspace_id
)
if not linear_user:
logger.warning(
f'[Linear] No active Linear user found for {linear_user_id} in workspace {workspace_id}'
)
return None, None
saas_user_auth = await get_user_auth_from_keycloak_id(
linear_user.keycloak_user_id
)
return linear_user, saas_user_auth
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
"""Get repositories that the user has access to."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return []
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
repos: list[Repository] = await client.get_repositories(
'pushed', server_config.app_mode, None, None, None, None
)
return repos
async def validate_request(
self, request: Request
) -> Tuple[bool, Optional[str], Optional[Dict]]:
"""Verify Linear webhook signature."""
signature = request.headers.get('linear-signature')
body = await request.body()
payload = await request.json()
actor_url = payload.get('actor', {}).get('url', '')
workspace_name = ''
# Extract workspace name from actor URL
# Format: https://linear.app/{workspace}/profiles/{user}
if actor_url.startswith('https://linear.app/'):
url_parts = actor_url.split('/')
if len(url_parts) >= 4:
workspace_name = url_parts[3] # Extract workspace name
else:
logger.warning(f'[Linear] Invalid actor URL format: {actor_url}')
return False, None, None
else:
logger.warning(
f'[Linear] Actor URL does not match expected format: {actor_url}'
)
return False, None, None
if not workspace_name:
logger.warning('[Linear] No workspace name found in webhook payload')
return False, None, None
if not signature:
logger.warning('[Linear] No signature found in webhook headers')
return False, None, None
workspace = await self.integration_store.get_workspace_by_name(workspace_name)
if not workspace:
logger.warning('[Linear] Could not identify workspace for webhook')
return False, None, None
if workspace.status != 'active':
logger.warning(f'[Linear] Workspace {workspace.id} is not active')
return False, None, None
webhook_secret = self.token_manager.decrypt_text(workspace.webhook_secret)
digest = hmac.new(webhook_secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, digest):
logger.info('[Linear] Webhook signature verified successfully')
return True, signature, payload
return False, None, None
def parse_webhook(self, payload: Dict) -> JobContext | None:
action = payload.get('action')
type = payload.get('type')
if action == 'create' and type == 'Comment':
data = payload.get('data', {})
comment = data.get('body', '')
if '@openhands' not in comment:
return None
issue_data = data.get('issue', {})
issue_id = issue_data.get('id', '')
issue_key = issue_data.get('identifier', '')
elif action == 'update' and type == 'Issue':
data = payload.get('data', {})
labels = data.get('labels', [])
has_openhands_label = False
label_id = ''
for label in labels:
if label.get('name') == 'openhands':
label_id = label.get('id', '')
has_openhands_label = True
break
if not has_openhands_label and not label_id:
return None
labelIdChanges = data.get('updatedFrom', {}).get('labelIds', [])
if labelIdChanges and label_id in labelIdChanges:
return None # Label was added previously, ignore this webhook
issue_id = data.get('id', '')
issue_key = data.get('identifier', '')
comment = ''
else:
return None
actor = payload.get('actor', {})
display_name = actor.get('name', '')
user_email = actor.get('email', '')
actor_url = actor.get('url', '')
actor_id = actor.get('id', '')
workspace_name = ''
if actor_url.startswith('https://linear.app/'):
url_parts = actor_url.split('/')
if len(url_parts) >= 4:
workspace_name = url_parts[3] # Extract workspace name
else:
logger.warning(f'[Linear] Invalid actor URL format: {actor_url}')
return None
else:
logger.warning(
f'[Linear] Actor URL does not match expected format: {actor_url}'
)
return None
if not all(
[issue_id, issue_key, display_name, user_email, actor_id, workspace_name]
):
logger.warning('[Linear] Missing required fields in webhook payload')
return None
return JobContext(
issue_id=issue_id,
issue_key=issue_key,
user_msg=comment,
user_email=user_email,
platform_user_id=actor_id,
workspace_name=workspace_name,
display_name=display_name,
)
async def receive_message(self, message: Message):
"""Process incoming Linear webhook message."""
payload = message.message.get('payload', {})
job_context = self.parse_webhook(payload)
if not job_context:
logger.info('[Linear] Webhook does not match trigger conditions')
return
# Get workspace by user email domain
workspace = await self.integration_store.get_workspace_by_name(
job_context.workspace_name
)
if not workspace:
logger.warning(
f'[Linear] No workspace found for email domain: {job_context.workspace_name}'
)
await self._send_error_comment(
job_context.issue_id,
'Your workspace is not configured with Linear integration.',
None,
)
return
# Prevent any recursive triggers from the service account
if job_context.user_email == workspace.svc_acc_email:
return
if workspace.status != 'active':
logger.warning(f'[Linear] Workspace {workspace.id} is not active')
await self._send_error_comment(
job_context.issue_id,
'Linear integration is not active for your workspace.',
workspace,
)
return
# Authenticate user
linear_user, saas_user_auth = await self.authenticate_user(
job_context.platform_user_id, workspace.id
)
if not linear_user or not saas_user_auth:
logger.warning(
f'[Linear] User authentication failed for {job_context.user_email}'
)
await self._send_error_comment(
job_context.issue_id,
f'User {job_context.user_email} is not authenticated or active in the Linear integration.',
workspace,
)
return
# Get issue details
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
issue_title, issue_description = await self.get_issue_details(
job_context.issue_id, api_key
)
job_context.issue_title = issue_title
job_context.issue_description = issue_description
except Exception as e:
logger.error(f'[Linear] Failed to get issue context: {str(e)}')
await self._send_error_comment(
job_context.issue_id,
'Failed to retrieve issue details. Please check the issue ID and try again.',
workspace,
)
return
try:
# Create Linear view
linear_view = await LinearFactory.create_linear_view_from_payload(
job_context,
saas_user_auth,
linear_user,
workspace,
)
except Exception as e:
logger.error(
f'[Linear] Failed to create linear view: {str(e)}', exc_info=True
)
await self._send_error_comment(
job_context.issue_id,
'Failed to initialize conversation. Please try again.',
workspace,
)
return
if not await self.is_job_requested(message, linear_view):
return
await self.start_job(linear_view)
async def is_job_requested(
self, message: Message, linear_view: LinearViewInterface
) -> bool:
"""
Check if a job is requested and handle repository selection.
"""
if isinstance(linear_view, LinearExistingConversationView):
return True
try:
# Get user repositories
user_repos: list[Repository] = await self._get_repositories(
linear_view.saas_user_auth
)
target_str = f'{linear_view.job_context.issue_description}\n{linear_view.job_context.user_msg}'
# Try to infer repository from issue description
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
if match:
# Found exact repository match
linear_view.selected_repo = repos[0].full_name
logger.info(f'[Linear] Inferred repository: {repos[0].full_name}')
return True
else:
# No clear match - send repository selection comment
await self._send_repo_selection_comment(linear_view)
return False
except Exception as e:
logger.error(f'[Linear] Error in is_job_requested: {str(e)}')
return False
async def start_job(self, linear_view: LinearViewInterface) -> None:
"""Start a Linear job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.linear_callback_processor import (
LinearCallbackProcessor,
)
try:
user_info: LinearUser = linear_view.linear_user
logger.info(
f'[Linear] Starting job for user {user_info.keycloak_user_id} '
f'issue {linear_view.job_context.issue_key}',
)
# Create conversation
conversation_id = await linear_view.create_or_update_conversation(
self.jinja_env
)
logger.info(
f'[Linear] Created/Updated conversation {conversation_id} for issue {linear_view.job_context.issue_key}'
)
if isinstance(linear_view, LinearNewConversationView):
# Register callback processor for updates
processor = LinearCallbackProcessor(
issue_id=linear_view.job_context.issue_id,
issue_key=linear_view.job_context.issue_key,
workspace_name=linear_view.linear_workspace.name,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Linear] Created callback processor for conversation {conversation_id}'
)
# Send initial response
msg_info = linear_view.get_response_msg()
except MissingSettingsError as e:
logger.warning(f'[Linear] Missing settings error: {str(e)}')
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
except LLMAuthenticationError as e:
logger.warning(f'[Linear] LLM authentication error: {str(e)}')
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(f'[Linear] Session expired: {str(e)}')
msg_info = get_session_expired_message()
except Exception as e:
logger.error(
f'[Linear] Unexpected error starting job: {str(e)}', exc_info=True
)
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
# Send response comment
try:
api_key = self.token_manager.decrypt_text(
linear_view.linear_workspace.svc_acc_api_key
)
await self.send_message(
msg_info,
linear_view.job_context.issue_id,
api_key,
)
except Exception as e:
logger.error(f'[Linear] Failed to send response message: {str(e)}')
async def _query_api(self, query: str, variables: Dict, api_key: str) -> Dict:
"""Query Linear GraphQL API."""
headers = {'Authorization': api_key}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
self.api_url,
headers=headers,
json={'query': query, 'variables': variables},
)
response.raise_for_status()
return response.json()
async def get_issue_details(self, issue_id: str, api_key: str) -> Tuple[str, str]:
"""Get issue details from Linear API."""
query = """
query Issue($issueId: String!) {
issue(id: $issueId) {
id
identifier
title
description
syncedWith {
metadata {
... on ExternalEntityInfoGithubMetadata {
owner
repo
}
}
}
}
}
"""
issue_payload = await self._query_api(query, {'issueId': issue_id}, api_key)
if not issue_payload:
raise ValueError(f'Issue with ID {issue_id} not found.')
issue_data = issue_payload.get('data', {}).get('issue', {})
title = issue_data.get('title', '')
description = issue_data.get('description', '')
synced_with = issue_data.get('syncedWith', [])
owner = ''
repo = ''
if synced_with:
owner = synced_with[0].get('metadata', {}).get('owner', '')
repo = synced_with[0].get('metadata', {}).get('repo', '')
if not title:
raise ValueError(f'Issue with ID {issue_id} does not have a title.')
if not description:
raise ValueError(f'Issue with ID {issue_id} does not have a description.')
if owner and repo:
description += f'\n\nGit Repo: {owner}/{repo}'
return title, description
async def send_message(self, message: str, issue_id: str, api_key: str):
"""Send message/comment to Linear issue.
Args:
message: The message content to send (plain text string)
issue_id: The Linear issue ID to comment on
api_key: The Linear API key for authentication
"""
query = """
mutation CommentCreate($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
}
}
}
"""
variables = {'input': {'issueId': issue_id, 'body': message}}
return await self._query_api(query, variables, api_key)
async def _send_error_comment(
self, issue_id: str, error_msg: str, workspace: LinearWorkspace | None
):
"""Send error comment to Linear issue."""
if not workspace:
logger.error('[Linear] Cannot send error comment - no workspace available')
return
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(error_msg, issue_id, api_key)
except Exception as e:
logger.error(f'[Linear] Failed to send error comment: {str(e)}')
async def _send_repo_selection_comment(self, linear_view: LinearViewInterface):
"""Send a comment with repository options for the user to choose."""
try:
comment_msg = (
'I need to know which repository to work with. '
'Please add it to your issue description or send a followup comment.'
)
api_key = self.token_manager.decrypt_text(
linear_view.linear_workspace.svc_acc_api_key
)
await self.send_message(
comment_msg,
linear_view.job_context.issue_id,
api_key,
)
logger.info(
f'[Linear] Sent repository selection comment for issue {linear_view.job_context.issue_key}'
)
except Exception as e:
logger.error(
f'[Linear] Failed to send repository selection comment: {str(e)}'
)

View File

@@ -1,40 +0,0 @@
from abc import ABC, abstractmethod
from integrations.models import JobContext
from jinja2 import Environment
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from openhands.server.user_auth.user_auth import UserAuth
class LinearViewInterface(ABC):
"""Interface for Linear views that handle different types of Linear interactions."""
job_context: JobContext
saas_user_auth: UserAuth
linear_user: LinearUser
linear_workspace: LinearWorkspace
selected_repo: str | None
conversation_id: str
@abstractmethod
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get initial instructions for the conversation."""
pass
@abstractmethod
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create or update a conversation and return the conversation ID."""
pass
@abstractmethod
def get_response_msg(self) -> str:
"""Get the response message to send back to Linear."""
pass
class StartingConvoException(Exception):
"""Exception raised when starting a conversation fails."""
pass

View File

@@ -1,288 +0,0 @@
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 (
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.conversation_summary import get_default_conversation_title
integration_store = LinearIntegrationStore.get_instance()
@dataclass
class LinearNewConversationView(LinearViewInterface):
job_context: JobContext
saas_user_auth: UserAuth
linear_user: LinearUser
linear_workspace: LinearWorkspace
selected_repo: str | None
conversation_id: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
instructions_template = jinja_env.get_template('linear_instructions.j2')
instructions = instructions_template.render()
user_msg_template = jinja_env.get_template('linear_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
user_message=self.job_context.user_msg or '',
)
return instructions, user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create a new Linear conversation"""
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = await self._get_instructions(jinja_env)
try:
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,
)
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}')
# Store Linear conversation mapping
linear_conversation = LinearConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
linear_user_id=self.linear_user.id,
)
await integration_store.create_conversation(linear_conversation)
return self.conversation_id
except Exception as e:
logger.error(
f'[Linear] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Linear"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [track my progress here]({conversation_link})."
@dataclass
class LinearExistingConversationView(LinearViewInterface):
job_context: JobContext
saas_user_auth: UserAuth
linear_user: LinearUser
linear_workspace: LinearWorkspace
selected_repo: str | None
conversation_id: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
user_msg_template = jinja_env.get_template('linear_existing_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
user_message=self.job_context.user_msg or '',
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
)
return '', user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Update an existing Linear conversation"""
user_id = self.linear_user.keycloak_user_id
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens is None:
raise ValueError('Could not load provider tokens')
providers_set = list(provider_tokens.keys())
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
if agent_loop_info.event_store is None:
raise StartingConvoException('Event store not available')
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = await self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
)
return self.conversation_id
except Exception as e:
logger.error(
f'[Linear] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Linear"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here]({conversation_link})."
class LinearFactory:
"""Factory for creating Linear views based on message content"""
@staticmethod
async def create_linear_view_from_payload(
job_context: JobContext,
saas_user_auth: UserAuth,
linear_user: LinearUser,
linear_workspace: LinearWorkspace,
) -> LinearViewInterface:
"""Create appropriate Linear view based on the message and user state"""
if not linear_user or not saas_user_auth or not linear_workspace:
raise StartingConvoException(
'User not authenticated with Linear integration'
)
conversation = await integration_store.get_user_conversations_by_issue_id(
job_context.issue_id, linear_user.id
)
if conversation:
logger.info(
f'[Linear] Found existing conversation for issue {job_context.issue_id}'
)
return LinearExistingConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
linear_user=linear_user,
linear_workspace=linear_workspace,
selected_repo=None,
conversation_id=conversation.conversation_id,
)
return LinearNewConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
linear_user=linear_user,
linear_workspace=linear_workspace,
selected_repo=None, # Will be set later after repo inference
conversation_id='', # Will be set when conversation is created
)

View File

@@ -16,21 +16,23 @@ 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,
keycloak_user_id: str | None = None,
) -> 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).
If the repo's git organization is claimed by an OpenHands org, returns the
claiming org's ID. When keycloak_user_id is provided, also verifies the user
is a member of that org.
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
keycloak_user_id: The user's Keycloak UUID string (optional). If provided,
membership is verified before returning the org_id.
Returns:
The org_id if the repo's org is claimed and user is a member, else None
The org_id if the repo's org is claimed (and user is a member when
keycloak_user_id is provided), else None
"""
git_org = full_repo_name.split('/')[0].lower()
@@ -44,6 +46,14 @@ async def resolve_org_for_repo(
)
return None
# Skip membership check if no user_id provided
if keycloak_user_id is None:
logger.info(
f'[OrgResolver] Resolved org {claim.org_id} '
f'for {provider}/{git_org} (no user membership check)',
)
return claim.org_id
member = await OrgMemberStore.get_org_member(
claim.org_id, UUID(keycloak_user_id)
)

View File

@@ -24,7 +24,6 @@ from integrations.utils import (
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from server.constants import SLACK_CLIENT_ID
from server.utils.conversation_callback_utils import register_callback_processor
from slack_sdk.oauth import AuthorizeUrlGenerator
from slack_sdk.web.async_client import AsyncWebClient
from sqlalchemy import select
@@ -698,11 +697,7 @@ class SlackManager(Manager[SlackViewInterface]):
return False
async def start_job(self, slack_view: SlackViewInterface) -> None:
# Importing here prevents circular import
from server.conversation_callback_processor.slack_callback_processor import (
SlackCallbackProcessor,
)
"""Start a Slack job using V1 app conversation system."""
try:
msg_info = None
user_info = slack_view.slack_to_openhands_user
@@ -719,37 +714,7 @@ class SlackManager(Manager[SlackViewInterface]):
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
)
# Only add SlackCallbackProcessor for new conversations (not updates) and non-v1 conversations
if (
not isinstance(slack_view, SlackUpdateExistingConversationView)
and not slack_view.v1_enabled
):
# We don't re-subscribe for follow up messages from slack.
# Summaries are generated for every messages anyways, we only need to do
# this subscription once for the event which kicked off the job.
processor = SlackCallbackProcessor(
slack_user_id=slack_view.slack_user_id,
channel_id=slack_view.channel_id,
message_ts=slack_view.message_ts,
thread_ts=slack_view.thread_ts,
team_id=slack_view.team_id,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Slack] Created callback processor for conversation {conversation_id}'
)
elif isinstance(slack_view, SlackUpdateExistingConversationView):
logger.info(
f'[Slack] Skipping callback processor for existing conversation update {conversation_id}'
)
elif slack_view.v1_enabled:
logger.info(
f'[Slack] Skipping callback processor for v1 conversation {conversation_id}'
)
# V1 callback processors are registered by the view during conversation creation
msg_info = slack_view.get_response_msg()

View File

@@ -14,13 +14,10 @@ from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProces
from integrations.utils import (
CONVERSATION_URL,
ENABLE_V1_SLACK_RESOLVER,
get_final_agent_observation,
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,23 +33,13 @@ from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
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
@@ -205,7 +192,6 @@ class SlackNewConversationView(SlackViewInterface):
self._verify_necessary_values_are_set()
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Determine git provider from repository (needed for both org routing and conversation creation)
self._resolved_git_provider = None
@@ -223,68 +209,9 @@ class SlackNewConversationView(SlackViewInterface):
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
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(jinja)
return self.conversation_id
else:
# Use existing V0 conversation service
await self._create_v0_conversation(jinja, provider_tokens, user_secrets)
return self.conversation_id
async def _create_v0_conversation(
self, jinja: Environment, provider_tokens, user_secrets
) -> None:
"""Create conversation using the legacy V0 system."""
user_instructions, conversation_instructions = await self._get_instructions(
jinja
)
user_id = self.slack_to_openhands_user.keycloak_user_id
# 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
),
)
self.conversation_id = conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
# V0 conversation path has been removed - all conversations use V1 app conversation service
await self._create_v1_conversation(jinja)
return self.conversation_id
async def _create_v1_conversation(self, jinja: Environment) -> None:
"""Create conversation using the new V1 app conversation system."""
@@ -378,53 +305,6 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
return user_message, ''
async def send_message_to_v0_conversation(self, jinja: Environment):
user_info: SlackUser = self.slack_to_openhands_user
user_id = user_info.keycloak_user_id
saas_user_auth: UserAuth = self.saas_user_auth
provider_tokens = await saas_user_auth.get_provider_tokens()
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
# Should we raise here if there are no provider tokens?
providers_set = list(provider_tokens.keys()) if provider_tokens else []
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
if agent_loop_info.event_store is None:
raise StartingConvoException('Event store not available')
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
instructions, _ = await self._get_instructions(jinja)
user_msg = MessageAction(content=instructions)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_msg)
)
async def send_message_to_v1_conversation(self, jinja: Environment):
"""Send a message to a v1 conversation using the agent server API."""
# Import services within the method to avoid circular imports
@@ -519,7 +399,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
raise Exception(f'Failed to send message to v1 conversation: {str(e)}')
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""Send new user message to converation"""
"""Send new user message to conversation."""
user_info: SlackUser = self.slack_to_openhands_user
user_id = user_info.keycloak_user_id
@@ -531,10 +411,8 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
)
if self.slack_conversation.v1_enabled:
await self.send_message_to_v1_conversation(jinja)
else:
await self.send_message_to_v0_conversation(jinja)
# All conversations use V1 app conversation system
await self.send_message_to_v1_conversation(jinja)
return self.conversation_id

View File

@@ -59,11 +59,11 @@ async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
extra={'user_id': user_id, 'org_id': str(org.id)},
)
# Create the customer in stripe
customer = await stripe.Customer.create_async(
email=org.contact_email,
metadata={'org_id': str(org.id)},
)
# Create the customer in stripe (only include email if available)
create_params: dict = {'metadata': {'org_id': str(org.id)}}
if org.contact_email:
create_params['email'] = org.contact_email
customer = await stripe.Customer.create_async(**create_params)
# Save the stripe customer in the local db
async with a_session_maker() as session:
@@ -108,11 +108,14 @@ async def migrate_customer(session, user_id: str, org: Org):
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)},
)
# Only include email if available to avoid sending empty strings to Stripe
modify_params: dict = {
'id': stripe_customer.stripe_customer_id,
'metadata': {'user_id': '', 'org_id': str(org.id)},
}
if org.contact_email:
modify_params['email'] = org.contact_email
customer = await stripe.Customer.modify_async(**modify_params)
logger.info(
'migrated_customer',

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import json
import os
import re
from typing import TYPE_CHECKING
from jinja2 import Environment, FileSystemLoader
from server.constants import WEB_HOST
@@ -20,12 +19,6 @@ from openhands.events.event_filter import EventFilter
from openhands.events.event_store_abc import EventStoreABC
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.integrations.service_types import Repository
from openhands.storage.data_models.conversation_status import ConversationStatus
if TYPE_CHECKING:
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
# ---- DO NOT REMOVE ----
# WARNING: Langfuse depends on the WEB_HOST environment variable being set to track events.
@@ -363,43 +356,6 @@ def extract_summary_from_event_store(
return summary_event.final_thought
async def get_event_store_from_conversation_manager(
conversation_manager: ConversationManager, conversation_id: str
) -> EventStoreABC:
agent_loop_infos = await conversation_manager.get_agent_loop_info(
filter_to_sids={conversation_id}
)
if not agent_loop_infos or agent_loop_infos[0].status != ConversationStatus.RUNNING:
raise RuntimeError(f'conversation_not_running:{conversation_id}')
event_store = agent_loop_infos[0].event_store
if not event_store:
raise RuntimeError(f'event_store_missing:{conversation_id}')
return event_store
async def get_last_user_msg_from_conversation_manager(
conversation_manager: ConversationManager, conversation_id: str
):
event_store = await get_event_store_from_conversation_manager(
conversation_manager, conversation_id
)
return get_last_user_msg(event_store)
async def extract_summary_from_conversation_manager(
conversation_manager: ConversationManager, conversation_id: str
) -> str:
"""
Get agent summary or alternative message depending on current AgentState
"""
event_store = await get_event_store_from_conversation_manager(
conversation_manager, conversation_id
)
summary = extract_summary_from_event_store(event_store, conversation_id)
return append_conversation_footer(summary, conversation_id)
def append_conversation_footer(message: str, conversation_id: str) -> str:
"""
Append a small footer with the conversation URL to a message.
@@ -436,12 +392,13 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
)
matches: list[str] = []
# Use dict to preserve ordering
matches: dict[str, bool] = {}
# Git URLs first (highest priority)
for owner, repo in re.findall(git_url_pattern, normalized_msg):
repo = re.sub(r'\.git$', '', repo)
matches.append(f'{owner}/{repo}')
matches[f'{owner}/{repo}'] = True
# Direct mentions
for owner, repo in re.findall(direct_pattern, normalized_msg):
@@ -457,9 +414,10 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
continue
if full_match not in matches:
matches.append(full_match)
matches[full_match] = True
return matches
result = list(matches)
return result
def filter_potential_repos_by_user_msg(
@@ -595,3 +553,18 @@ def markdown_to_jira_markup(markdown_text: str) -> str:
# Log the error but don't raise it - return original text as fallback
print(f'Error converting markdown to Jira markup: {str(e)}')
return markdown_text or ''
def format_jira_comment_body(message: str) -> dict:
"""Format a message as a Jira API v2 comment body.
This helper ensures consistent comment formatting across all Jira integrations.
Converts markdown to Jira Wiki Markup and wraps in the expected API structure.
Args:
message: The message content to send (may contain markdown)
Returns:
dict: The comment body in Jira API v2 format {'body': ...}
"""
return {'body': markdown_to_jira_markup(message)}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,563 @@
"""Add agent_settings columns to enterprise settings tables.
Revision ID: 108
Revises: 107
Create Date: 2026-03-22 00:00:00.000000
"""
from collections.abc import Mapping
from typing import Any, Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '108'
down_revision: Union[str, None] = '107'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_EMPTY_JSON = sa.text("'{}'::json")
def _deep_merge(
base: dict[str, Any], overrides: Mapping[str, Any] | None
) -> dict[str, Any]:
merged = dict(base)
for key, value in (overrides or {}).items():
existing = merged.get(key)
if isinstance(existing, dict) and isinstance(value, Mapping):
merged[key] = _deep_merge(existing, value)
else:
merged[key] = value
return merged
def _strip_none_and_empty(value: Any) -> Any:
if isinstance(value, Mapping):
cleaned: dict[str, Any] = {}
for key, item in value.items():
cleaned_item = _strip_none_and_empty(item)
if cleaned_item is None:
continue
if isinstance(cleaned_item, dict) and not cleaned_item:
continue
cleaned[key] = cleaned_item
return cleaned
return value
def _build_user_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'agent': row['agent'],
'llm': {
'model': row['llm_model'],
'base_url': row['llm_base_url'],
},
'condenser': {
'enabled': row['enable_default_condenser'],
'max_size': row['condenser_max_size'],
},
'mcp_config': row['mcp_config'],
}
)
return _deep_merge(generated, row.get('agent_settings') or {})
def _build_user_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'max_iterations': row['max_iterations'],
'confirmation_mode': row['confirmation_mode'],
'security_analyzer': row['security_analyzer'],
}
)
return _deep_merge(generated, row.get('conversation_settings') or {})
def _build_org_member_agent_settings_diff(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'llm': {
'model': row['llm_model'],
'base_url': row['llm_base_url'],
},
'mcp_config': row['mcp_config'],
}
)
return _deep_merge(generated, row.get('agent_settings_diff') or {})
def _build_org_member_conversation_settings_diff(
row: Mapping[str, Any],
) -> dict[str, Any]:
generated = _strip_none_and_empty({'max_iterations': row['max_iterations']})
return _deep_merge(generated, row.get('conversation_settings_diff') or {})
def _build_org_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'agent': row['agent'],
'llm': {
'model': row['default_llm_model'],
'base_url': row['default_llm_base_url'],
},
'condenser': {
'enabled': row['enable_default_condenser'],
'max_size': row['condenser_max_size'],
},
'mcp_config': row['mcp_config'],
}
)
return _deep_merge(generated, row.get('agent_settings') or {})
def _build_org_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'max_iterations': row['default_max_iterations'],
'confirmation_mode': row['confirmation_mode'],
'security_analyzer': row['security_analyzer'],
}
)
return _deep_merge(generated, row.get('conversation_settings') or {})
def _get_nested_value(data: Mapping[str, Any] | None, *path: str) -> Any:
current: Any = data or {}
for key in path:
if not isinstance(current, Mapping) or key not in current:
return None
current = current[key]
return current
def _legacy_user_settings_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings = row.get('agent_settings') or {}
conversation_settings = row.get('conversation_settings') or {}
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
return {
'agent': _get_nested_value(agent_settings, 'agent'),
'max_iterations': _get_nested_value(conversation_settings, 'max_iterations'),
'security_analyzer': _get_nested_value(
conversation_settings, 'security_analyzer'
),
'confirmation_mode': _get_nested_value(
conversation_settings, 'confirmation_mode'
),
'llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
'llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
'enable_default_condenser': (
True if condenser_enabled is None else condenser_enabled
),
'condenser_max_size': _get_nested_value(
agent_settings, 'condenser', 'max_size'
),
}
def _legacy_org_member_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings_diff = row.get('agent_settings_diff') or {}
conversation_settings_diff = row.get('conversation_settings_diff') or {}
return {
'llm_model': _get_nested_value(agent_settings_diff, 'llm', 'model'),
'llm_base_url': _get_nested_value(agent_settings_diff, 'llm', 'base_url'),
'max_iterations': _get_nested_value(
conversation_settings_diff, 'max_iterations'
),
'mcp_config': _get_nested_value(agent_settings_diff, 'mcp_config'),
}
def _legacy_org_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings = row.get('agent_settings') or {}
conversation_settings = row.get('conversation_settings') or {}
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
return {
'agent': _get_nested_value(agent_settings, 'agent'),
'default_max_iterations': _get_nested_value(
conversation_settings, 'max_iterations'
),
'security_analyzer': _get_nested_value(
conversation_settings, 'security_analyzer'
),
'confirmation_mode': _get_nested_value(
conversation_settings, 'confirmation_mode'
),
'default_llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
'default_llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
'enable_default_condenser': (
True if condenser_enabled is None else condenser_enabled
),
'mcp_config': _get_nested_value(agent_settings, 'mcp_config'),
'condenser_max_size': _get_nested_value(
agent_settings, 'condenser', 'max_size'
),
}
def upgrade() -> None:
op.add_column(
'user_settings',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'user_settings',
sa.Column(
'conversation_settings',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org_member',
sa.Column(
'agent_settings_diff',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org_member',
sa.Column(
'conversation_settings_diff',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'org',
sa.Column(
'conversation_settings',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column('org', sa.Column('_llm_api_key', sa.String(), nullable=True))
op.add_column(
'org_member',
sa.Column(
'has_custom_llm_api_key',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
bind = op.get_bind()
user_settings_table = sa.table(
'user_settings',
sa.column('id', sa.Integer()),
sa.column('agent', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('condenser_max_size', sa.Integer()),
sa.column('mcp_config', sa.JSON()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
)
user_settings_rows = bind.execute(
sa.select(
user_settings_table.c.id,
user_settings_table.c.agent,
user_settings_table.c.max_iterations,
user_settings_table.c.security_analyzer,
user_settings_table.c.confirmation_mode,
user_settings_table.c.llm_model,
user_settings_table.c.llm_base_url,
user_settings_table.c.enable_default_condenser,
user_settings_table.c.condenser_max_size,
user_settings_table.c.mcp_config,
user_settings_table.c.agent_settings,
user_settings_table.c.conversation_settings,
)
).mappings()
for row in user_settings_rows:
bind.execute(
user_settings_table.update()
.where(user_settings_table.c.id == row['id'])
.values(
agent_settings=_build_user_agent_settings(row),
conversation_settings=_build_user_conversation_settings(row),
)
)
org_member_table = sa.table(
'org_member',
sa.column('org_id', sa.Uuid()),
sa.column('user_id', sa.Uuid()),
sa.column('max_iterations', sa.Integer()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('mcp_config', sa.JSON()),
sa.column('agent_settings_diff', sa.JSON()),
sa.column('conversation_settings_diff', sa.JSON()),
)
org_member_rows = bind.execute(
sa.select(
org_member_table.c.org_id,
org_member_table.c.user_id,
org_member_table.c.max_iterations,
org_member_table.c.llm_model,
org_member_table.c.llm_base_url,
org_member_table.c.mcp_config,
org_member_table.c.agent_settings_diff,
org_member_table.c.conversation_settings_diff,
)
).mappings()
for row in org_member_rows:
bind.execute(
org_member_table.update()
.where(org_member_table.c.org_id == row['org_id'])
.where(org_member_table.c.user_id == row['user_id'])
.values(
agent_settings_diff=_build_org_member_agent_settings_diff(row),
conversation_settings_diff=_build_org_member_conversation_settings_diff(
row
),
)
)
org_table = sa.table(
'org',
sa.column('id', sa.Uuid()),
sa.column('agent', sa.String()),
sa.column('default_max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('default_llm_model', sa.String()),
sa.column('default_llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('mcp_config', sa.JSON()),
sa.column('condenser_max_size', sa.Integer()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
)
org_rows = bind.execute(
sa.select(
org_table.c.id,
org_table.c.agent,
org_table.c.default_max_iterations,
org_table.c.security_analyzer,
org_table.c.confirmation_mode,
org_table.c.default_llm_model,
org_table.c.default_llm_base_url,
org_table.c.enable_default_condenser,
org_table.c.mcp_config,
org_table.c.condenser_max_size,
org_table.c.agent_settings,
org_table.c.conversation_settings,
)
).mappings()
for row in org_rows:
bind.execute(
org_table.update()
.where(org_table.c.id == row['id'])
.values(
agent_settings=_build_org_agent_settings(row),
conversation_settings=_build_org_conversation_settings(row),
)
)
op.alter_column('user_settings', 'agent_settings', server_default=None)
op.alter_column('user_settings', 'conversation_settings', server_default=None)
op.alter_column('org_member', 'agent_settings_diff', server_default=None)
op.alter_column('org_member', 'conversation_settings_diff', server_default=None)
op.alter_column('org', 'agent_settings', server_default=None)
op.alter_column('org', 'conversation_settings', server_default=None)
op.alter_column('org_member', 'has_custom_llm_api_key', server_default=None)
op.drop_column('user_settings', 'agent')
op.drop_column('user_settings', 'max_iterations')
op.drop_column('user_settings', 'security_analyzer')
op.drop_column('user_settings', 'confirmation_mode')
op.drop_column('user_settings', 'llm_model')
op.drop_column('user_settings', 'llm_base_url')
op.drop_column('user_settings', 'enable_default_condenser')
op.drop_column('user_settings', 'condenser_max_size')
op.drop_column('org_member', 'max_iterations')
op.drop_column('org_member', 'llm_model')
op.drop_column('org_member', 'llm_base_url')
op.drop_column('org_member', 'mcp_config')
op.drop_column('org', 'agent')
op.drop_column('org', 'default_max_iterations')
op.drop_column('org', 'security_analyzer')
op.drop_column('org', 'confirmation_mode')
op.drop_column('org', 'default_llm_model')
op.drop_column('org', 'default_llm_base_url')
op.drop_column('org', 'enable_default_condenser')
op.drop_column('org', 'mcp_config')
op.drop_column('org', 'condenser_max_size')
def downgrade() -> None:
op.add_column('user_settings', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('security_analyzer', sa.String(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('confirmation_mode', sa.Boolean(), nullable=True)
)
op.add_column('user_settings', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('llm_base_url', sa.String(), nullable=True)
)
op.add_column(
'user_settings',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column(
'user_settings', sa.Column('condenser_max_size', sa.Integer(), nullable=True)
)
op.add_column('org_member', sa.Column('llm_base_url', sa.String(), nullable=True))
op.add_column('org_member', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'org_member', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'org', sa.Column('default_max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org', sa.Column('security_analyzer', sa.String(), nullable=True))
op.add_column('org', sa.Column('confirmation_mode', sa.Boolean(), nullable=True))
op.add_column('org', sa.Column('default_llm_model', sa.String(), nullable=True))
op.add_column('org', sa.Column('default_llm_base_url', sa.String(), nullable=True))
op.add_column(
'org',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
bind = op.get_bind()
user_settings_table = sa.table(
'user_settings',
sa.column('id', sa.Integer()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
sa.column('agent', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('condenser_max_size', sa.Integer()),
)
user_settings_rows = bind.execute(
sa.select(
user_settings_table.c.id,
user_settings_table.c.agent_settings,
user_settings_table.c.conversation_settings,
)
).mappings()
for row in user_settings_rows:
bind.execute(
user_settings_table.update()
.where(user_settings_table.c.id == row['id'])
.values(**_legacy_user_settings_values(row))
)
org_member_table = sa.table(
'org_member',
sa.column('org_id', sa.Uuid()),
sa.column('user_id', sa.Uuid()),
sa.column('agent_settings_diff', sa.JSON()),
sa.column('conversation_settings_diff', sa.JSON()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('mcp_config', sa.JSON()),
)
org_member_rows = bind.execute(
sa.select(
org_member_table.c.org_id,
org_member_table.c.user_id,
org_member_table.c.agent_settings_diff,
org_member_table.c.conversation_settings_diff,
)
).mappings()
for row in org_member_rows:
bind.execute(
org_member_table.update()
.where(org_member_table.c.org_id == row['org_id'])
.where(org_member_table.c.user_id == row['user_id'])
.values(**_legacy_org_member_values(row))
)
org_table = sa.table(
'org',
sa.column('id', sa.Uuid()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
sa.column('agent', sa.String()),
sa.column('default_max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('default_llm_model', sa.String()),
sa.column('default_llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('mcp_config', sa.JSON()),
sa.column('condenser_max_size', sa.Integer()),
)
org_rows = bind.execute(
sa.select(
org_table.c.id,
org_table.c.agent_settings,
org_table.c.conversation_settings,
)
).mappings()
for row in org_rows:
bind.execute(
org_table.update()
.where(org_table.c.id == row['id'])
.values(**_legacy_org_values(row))
)
op.drop_column('org', 'agent_settings')
op.drop_column('org', 'conversation_settings')
op.drop_column('org', '_llm_api_key')
op.drop_column('org_member', 'agent_settings_diff')
op.drop_column('org_member', 'conversation_settings_diff')
op.drop_column('org_member', 'has_custom_llm_api_key')
op.drop_column('user_settings', 'agent_settings')
op.drop_column('user_settings', 'conversation_settings')

178
enterprise/poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "agent-client-protocol"
@@ -1708,61 +1708,61 @@ files = [
[[package]]
name = "cryptography"
version = "46.0.6"
version = "46.0.7"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main"]
files = [
{file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"},
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"},
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"},
{file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"},
{file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"},
{file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"},
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"},
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"},
{file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"},
{file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"},
{file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"},
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"},
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"},
{file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"},
{file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"},
{file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"},
{file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"},
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"},
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"},
{file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"},
{file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"},
{file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"},
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"},
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"},
{file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"},
{file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"},
{file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"},
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"},
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"},
{file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"},
{file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"},
{file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"},
]
[package.dependencies]
@@ -1775,7 +1775,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -3585,7 +3585,7 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.5.5"
grpcio = ">=1.67.1"
protobuf = ">=5.26.1,<6.0.dev0"
protobuf = ">=5.26.1,<6.0dev"
[[package]]
name = "gspread"
@@ -3906,7 +3906,7 @@ pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
@@ -4348,7 +4348,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
jsonschema-specifications = ">=2023.3.6"
jsonschema-specifications = ">=2023.03.6"
referencing = ">=0.28.4"
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
@@ -4756,7 +4756,7 @@ files = [
]
[package.dependencies]
certifi = ">=14.5.14"
certifi = ">=14.05.14"
durationpy = ">=0.7"
python-dateutil = ">=2.5.3"
pyyaml = ">=5.4.1"
@@ -4890,25 +4890,24 @@ valkey = ["valkey (>=6)"]
[[package]]
name = "litellm"
version = "1.80.10"
version = "1.83.0"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "litellm-1.80.10-py3-none-any.whl", hash = "sha256:9b3e561efaba0eb1291cb1555d3dcb7283cf7f3cb65aadbcdb42e2a8765898c8"},
{file = "litellm-1.80.10.tar.gz", hash = "sha256:4a4aff7558945c2f7e5c6523e67c1b5525a46b10b0e1ad6b8f847cb13b16779e"},
{file = "litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8"},
{file = "litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a"},
]
[package.dependencies]
aiohttp = ">=3.10"
click = "*"
fastuuid = ">=0.13.0"
grpcio = {version = ">=1.62.3,<1.68.0", markers = "python_version < \"3.14\""}
httpx = ">=0.23.0"
importlib-metadata = ">=6.8.0"
jinja2 = ">=3.1.2,<4.0.0"
jsonschema = ">=4.22.0,<5.0.0"
jsonschema = ">=4.23.0,<5.0.0"
openai = ">=2.8.0"
pydantic = ">=2.5.0,<3.0.0"
python-dotenv = ">=0.2.0"
@@ -4917,9 +4916,11 @@ tokenizers = "*"
[package.extras]
caching = ["diskcache (>=5.6.1,<6.0.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
extra-proxy = ["a2a-sdk (>=0.3.22,<0.4.0) ; python_version >= \"3.10\"", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (>=0.11.0,<0.12.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
google = ["google-cloud-aiplatform (>=1.38.0)"]
grpc = ["grpcio (>=1.62.3,<1.68.dev0 || >1.71.0,!=1.71.1,!=1.72.0,!=1.72.1,!=1.73.0) ; python_version < \"3.14\"", "grpcio (>=1.75.0) ; python_version >= \"3.14\""]
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.25)", "litellm-proxy-extras (==0.4.14)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
proxy = ["PyJWT (>=2.12.0,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (>=1.40.76,<2.0.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.35)", "litellm-proxy-extras (>=0.4.62,<0.5.0)", "mcp (>=1.25.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "pyroscope-io (>=0.8,<0.9) ; sys_platform != \"win32\"", "python-multipart (>=0.0.20)", "pyyaml (>=6.0.1,<7.0.0)", "rich (>=13.7.1,<14.0.0)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.32.1,<1.0.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
utils = ["numpydoc"]
@@ -6453,14 +6454,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.16.1"
version = "1.17.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.16.1-py3-none-any.whl", hash = "sha256:015983b300510c9c329c8eace49fbd4117d31d0895a125e419c31a9964be4155"},
{file = "openhands_agent_server-1.16.1.tar.gz", hash = "sha256:489151d35250a424dede8646396bef7b7095adb25e5c973ca8bc6dcbd19cdf07"},
{file = "openhands_agent_server-1.17.0-py3-none-any.whl", hash = "sha256:44336cad001c31caeb516481a5a7aea6dd9b5ab4798461f147b5231668d8fb74"},
{file = "openhands_agent_server-1.17.0.tar.gz", hash = "sha256:3a88449a3b9ded653dcd2a8c518810c75602873cf9f7d4e8f9b90fd8fd225652"},
]
[package.dependencies]
@@ -6522,9 +6523,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.3"
openhands-agent-server = "1.16.1"
openhands-sdk = "1.16.1"
openhands-tools = "1.16.1"
openhands-agent-server = "1.17"
openhands-sdk = "1.17"
openhands-tools = "1.17"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
orjson = ">=3.11.6"
@@ -6546,7 +6547,7 @@ python-docx = "*"
python-dotenv = "*"
python-frontmatter = ">=1.1"
python-json-logger = ">=3.2.1"
python-multipart = ">=0.0.22"
python-multipart = ">=0.0.26"
python-pptx = "*"
python-socketio = "5.14"
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
@@ -6570,23 +6571,20 @@ uvicorn = "*"
whatthepatch = ">=1.0.6"
zope-interface = "7.2"
[package.extras]
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2)", "modal (>=0.66.26,<1.2)", "runloop-api-client (==0.50)"]
[package.source]
type = "directory"
url = ".."
[[package]]
name = "openhands-sdk"
version = "1.16.1"
version = "1.17.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.16.1-py3-none-any.whl", hash = "sha256:0b487929e03e8c87ac6d99f37ff5314df3db6af70a06b516b0858327f9744f2b"},
{file = "openhands_sdk-1.16.1.tar.gz", hash = "sha256:12f203c3766800bdf5d9dd4dd0a7988b88e13ff4954b0c208903778111e29567"},
{file = "openhands_sdk-1.17.0-py3-none-any.whl", hash = "sha256:3b771e72209453871c3036a562cf33e9ad9642a54bd48edb44f89915ac54709d"},
{file = "openhands_sdk-1.17.0.tar.gz", hash = "sha256:3c69df6590f023a514137272d413658848e0d5bc9aecf941b946c8662862779a"},
]
[package.dependencies]
@@ -6596,7 +6594,7 @@ fakeredis = {version = ">=2.32.1", extras = ["lua"]}
fastmcp = ">=3.0.0"
filelock = ">=3.20.1"
httpx = {version = ">=0.27.0", extras = ["socks"]}
litellm = "1.80.10"
litellm = ">=1.82.6,<1.82.7 || >1.82.7,<1.82.8 || >1.82.8"
lmnr = ">=0.7.24"
pydantic = ">=2.12.5"
python-frontmatter = ">=1.1.0"
@@ -6609,14 +6607,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.16.1"
version = "1.17.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.16.1-py3-none-any.whl", hash = "sha256:f7fd1eb205571d02ee480ad71e96cac0c34c57c0938c4074fe135a579a7538d7"},
{file = "openhands_tools-1.16.1.tar.gz", hash = "sha256:64488f2d7705ff90f4bfb7dfd1a2f1fbb4f379059d96e0073677c168d97135e7"},
{file = "openhands_tools-1.17.0-py3-none-any.whl", hash = "sha256:76cd30fcc153627444f18638bcd926c9190989f80a3492381e84a181c021d815"},
{file = "openhands_tools-1.17.0.tar.gz", hash = "sha256:4a9d6c1aec00d366d0feb1ac2e9ee9988ad9806a0ef89f7dbe4655644e639d4a"},
]
[package.dependencies]
@@ -7140,7 +7138,7 @@ files = [
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
@@ -11889,14 +11887,14 @@ files = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["test"]
files = [
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
]
[package.dependencies]
@@ -12130,14 +12128,14 @@ requests-toolbelt = ">=0.6.0"
[[package]]
name = "python-multipart"
version = "0.0.22"
version = "0.0.26"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"},
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
{file = "python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185"},
{file = "python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17"},
]
[[package]]
@@ -13111,10 +13109,10 @@ files = [
]
[package.dependencies]
botocore = ">=1.37.4,<2.0a0"
botocore = ">=1.37.4,<2.0a.0"
[package.extras]
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
[[package]]
name = "scantree"
@@ -15258,7 +15256,7 @@ files = [
]
[package.extras]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
[metadata]
lock-version = "2.1"

View File

@@ -17,7 +17,6 @@ from server.auth.constants import ( # noqa: E402
BITBUCKET_DATA_CENTER_HOST,
ENABLE_JIRA,
ENABLE_JIRA_DC,
ENABLE_LINEAR,
GITHUB_APP_CLIENT_ID,
GITLAB_APP_CLIENT_ID,
)
@@ -29,12 +28,10 @@ from server.routes.api_keys import api_router as api_keys_router # noqa: E402
from server.routes.auth import api_router, oauth_router # noqa: E402
from server.routes.billing import billing_router # noqa: E402
from server.routes.email import api_router as email_router # noqa: E402
from server.routes.event_webhook import event_webhook_router # noqa: E402
from server.routes.feedback import router as feedback_router # noqa: E402
from server.routes.github_proxy import add_github_proxy_routes # noqa: E402
from server.routes.integration.jira import jira_integration_router # noqa: E402
from server.routes.integration.jira_dc import jira_dc_integration_router # noqa: E402
from server.routes.integration.linear import linear_integration_router # noqa: E402
from server.routes.integration.slack import slack_router # noqa: E402
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
from server.routes.oauth_device import oauth_device_router # noqa: E402
@@ -47,7 +44,6 @@ 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,
@@ -86,7 +82,6 @@ base_app.include_router(readiness_router) # Add routes for readiness checks
base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
base_app.include_router(user_app_settings_router) # Add routes for user app settings
base_app.include_router(
billing_router
@@ -111,8 +106,15 @@ if GITHUB_APP_CLIENT_ID:
# Add GitLab integration router only if GITLAB_APP_CLIENT_ID is set
if GITLAB_APP_CLIENT_ID:
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
from integrations.gitlab.gitlab_v1_callback_processor import ( # noqa: E402
GitlabV1CallbackProcessor,
)
from server.routes.integration.gitlab import gitlab_integration_router # noqa: E402
# Bludgeon mypy into not deleting my import
logger.debug(f'Loaded {GitlabV1CallbackProcessor.__name__}')
base_app.include_router(gitlab_integration_router)
base_app.include_router(api_keys_router) # Add routes for API key management
@@ -138,8 +140,6 @@ if ENABLE_JIRA:
base_app.include_router(jira_integration_router)
if ENABLE_JIRA_DC:
base_app.include_router(jira_dc_integration_router)
if ENABLE_LINEAR:
base_app.include_router(linear_integration_router)
if BITBUCKET_DATA_CENTER_HOST:
from server.routes.bitbucket_dc_proxy import (
router as bitbucket_dc_proxy_router, # noqa: E402
@@ -148,9 +148,6 @@ if BITBUCKET_DATA_CENTER_HOST:
base_app.include_router(bitbucket_dc_proxy_router)
base_app.include_router(email_router) # Add routes for email management
base_app.include_router(feedback_router) # Add routes for conversation feedback
base_app.include_router(
event_webhook_router
) # Add routes for Events in nested runtimes
base_app.add_middleware(

View File

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

View File

@@ -56,6 +56,23 @@ RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
# Automation Service
AUTOMATION_SERVICE_URL = os.getenv('AUTOMATION_SERVICE_URL', '').strip()
if AUTOMATION_SERVICE_URL and not AUTOMATION_SERVICE_URL.startswith(
('http://', 'https://')
):
raise ValueError(
f'AUTOMATION_SERVICE_URL must start with http:// or https://, '
f'got: {AUTOMATION_SERVICE_URL}'
)
AUTOMATION_EVENT_FORWARDING_ENABLED = os.getenv(
'AUTOMATION_EVENT_FORWARDING_ENABLED', 'false'
) in ('1', 'true')
# Shared secret for signing payloads sent to automation service (separate from GitHub webhook secret)
AUTOMATION_WEBHOOK_SECRET = os.getenv('AUTOMATION_WEBHOOK_SECRET', '').strip()
# Default HTTP timeout for automation service requests (seconds)
AUTOMATION_SERVICE_TIMEOUT = int(os.getenv('AUTOMATION_SERVICE_TIMEOUT', '30'))
# Account Defender labels that indicate suspicious activity
SUSPICIOUS_LABELS = {
'SUSPICIOUS_LOGIN_ACTIVITY',

View File

@@ -1,808 +0,0 @@
import asyncio
import json
import time
from dataclasses import dataclass, field
from uuid import uuid4
import socketio
from server.logger import logger
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
from sqlalchemy import select
from storage.database import a_session_maker
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.core.config import LLMConfig
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.config.utils import load_openhands_config
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.event_store import EventStore
from openhands.events.event_store_abc import EventStoreABC
from openhands.events.observation import AgentStateChangedObservation
from openhands.events.stream import EventStreamSubscriber
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.conversation_manager.standalone_conversation_manager import (
StandaloneConversationManager,
)
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
from openhands.server.monitoring import MonitoringListener
from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE
from openhands.server.session.session import Session
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import call_sync_from_async, wait_all
from openhands.utils.shutdown_listener import should_continue
# Time in seconds between cleanup operations for stale conversations
_CLEANUP_INTERVAL_SECONDS = 15
# Time in seconds before a Redis entry is considered expired if not refreshed
_REDIS_ENTRY_TIMEOUT_SECONDS = 15
# Time in seconds between updates to Redis entries
_REDIS_UPDATE_INTERVAL_SECONDS = 5
_REDIS_POLL_TIMEOUT = 0.15
@dataclass
class _LLMResponseRequest:
query_id: str
response: str | None
flag: asyncio.Event
@dataclass
class ClusteredConversationManager(StandaloneConversationManager):
"""Manages conversations in clustered mode (multiple server instances with Redis).
This class extends StandaloneConversationManager to provide distributed conversation
management across multiple server instances using Redis as a communication channel
and state store. It handles:
- Cross-server message passing via Redis pub/sub
- Tracking of conversations and connections across the cluster
- Graceful recovery from server failures
- Enforcement of conversation limits across the cluster
- Cleanup of stale conversations and connections
The Redis communication uses several key patterns:
- ohcnv:{user_id}:{conversation_id} - Marks a conversation as active
- ohcnct:{user_id}:{conversation_id}:{connection_id} - Tracks connections to conversations
"""
_redis_listen_task: asyncio.Task | None = field(default=None)
_redis_update_task: asyncio.Task | None = field(default=None)
_llm_responses: dict[str, _LLMResponseRequest] = field(default_factory=dict)
def __post_init__(self):
# We increment the max_concurrent_conversations by 1 because this class
# marks the conversation as started in Redis before checking the number
# of running conversations. This prevents race conditions where multiple
# servers might simultaneously start new conversations.
self.config.max_concurrent_conversations += 1
async def __aenter__(self):
await super().__aenter__()
self._redis_update_task = asyncio.create_task(
self._update_state_in_redis_task()
)
self._redis_listen_task = asyncio.create_task(self._redis_subscribe())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._redis_update_task:
self._redis_update_task.cancel()
self._redis_update_task = None
if self._redis_listen_task:
self._redis_listen_task.cancel()
self._redis_listen_task = None
await super().__aexit__(exc_type, exc_value, traceback)
async def _redis_subscribe(self):
"""Subscribe to Redis messages for cross-server communication.
This method creates a Redis pub/sub subscription to receive messages from
other server instances. It runs in a continuous loop until cancelled.
"""
logger.debug('_redis_subscribe')
redis_client = self._get_redis_client()
pubsub = redis_client.pubsub()
await pubsub.subscribe('session_msg')
while should_continue():
try:
message = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=5
)
if message:
await self._process_message(message)
except asyncio.CancelledError:
logger.debug('redis_subscribe_cancelled')
return
except Exception as e:
try:
asyncio.get_running_loop()
logger.exception(f'error_reading_from_redis:{str(e)}')
except RuntimeError:
# Loop has been shut down, exit gracefully
return
async def _process_message(self, message: dict):
"""Process messages received from Redis pub/sub.
Handles three types of messages:
- 'event': Forward an event to a local session
- 'close_session': Close a local session
- 'session_closing': Handle remote session closure
Args:
message: The Redis pub/sub message containing the action to perform
"""
data = json.loads(message['data'])
logger.debug(f'got_published_message:{message}')
message_type = data['message_type']
if message_type == 'event':
# Forward an event to a local session if it exists
sid = data['sid']
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data['data'])
elif message_type == 'close_session':
# Close a local session if it exists
sid = data['sid']
if sid in self._local_agent_loops_by_sid:
await self._close_session(sid)
elif message_type == 'session_closing':
# Handle connections to a session that is closing on another node
# We only get this in the event of graceful shutdown,
# which can't be guaranteed - nodes can simply vanish unexpectedly!
sid = data['sid']
user_id = data['user_id']
logger.debug(f'session_closing:{sid}')
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._local_connection_id_to_session_id.items())
for connection_id, local_sid in items:
if sid == local_sid:
logger.warning(
f'local_connection_to_closing_session:{connection_id}:{sid}'
)
await self._handle_remote_conversation_stopped(
user_id, connection_id
)
elif message_type == 'llm_completion':
# Request extraneous llm completion from session's LLM Registry
sid = data['sid']
service_id = data['service_id']
messages = data['messages']
llm_config = data['llm_config']
query_id = data['query_id']
session = self._local_agent_loops_by_sid.get(sid)
if session:
llm_registry: LLMRegistry = session.llm_registry
response = await call_sync_from_async(
llm_registry.request_extraneous_completion,
service_id,
llm_config,
messages,
)
await self._get_redis_client().publish(
'session_msg',
json.dumps(
{
'query_id': query_id,
'response': response,
'message_type': 'llm_completion_response',
}
),
)
elif message_type == 'llm_completion_response':
query_id = data['query_id']
llm_response = self._llm_responses.get(query_id)
if llm_response:
llm_response.response = data['response']
llm_response.flag.set()
def _get_redis_client(self):
return getattr(self.sio.manager, 'redis', None)
def _get_redis_conversation_key(self, user_id: str | None, conversation_id: str):
return f'ohcnv:{user_id}:{conversation_id}'
def _get_redis_connection_key(
self, user_id: str, conversation_id: str, connection_id: str
):
return f'ohcnct:{user_id}:{conversation_id}:{connection_id}'
async def _get_event_store(self, sid, user_id) -> EventStoreABC | None:
session = self._local_agent_loops_by_sid.get(sid)
if session:
logger.debug('found_local_agent_loop', extra={'sid': sid})
return session.agent_session.event_stream
redis = self._get_redis_client()
key = self._get_redis_conversation_key(user_id, sid)
value = await redis.get(key)
if value:
logger.debug('found_remote_agent_loop', extra={'sid': sid})
return EventStore(sid, self.file_store, user_id)
return None
async def get_running_agent_loops(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
sids = await self.get_running_agent_loops_locally(user_id, filter_to_sids)
if not filter_to_sids or len(sids) != len(filter_to_sids):
remote_sids = await self._get_running_agent_loops_remotely(
user_id, filter_to_sids
)
sids = sids.union(remote_sids)
return sids
async def get_running_agent_loops_locally(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
sids = await super().get_running_agent_loops(user_id, filter_to_sids)
return sids
async def _get_running_agent_loops_remotely(
self,
user_id: str | None = None,
filter_to_sids: set[str] | None = None,
) -> set[str]:
"""Get the set of conversation IDs running on remote servers.
Args:
user_id: Optional user ID to filter conversations by
filter_to_sids: Optional set of conversation IDs to filter by
Returns:
A set of conversation IDs running on remote servers
"""
if filter_to_sids is not None and not filter_to_sids:
return set()
if user_id:
pattern = self._get_redis_conversation_key(user_id, '*')
else:
pattern = self._get_redis_conversation_key('*', '*')
redis = self._get_redis_client()
result = set()
async for key in redis.scan_iter(pattern):
conversation_id = key.decode().split(':')[2]
if filter_to_sids is None or conversation_id in filter_to_sids:
result.add(conversation_id)
return result
async def get_connections(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> dict[str, str]:
connections = await super().get_connections(user_id, filter_to_sids)
if not filter_to_sids or len(connections) != len(filter_to_sids):
remote_connections = await self._get_connections_remotely(
user_id, filter_to_sids
)
connections.update(remote_connections)
return connections
async def _get_connections_remotely(
self,
user_id: str | None = None,
filter_to_sids: set[str] | None = None,
) -> dict[str, str]:
if filter_to_sids is not None and not filter_to_sids:
return {}
if user_id:
pattern = self._get_redis_connection_key(user_id, '*', '*')
else:
pattern = self._get_redis_connection_key('*', '*', '*')
redis = self._get_redis_client()
result = {}
async for key in redis.scan_iter(pattern):
parts = key.decode().split(':')
conversation_id = parts[2]
connection_id = parts[3]
if filter_to_sids is None or conversation_id in filter_to_sids:
result[connection_id] = conversation_id
return result
async def send_to_event_stream(self, connection_id: str, data: dict) -> None:
sid = self._local_connection_id_to_session_id.get(connection_id)
if sid:
await self.send_event_to_conversation(sid, data)
async def request_llm_completion(
self,
sid: str,
service_id: str,
llm_config: LLMConfig,
messages: list[dict[str, str]],
) -> str:
session = self._local_agent_loops_by_sid.get(sid)
if session:
llm_registry = session.llm_registry
return llm_registry.request_extraneous_completion(
service_id, llm_config, messages
)
flag = asyncio.Event()
query_id = str(uuid4())
query = _LLMResponseRequest(query_id=query_id, response=None, flag=flag)
self._llm_responses[query_id] = query
try:
redis_client = self._get_redis_client()
await redis_client.publish(
'session_msg',
json.dumps(
{
'message_type': 'llm_completion',
'query_id': query_id,
'sid': sid,
'service_id': service_id,
'llm_config': llm_config,
'message': messages,
}
),
)
async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
await flag.wait()
if query.response:
return query.response
raise Exception('Failed to perform LLM completion')
except TimeoutError:
raise Exception('Timeout occured')
async def send_event_to_conversation(self, sid: str, data: dict):
if not sid:
return
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data)
else:
# The session is running on another node
redis_client = self._get_redis_client()
await redis_client.publish(
'session_msg',
json.dumps({'message_type': 'event', 'sid': sid, 'data': data}),
)
async def close_session(self, sid: str):
# Send a message to other nodes telling them to close this session if they have the agent loop, and close any connections.
redis_client = self._get_redis_client()
await redis_client.publish(
'session_msg',
json.dumps({'message_type': 'close_session', 'sid': sid}),
)
await self._close_session(sid)
async def maybe_start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
replay_json: str | None = None,
) -> AgentLoopInfo:
# If we can set the key in redis then no other worker is running this conversation
redis = self._get_redis_client()
key = self._get_redis_conversation_key(user_id, sid) # type: ignore
created = await redis.set(key, 1, nx=True, ex=_REDIS_ENTRY_TIMEOUT_SECONDS)
if created:
await self._start_agent_loop(
sid, settings, user_id, initial_user_msg, replay_json
)
event_store = await self._get_event_store(sid, user_id)
if not event_store:
logger.error(
f'No event stream after starting agent loop: {sid}',
extra={'sid': sid},
)
raise RuntimeError(f'no_event_stream:{sid}')
return AgentLoopInfo(
conversation_id=sid,
url=self._get_conversation_url(sid),
session_api_key=None,
event_store=event_store,
)
async def _update_state_in_redis_task(self):
while should_continue():
try:
await self._update_state_in_redis()
await asyncio.sleep(_REDIS_UPDATE_INTERVAL_SECONDS)
except asyncio.CancelledError:
return
except Exception:
try:
asyncio.get_running_loop()
logger.exception('error_reading_from_redis')
except RuntimeError:
return # Loop has been shut down
async def _update_state_in_redis(self):
"""Refresh all entries in Redis to maintain conversation state across the cluster.
This method:
1. Scans Redis for all conversation keys to build a mapping of conversation IDs to user IDs
2. Updates Redis entries for all local conversations to prevent them from expiring
3. Updates Redis entries for all local connections to prevent them from expiring
This is critical for maintaining the distributed state and allowing other servers
to detect when a server has gone down unexpectedly.
"""
redis = self._get_redis_client()
# Build a mapping of conversation_id -> user_id from existing Redis keys
pattern = self._get_redis_conversation_key('*', '*')
conversation_user_ids = {}
async for key in redis.scan_iter(pattern):
parts = key.decode().split(':')
conversation_user_ids[parts[2]] = parts[1]
pipe = redis.pipeline()
# Add multiple commands to the pipeline
# First, update all local agent loops
for sid, session in self._local_agent_loops_by_sid.items():
if sid:
await pipe.set(
self._get_redis_conversation_key(session.user_id, sid),
1,
ex=_REDIS_ENTRY_TIMEOUT_SECONDS,
)
# Then, update all local connections
for (
connection_id,
conversation_id,
) in self._local_connection_id_to_session_id.items():
user_id = conversation_user_ids.get(conversation_id)
if user_id:
await pipe.set(
self._get_redis_connection_key(
user_id, conversation_id, connection_id
),
1,
ex=_REDIS_ENTRY_TIMEOUT_SECONDS,
)
# Execute all commands in the pipeline
await pipe.execute()
async def _disconnect_from_stopped(self):
"""
Handle connections to conversations that have stopped unexpectedly.
This method detects when a local connection is pointing to a conversation
that was running on another server that has crashed or been terminated
without proper cleanup. It:
1. Identifies local connections to remote conversations
2. Checks which remote conversations are still running in Redis
3. Disconnects from conversations that are no longer running
4. Attempts to restart the conversation locally if possible
"""
# Get the remote sessions with local connections
connected_to_remote_sids = set(
self._local_connection_id_to_session_id.values()
) - set(self._local_agent_loops_by_sid.keys())
if not connected_to_remote_sids:
return
# Get the list of sessions which are actually running
redis = self._get_redis_client()
pattern = self._get_redis_conversation_key('*', '*')
running_remote = set()
async for key in redis.scan_iter(pattern):
parts = key.decode().split(':')
running_remote.add(parts[2])
# Get the list of connections locally where the remote agentloop has died.
stopped_conversation_ids = connected_to_remote_sids - running_remote
if not stopped_conversation_ids:
return
# Process each connection to a stopped conversation
items = list(self._local_connection_id_to_session_id.items())
for connection_id, conversation_id in items:
if conversation_id in stopped_conversation_ids:
logger.warning(
f'local_connection_to_stopped_conversation:{connection_id}:{conversation_id}'
)
# Look up the user_id from the database
async with a_session_maker() as session:
result = await session.execute(
select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id
== conversation_id
)
)
conversation_metadata_saas = result.scalars().first()
user_id = (
str(conversation_metadata_saas.user_id)
if conversation_metadata_saas
else None
)
# Handle the stopped conversation asynchronously
asyncio.create_task(
self._handle_remote_conversation_stopped(user_id, connection_id) # type: ignore
)
async def _close_disconnected(self):
async with self._conversations_lock:
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._detached_conversations.items())
for sid, (conversation, detach_time) in items:
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
close_threshold = time.time() - self.config.sandbox.close_delay
running_loops = list(self._local_agent_loops_by_sid.items())
running_loops.sort(key=lambda item: item[1].last_active_ts)
sid_to_close: list[str] = []
for sid, session in running_loops:
state = session.agent_session.get_state()
if session.last_active_ts < close_threshold and state not in [
AgentState.RUNNING,
None,
]:
sid_to_close.append(sid)
# First we filter out any conversation that has local connections
connections = await super().get_connections(filter_to_sids=set(sid_to_close))
connected_sids = set(connections.values())
sid_to_close = [sid for sid in sid_to_close if sid not in connected_sids]
# Next we filter out any conversation that has remote connections
if sid_to_close:
connections = await self._get_connections_remotely(
filter_to_sids=set(sid_to_close)
)
connected_sids = {sid for _, sid in connections.items()}
sid_to_close = [sid for sid in sid_to_close if sid not in connected_sids]
await wait_all(
(self._close_session(sid) for sid in sid_to_close),
timeout=WAIT_TIME_BEFORE_CLOSE,
)
async def _cleanup_stale(self):
while should_continue():
try:
logger.info(
'conversation_manager',
extra={
'attached': len(self._active_conversations),
'detached': len(self._detached_conversations),
'running': len(self._local_agent_loops_by_sid),
'local_conn': len(self._local_connection_id_to_session_id),
},
)
await self._disconnect_from_stopped()
await self._close_disconnected()
await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
except asyncio.CancelledError:
async with self._conversations_lock:
for conversation, _ in self._detached_conversations.values():
await conversation.disconnect()
self._detached_conversations.clear()
await wait_all(
(
self._close_session(sid)
for sid in self._local_agent_loops_by_sid
),
timeout=WAIT_TIME_BEFORE_CLOSE,
)
return
except Exception:
logger.warning('error_cleaning_stale', exc_info=True, stack_info=True)
await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
async def _close_session(self, sid: str):
logger.info(f'_close_session:{sid}')
redis = self._get_redis_client()
# Keys to delete from redis
to_delete = []
# Remove connections
connection_ids_to_remove = list(
connection_id
for connection_id, conn_sid in self._local_connection_id_to_session_id.items()
if sid == conn_sid
)
if connection_ids_to_remove:
pattern = self._get_redis_connection_key('*', sid, '*')
async for key in redis.scan_iter(pattern):
parts = key.decode().split(':')
connection_id = parts[3]
if connection_id in connection_ids_to_remove:
to_delete.append(key)
logger.info(f'removing connections: {connection_ids_to_remove}')
for connection_id in connection_ids_to_remove:
await self.sio.disconnect(connection_id)
self._local_connection_id_to_session_id.pop(connection_id, None)
# Delete the conversation key if running locally
session = self._local_agent_loops_by_sid.pop(sid, None)
if not session:
logger.info(f'no_session_to_close:{sid}')
if to_delete:
redis.delete(*to_delete)
return
to_delete.append(self._get_redis_conversation_key(session.user_id, sid))
await redis.delete(*to_delete)
try:
redis_client = self._get_redis_client()
if redis_client:
await redis_client.publish(
'session_msg',
json.dumps(
{
'sid': session.sid,
'message_type': 'session_closing',
'user_id': session.user_id,
}
),
)
except Exception:
logger.info(
'error_publishing_close_session_event', exc_info=True, stack_info=True
)
await session.close()
logger.info(f'closed_session:{session.sid}')
async def get_agent_loop_info(self, user_id=None, filter_to_sids=None):
# conversation_ids = await self.get_running_agent_loops(user_id=user_id, filter_to_sids=filter_to_sids)
redis = self._get_redis_client()
results = []
if user_id:
pattern = self._get_redis_conversation_key(user_id, '*')
else:
pattern = self._get_redis_conversation_key('*', '*')
async for key in redis.scan_iter(pattern):
uid, conversation_id = key.decode().split(':')[1:]
if filter_to_sids is None or conversation_id in filter_to_sids:
results.append(
AgentLoopInfo(
conversation_id,
url=self._get_conversation_url(conversation_id),
session_api_key=None,
event_store=EventStore(conversation_id, self.file_store, uid),
runtime_status=RuntimeStatus.READY,
)
)
return results
@classmethod
def get_instance(
cls,
sio: socketio.AsyncServer,
config: OpenHandsConfig,
file_store: FileStore,
server_config: ServerConfig,
monitoring_listener: MonitoringListener | None,
) -> ConversationManager:
return ClusteredConversationManager(
sio,
config,
file_store,
server_config,
monitoring_listener, # type: ignore[arg-type]
)
async def _handle_remote_conversation_stopped(
self, user_id: str, connection_id: str
):
"""Handle a situation where a remote conversation has stopped unexpectedly.
When a server hosting a conversation crashes or is terminated without proper
cleanup, this method attempts to recover by:
1. Verifying the connection and conversation still exist
2. Checking if we can start a new conversation (within limits)
3. Restarting the conversation locally if possible
4. Disconnecting the client if recovery isn't possible
Args:
user_id: The user ID associated with the conversation
connection_id: The connection ID to handle
"""
conversation_id = self._local_connection_id_to_session_id.get(connection_id)
# Not finding a user_id or a conversation_id indicates we are in some unknown state
# so we disconnect
if not user_id or not conversation_id:
await self.sio.disconnect(connection_id)
return
# Wait a second for connections to stabilize
await asyncio.sleep(1)
# Check if there are too many loops running - if so disconnect
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) > self.config.max_concurrent_conversations:
await self.sio.disconnect(connection_id)
return
# Restart the agent loop
from storage.saas_settings_store import SaasSettingsStore
config = load_openhands_config()
settings_store = await SaasSettingsStore.get_instance(config, user_id)
settings = await settings_store.load()
if not settings:
logger.error(f'Failed to load settings for user {user_id}')
return
await self.maybe_start_agent_loop(conversation_id, settings, user_id)
async def _start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
replay_json: str | None = None,
) -> Session:
"""Start an agent loop and add conversation callback subscriber.
This method calls the parent implementation and then adds a subscriber
to the event stream that will invoke conversation callbacks when events occur.
"""
# Call the parent method to start the agent loop
session = await super()._start_agent_loop(
sid, settings, user_id, initial_user_msg, replay_json
)
# Subscribers run in a different thread - if we are going to access socketio, redis or anything else
# bound to the main event loop, we need to pass callbacks back to the main event loop.
loop = asyncio.get_running_loop()
# Add a subscriber for conversation callbacks
def conversation_callback_handler(event):
"""Handle events by invoking conversation callbacks."""
try:
if isinstance(event, AgentStateChangedObservation):
asyncio.run_coroutine_threadsafe(
invoke_conversation_callbacks(sid, event), loop
)
except Exception as e:
logger.error(
f'Error invoking conversation callbacks for {sid}: {str(e)}',
extra={'session_id': sid, 'error': str(e)},
exc_info=True,
)
# Subscribe to the event stream with our callback handler
try:
session.agent_session.event_stream.subscribe(
EventStreamSubscriber.SERVER,
conversation_callback_handler,
'conversation_callbacks',
)
except ValueError:
# Already subscribed - this can happen if the method is called multiple times
pass
return session
def get_local_session(self, sid: str) -> Session:
return self._local_agent_loops_by_sid[sid]

View File

@@ -20,6 +20,7 @@ from server.auth.constants import (
GITLAB_APP_CLIENT_ID,
RECAPTCHA_SITE_KEY,
)
from server.constants import DEPLOYMENT_MODE
from openhands.core.config.utils import load_openhands_config
from openhands.integrations.service_types import ProviderType
@@ -74,10 +75,6 @@ class SaaSServerConfig(ServerConfig):
conversation_store_class: str = (
'storage.saas_conversation_store.SaasConversationStore'
)
conversation_manager_class: str = os.environ.get(
'CONVERSATION_MANAGER_CLASS',
'server.clustered_conversation_manager.ClusteredConversationManager',
)
monitoring_listener_class: str = (
'server.saas_monitoring_listener.SaaSMonitoringListener'
)
@@ -179,6 +176,7 @@ class SaaSServerConfig(ServerConfig):
'ENABLE_JIRA': self.enable_jira,
'ENABLE_JIRA_DC': self.enable_jira_dc,
'ENABLE_LINEAR': self.enable_linear,
'DEPLOYMENT_MODE': DEPLOYMENT_MODE,
},
'PROVIDERS_CONFIGURED': providers_configured,
}

View File

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

View File

@@ -1,56 +0,0 @@
# Conversation Callback Processor
This module provides a framework for processing conversation events and sending summaries or notifications to external platforms like Slack and GitLab.
## Overview
The conversation callback processor system consists of two main components:
1. **ConversationCallback**: A database model that stores information about callbacks to be executed when specific conversation events occur.
2. **ConversationCallbackProcessor**: An abstract base class that defines the interface for processors that handle conversation events.
## How It Works
### ConversationCallback
The `ConversationCallback` class is a database model that stores:
- A reference to a conversation (`conversation_id`)
- The current status of the callback (`ACTIVE`, `COMPLETED`, or `ERROR`)
- The type of processor to use (`processor_type`)
- Serialized processor configuration (`processor_json`)
- Timestamps for creation and updates
This model provides methods to:
- `get_processor()`: Dynamically instantiate the processor from the stored type and JSON data
- `set_processor()`: Store a processor instance by serializing its type and data
### ConversationCallbackProcessor
The `ConversationCallbackProcessor` is an abstract base class that defines the interface for all callback processors. It:
- Is a Pydantic model that can be serialized to/from JSON
- Requires implementing the `__call__` method to process conversation events
- Receives the callback instance and an `AgentStateChangedObservation` when called
## Implemented Processors
### SlackCallbackProcessor
The `SlackCallbackProcessor` sends conversation summaries to Slack channels when specific agent state changes occur. It:
1. Monitors for agent state changes to `AWAITING_USER_INPUT` or `FINISHED`
2. Sends a summary instruction to the conversation if needed
3. Extracts a summary from the conversation
4. Sends the summary to the appropriate Slack channel
5. Marks the callback as completed
### GithubCallbackProcessor and GitlabCallbackProcessor
The `GithubCallbackProcessor` and `GitlabCallbackProcessor` send conversation summaries to GitHub / GitLab issues when specific agent state changes occur. They:
1. Monitors for agent state changes to `AWAITING_USER_INPUT` or `FINISHED`
2. Sends a summary instruction to the conversation if needed
3. Extracts a summary from the conversation
4. Sends the summary to the appropriate Github or GitLab issue
5. Marks the callback as completed

View File

@@ -1 +0,0 @@
# This file makes the conversation_callback_processor directory a Python package

View File

@@ -1,135 +0,0 @@
import asyncio
from datetime import datetime
from integrations.github.github_manager import GithubManager
from integrations.github.github_view import GithubViewType
from integrations.utils import (
extract_summary_from_conversation_manager,
get_summary_instruction,
)
from server.auth.token_manager import TokenManager
from storage.conversation_callback import (
CallbackStatus,
ConversationCallback,
ConversationCallbackProcessor,
)
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.observation.agent import AgentStateChangedObservation
from openhands.events.serialization.event import event_to_dict
from openhands.server.shared import conversation_manager
class GithubCallbackProcessor(ConversationCallbackProcessor):
"""
Processor for sending conversation summaries to GitHub.
This processor is used to send summaries of conversations to GitHub issues/PRs
when agent state changes occur.
"""
github_view: GithubViewType
send_summary_instruction: bool = True
async def _send_message_to_github(self, message: str) -> None:
"""Send a message to GitHub.
Args:
message: The message content to send to GitHub
"""
try:
# Get the token manager
token_manager = TokenManager()
# Create GitHub manager
from integrations.github.data_collector import GitHubDataCollector
github_manager = GithubManager(token_manager, GitHubDataCollector())
# Send the message directly as a string
await github_manager.send_message(message, self.github_view)
logger.info(
f'[GitHub] Sent summary message to {self.github_view.full_repo_name}#{self.github_view.issue_number}'
)
except Exception as e:
logger.exception(f'[GitHub] Failed to send summary message: {str(e)}')
async def __call__(
self,
callback: ConversationCallback,
observation: AgentStateChangedObservation,
) -> None:
"""
Process a conversation event by sending a summary to GitHub.
Args:
callback: The conversation callback
observation: The AgentStateChangedObservation that triggered the callback
"""
logger.info(f'[GitHub] Callback agent state was {observation.agent_state}')
if observation.agent_state not in (
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
):
return
conversation_id = callback.conversation_id
try:
# If we need to send a summary instruction first
if self.send_summary_instruction:
logger.info(
f'[GitHub] Sending summary instruction for conversation {conversation_id}'
)
# Get the summary instruction
summary_instruction = get_summary_instruction()
summary_event = event_to_dict(
MessageAction(content=summary_instruction)
)
# Add the summary instruction to the event stream
logger.info(
f'[GitHub] Sending summary instruction to conversation {conversation_id} {summary_event}'
)
await conversation_manager.send_event_to_conversation(
conversation_id, summary_event
)
logger.info(
f'[GitHub] Sent summary instruction to conversation {conversation_id} {summary_event}'
)
# Update the processor state - the outer session will commit this
self.send_summary_instruction = False
callback.set_processor(self)
callback.updated_at = datetime.now()
return
# Extract the summary from the event store
logger.info(
f'[GitHub] Extracting summary for conversation {conversation_id}'
)
summary = await extract_summary_from_conversation_manager(
conversation_manager, conversation_id
)
# Send the summary to GitHub
asyncio.create_task(self._send_message_to_github(summary))
logger.info(f'[GitHub] Summary sent for conversation {conversation_id}')
# Mark callback as completed status - the outer session will commit this
callback.status = CallbackStatus.COMPLETED
callback.updated_at = datetime.now()
except Exception as e:
logger.exception(
f'[GitHub] Error processing conversation callback: {str(e)}'
)
# Mark callback as error to prevent infinite re-invocation
# The outer session will commit this
callback.status = CallbackStatus.ERROR
callback.updated_at = datetime.now()

View File

@@ -1,136 +0,0 @@
import asyncio
from datetime import datetime
from integrations.gitlab.gitlab_manager import GitlabManager
from integrations.gitlab.gitlab_view import GitlabViewType
from integrations.utils import (
extract_summary_from_conversation_manager,
get_summary_instruction,
)
from server.auth.token_manager import TokenManager
from storage.conversation_callback import (
CallbackStatus,
ConversationCallback,
ConversationCallbackProcessor,
)
from storage.database import a_session_maker
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.observation.agent import AgentStateChangedObservation
from openhands.events.serialization.event import event_to_dict
from openhands.server.shared import conversation_manager
token_manager = TokenManager()
gitlab_manager = GitlabManager(token_manager)
class GitlabCallbackProcessor(ConversationCallbackProcessor):
"""Processor for sending conversation summaries to GitLab.
This processor is used to send summaries of conversations to GitLab
when agent state changes occur.
"""
gitlab_view: GitlabViewType
send_summary_instruction: bool = True
async def _send_message_to_gitlab(self, message: str) -> None:
"""Send a message to GitLab.
Args:
message: The message content to send to GitLab
"""
try:
# Get the token manager
token_manager = TokenManager()
gitlab_manager = GitlabManager(token_manager)
# Send the message directly as a string
await gitlab_manager.send_message(message, self.gitlab_view)
logger.info(
f'[GitLab] Sent summary message to {self.gitlab_view.full_repo_name}#{self.gitlab_view.issue_number}'
)
except Exception as e:
logger.exception(f'[GitLab] Failed to send summary message: {str(e)}')
async def __call__(
self,
callback: ConversationCallback,
observation: AgentStateChangedObservation,
) -> None:
"""
Process a conversation event by sending a summary to GitLab.
Args:
callback: The conversation callback
observation: The AgentStateChangedObservation that triggered the callback
"""
logger.info(f'[GitLab] Callback agent state was {observation.agent_state}')
if observation.agent_state not in (
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
):
return
conversation_id = callback.conversation_id
try:
# If we need to send a summary instruction first
if self.send_summary_instruction:
logger.info(
f'[GitLab] Sending summary instruction for conversation {conversation_id}'
)
# Get the summary instruction
summary_instruction = get_summary_instruction()
summary_event = event_to_dict(
MessageAction(content=summary_instruction)
)
# Add the summary instruction to the event stream
logger.info(
f'[GitLab] Sending summary instruction to conversation {conversation_id} {summary_event}'
)
await conversation_manager.send_event_to_conversation(
conversation_id, summary_event
)
logger.info(
f'[GitLab] Sent summary instruction to conversation {conversation_id} {summary_event}'
)
# Update the processor state
self.send_summary_instruction = False
callback.set_processor(self)
callback.updated_at = datetime.now()
async with a_session_maker() as session:
session.merge(callback)
await session.commit()
return
# Extract the summary from the event store
logger.info(
f'[GitLab] Extracting summary for conversation {conversation_id}'
)
summary = await extract_summary_from_conversation_manager(
conversation_manager, conversation_id
)
# Send the summary to GitLab
asyncio.create_task(self._send_message_to_gitlab(summary))
logger.info(f'[GitLab] Summary sent for conversation {conversation_id}')
# Mark callback as completed status
callback.status = CallbackStatus.COMPLETED
callback.updated_at = datetime.now()
async with a_session_maker() as session:
session.merge(callback)
await session.commit()
except Exception as e:
logger.exception(
f'[GitLab] Error processing conversation callback: {str(e)}'
)

View File

@@ -1,154 +0,0 @@
import asyncio
from integrations.jira.jira_manager import JiraManager
from integrations.utils import (
extract_summary_from_conversation_manager,
get_last_user_msg_from_conversation_manager,
get_summary_instruction,
markdown_to_jira_markup,
)
from server.auth.token_manager import TokenManager
from storage.conversation_callback import (
ConversationCallback,
ConversationCallbackProcessor,
)
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.observation.agent import AgentStateChangedObservation
from openhands.events.serialization.event import event_to_dict
from openhands.server.shared import conversation_manager
token_manager = TokenManager()
jira_manager = JiraManager(token_manager)
integration_store = jira_manager.integration_store
class JiraCallbackProcessor(ConversationCallbackProcessor):
"""
Processor for sending conversation summaries to Jira.
This processor is used to send summaries of conversations to Jira issues
when agent state changes occur.
"""
issue_key: str
workspace_name: str
async def _send_comment_to_jira(self, message: str) -> None:
"""Send a comment to Jira issue.
Args:
message: The message content to send to Jira
"""
try:
# Get workspace details to retrieve API credentials
workspace = await jira_manager.integration_store.get_workspace_by_name(
self.workspace_name
)
if not workspace:
logger.error(f'[Jira] Workspace {self.workspace_name} not found')
return
if workspace.status != 'active':
logger.error(f'[Jira] Workspace {workspace.id} is not active')
return
# Decrypt API key
api_key = jira_manager.token_manager.decrypt_text(workspace.svc_acc_api_key)
# Send comment directly as a string
await jira_manager.send_message(
message,
issue_key=self.issue_key,
jira_cloud_id=workspace.jira_cloud_id,
svc_acc_email=workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
logger.info(
f'[Jira] Sent summary comment to issue {self.issue_key} '
f'(workspace {self.workspace_name})'
)
except Exception as e:
logger.error(f'[Jira] Failed to send summary comment: {str(e)}')
async def __call__(
self,
callback: ConversationCallback,
observation: AgentStateChangedObservation,
) -> None:
"""
Process a conversation event by sending a summary to Jira.
Args:
callback: The conversation callback
observation: The AgentStateChangedObservation that triggered the callback
"""
logger.info(f'[Jira] Callback agent state was {observation.agent_state}')
if observation.agent_state not in (
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
):
return
conversation_id = callback.conversation_id
try:
logger.info(
f'[Jira] Sending summary instruction for conversation {conversation_id}'
)
# Get the summary instruction
summary_instruction = get_summary_instruction()
summary_event = event_to_dict(MessageAction(content=summary_instruction))
# Prevent infinite loops for summary callback that always sends instructions when agent stops
# We should not request summary if the last message is the summary request
last_user_msg = await get_last_user_msg_from_conversation_manager(
conversation_manager, conversation_id
)
logger.info(
'last_user_msg',
extra={
'last_user_msg': [m.content for m in last_user_msg],
'summary_instruction': summary_instruction,
},
)
if (
len(last_user_msg) > 0
and last_user_msg[0].content == summary_instruction
):
# Extract the summary from the event store
logger.info(
f'[Jira] Extracting summary for conversation {conversation_id}'
)
summary_markdown = await extract_summary_from_conversation_manager(
conversation_manager, conversation_id
)
summary = markdown_to_jira_markup(summary_markdown)
asyncio.create_task(self._send_comment_to_jira(summary))
logger.info(f'[Jira] Summary sent for conversation {conversation_id}')
return
# Add the summary instruction to the event stream
logger.info(
f'[Jira] Sending summary instruction to conversation {conversation_id} {summary_event}'
)
await conversation_manager.send_event_to_conversation(
conversation_id, summary_event
)
logger.info(
f'[Jira] Sent summary instruction to conversation {conversation_id} {summary_event}'
)
except Exception:
logger.error(
'[Jira] Error processing conversation callback',
exc_info=True,
stack_info=True,
)

View File

@@ -1,158 +0,0 @@
import asyncio
from integrations.jira_dc.jira_dc_manager import JiraDcManager
from integrations.utils import (
extract_summary_from_conversation_manager,
get_last_user_msg_from_conversation_manager,
get_summary_instruction,
markdown_to_jira_markup,
)
from server.auth.token_manager import TokenManager
from storage.conversation_callback import (
ConversationCallback,
ConversationCallbackProcessor,
)
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.observation.agent import AgentStateChangedObservation
from openhands.events.serialization.event import event_to_dict
from openhands.server.shared import conversation_manager
token_manager = TokenManager()
jira_dc_manager = JiraDcManager(token_manager)
class JiraDcCallbackProcessor(ConversationCallbackProcessor):
"""
Processor for sending conversation summaries to Jira DC.
This processor is used to send summaries of conversations to Jira DC issues
when agent state changes occur.
"""
issue_key: str
workspace_name: str
base_api_url: str
async def _send_comment_to_jira_dc(self, message: str) -> None:
"""Send a comment to Jira DC issue.
Args:
message: The message content to send to Jira DC
"""
try:
# Get workspace details to retrieve API credentials
workspace = await jira_dc_manager.integration_store.get_workspace_by_name(
self.workspace_name
)
if not workspace:
logger.error(f'[Jira DC] Workspace {self.workspace_name} not found')
return
if workspace.status != 'active':
logger.error(f'[Jira DC] Workspace {workspace.id} is not active')
return
# Decrypt API key
api_key = jira_dc_manager.token_manager.decrypt_text(
workspace.svc_acc_api_key
)
# Send comment directly as a string
await jira_dc_manager.send_message(
message,
issue_key=self.issue_key,
base_api_url=self.base_api_url,
svc_acc_api_key=api_key,
)
logger.info(
f'[Jira DC] Sent summary comment to issue {self.issue_key} '
f'(workspace {self.workspace_name})'
)
except Exception as e:
logger.error(f'[Jira DC] Failed to send summary comment: {str(e)}')
async def __call__(
self,
callback: ConversationCallback,
observation: AgentStateChangedObservation,
) -> None:
"""
Process a conversation event by sending a summary to Jira DC.
Args:
callback: The conversation callback
observation: The AgentStateChangedObservation that triggered the callback
"""
logger.info(f'[Jira DC] Callback agent state was {observation.agent_state}')
if observation.agent_state not in (
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
):
return
conversation_id = callback.conversation_id
try:
logger.info(
f'[Jira DC] Sending summary instruction for conversation {conversation_id}'
)
# Get the summary instruction
summary_instruction = get_summary_instruction()
summary_event = event_to_dict(MessageAction(content=summary_instruction))
# Prevent infinite loops for summary callback that always sends instructions when agent stops
# We should not request summary if the last message is the summary request
last_user_msg = await get_last_user_msg_from_conversation_manager(
conversation_manager, conversation_id
)
logger.info(
'last_user_msg',
extra={
'last_user_msg': [m.content for m in last_user_msg],
'summary_instruction': summary_instruction,
},
)
if (
len(last_user_msg) > 0
and last_user_msg[0].content == summary_instruction
):
# Extract the summary from the event store
logger.info(
f'[Jira DC] Extracting summary for conversation {conversation_id}'
)
summary_markdown = await extract_summary_from_conversation_manager(
conversation_manager, conversation_id
)
summary = markdown_to_jira_markup(summary_markdown)
asyncio.create_task(self._send_comment_to_jira_dc(summary))
logger.info(
f'[Jira DC] Summary sent for conversation {conversation_id}'
)
return
# Add the summary instruction to the event stream
logger.info(
f'[Jira DC] Sending summary instruction to conversation {conversation_id} {summary_event}'
)
await conversation_manager.send_event_to_conversation(
conversation_id, summary_event
)
logger.info(
f'[Jira DC] Sent summary instruction to conversation {conversation_id} {summary_event}'
)
except Exception:
logger.error(
'[Jira DC] Error processing conversation callback',
exc_info=True,
stack_info=True,
)

View File

@@ -1,152 +0,0 @@
import asyncio
from integrations.linear.linear_manager import LinearManager
from integrations.utils import (
extract_summary_from_conversation_manager,
get_last_user_msg_from_conversation_manager,
get_summary_instruction,
)
from server.auth.token_manager import TokenManager
from storage.conversation_callback import (
ConversationCallback,
ConversationCallbackProcessor,
)
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.observation.agent import AgentStateChangedObservation
from openhands.events.serialization.event import event_to_dict
from openhands.server.shared import conversation_manager
token_manager = TokenManager()
linear_manager = LinearManager(token_manager)
class LinearCallbackProcessor(ConversationCallbackProcessor):
"""
Processor for sending conversation summaries to Linear.
This processor is used to send summaries of conversations to Linear issues
when agent state changes occur.
"""
issue_id: str
issue_key: str
workspace_name: str
async def _send_comment_to_linear(self, message: str) -> None:
"""Send a comment to Linear issue.
Args:
message: The message content to send to Linear
"""
try:
# Get workspace details to retrieve API key
workspace = await linear_manager.integration_store.get_workspace_by_name(
self.workspace_name
)
if not workspace:
logger.error(f'[Linear] Workspace {self.workspace_name} not found')
return
if workspace.status != 'active':
logger.error(f'[Linear] Workspace {workspace.id} is not active')
return
# Decrypt API key
api_key = linear_manager.token_manager.decrypt_text(
workspace.svc_acc_api_key
)
# Send comment directly as a string
await linear_manager.send_message(
message,
self.issue_id,
api_key,
)
logger.info(
f'[Linear] Sent summary comment to issue {self.issue_key} '
f'(workspace {self.workspace_name})'
)
except Exception as e:
logger.error(f'[Linear] Failed to send summary comment: {str(e)}')
async def __call__(
self,
callback: ConversationCallback,
observation: AgentStateChangedObservation,
) -> None:
"""
Process a conversation event by sending a summary to Linear.
Args:
callback: The conversation callback
observation: The AgentStateChangedObservation that triggered the callback
"""
logger.info(f'[Linear] Callback agent state was {observation.agent_state}')
if observation.agent_state not in (
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
):
return
conversation_id = callback.conversation_id
try:
logger.info(
f'[Linear] Sending summary instruction for conversation {conversation_id}'
)
# Get the summary instruction
summary_instruction = get_summary_instruction()
summary_event = event_to_dict(MessageAction(content=summary_instruction))
# Prevent infinite loops for summary callback that always sends instructions when agent stops
# We should not request summary if the last message is the summary request
last_user_msg = await get_last_user_msg_from_conversation_manager(
conversation_manager, conversation_id
)
logger.info(
'last_user_msg',
extra={
'last_user_msg': [m.content for m in last_user_msg],
'summary_instruction': summary_instruction,
},
)
if (
len(last_user_msg) > 0
and last_user_msg[0].content == summary_instruction
):
# Extract the summary from the event store
logger.info(
f'[Linear] Extracting summary for conversation {conversation_id}'
)
summary = await extract_summary_from_conversation_manager(
conversation_manager, conversation_id
)
# Send the summary to Linear
asyncio.create_task(self._send_comment_to_linear(summary))
logger.info(f'[Linear] Summary sent for conversation {conversation_id}')
return
# Add the summary instruction to the event stream
logger.info(
f'[Linear] Sending summary instruction to conversation {conversation_id} {summary_event}'
)
await conversation_manager.send_event_to_conversation(
conversation_id, summary_event
)
logger.info(
f'[Linear] Sent summary instruction to conversation {conversation_id} {summary_event}'
)
except Exception:
logger.error(
'[Linear] Error processing conversation callback',
exc_info=True,
stack_info=True,
)

View File

@@ -1,179 +0,0 @@
import asyncio
from integrations.models import Message, SourceType
from integrations.slack.slack_manager import SlackManager
from integrations.slack.slack_view import SlackFactory
from integrations.utils import (
extract_summary_from_conversation_manager,
get_last_user_msg_from_conversation_manager,
get_summary_instruction,
)
from server.auth.token_manager import TokenManager
from storage.conversation_callback import (
ConversationCallback,
ConversationCallbackProcessor,
)
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.observation.agent import AgentStateChangedObservation
from openhands.events.serialization.event import event_to_dict
from openhands.server.shared import conversation_manager
token_manager = TokenManager()
slack_manager = SlackManager(token_manager)
class SlackCallbackProcessor(ConversationCallbackProcessor):
"""Processor for sending conversation summaries to Slack.
This processor is used to send summaries of conversations to Slack channels
when agent state changes occur.
"""
slack_user_id: str
channel_id: str
message_ts: str
thread_ts: str | None
team_id: str
last_user_msg_id: int | None = None
async def _send_message_to_slack(self, message: str) -> None:
"""Send a message to Slack.
Args:
message: The message content to send to Slack
"""
try:
# Create a message object for Slack view creation (incoming message format)
message_obj = Message(
source=SourceType.SLACK,
message={
'slack_user_id': self.slack_user_id,
'channel_id': self.channel_id,
'message_ts': self.message_ts,
'thread_ts': self.thread_ts,
'team_id': self.team_id,
'user_msg': message,
},
)
slack_user, saas_user_auth = await slack_manager.authenticate_user(
self.slack_user_id
)
slack_view = await SlackFactory.create_slack_view_from_payload(
message_obj, slack_user, saas_user_auth
)
# Send the message directly as a string
await slack_manager.send_message(message, slack_view)
logger.info(
f'[Slack] Sent summary message to channel {self.channel_id} '
f'for user {self.slack_user_id}'
)
except Exception as e:
logger.error(f'[Slack] Failed to send summary message: {str(e)}')
async def __call__(
self,
callback: ConversationCallback,
observation: AgentStateChangedObservation,
) -> None:
"""
Process a conversation event by sending a summary to Slack.
Args:
conversation_id: The ID of the conversation to process
observation: The AgentStateChangedObservation that triggered the callback
callback: The conversation callback
"""
logger.info(f'[Slack] Callback agent state was {observation.agent_state}')
if observation.agent_state not in (
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
):
return
conversation_id = callback.conversation_id
try:
logger.info(f'[Slack] Processing conversation {conversation_id}')
# Get the summary instruction
summary_instruction = get_summary_instruction()
summary_event = event_to_dict(MessageAction(content=summary_instruction))
# Prevent infinite loops for summary callback that always sends instructions when agent stops
# We should not request summary if the last message is the summary request
last_user_msg = await get_last_user_msg_from_conversation_manager(
conversation_manager, conversation_id
)
# Check if we have any messages
if len(last_user_msg) == 0:
logger.info(
f'[Slack] No messages found for conversation {conversation_id}'
)
return
# Get the ID of the last user message
current_msg_id = last_user_msg[0].id if last_user_msg else None
logger.info(
'last_user_msg',
extra={
'last_user_msg': [m.content for m in last_user_msg],
'summary_instruction': summary_instruction,
'current_msg_id': current_msg_id,
'last_user_msg_id': self.last_user_msg_id,
},
)
# Check if the message ID has changed
if current_msg_id == self.last_user_msg_id:
logger.info(
f'[Slack] Skipping processing as message ID has not changed: {current_msg_id}'
)
return
# Update the last user message ID
self.last_user_msg_id = current_msg_id
# Update the processor in the callback and save to database
callback.set_processor(self)
logger.info(f'[Slack] Updated last_user_msg_id to {self.last_user_msg_id}')
if last_user_msg[0].content == summary_instruction:
# Extract the summary from the event store
logger.info(
f'[Slack] Extracting summary for conversation {conversation_id}'
)
summary = await extract_summary_from_conversation_manager(
conversation_manager, conversation_id
)
# Send the summary to Slack
asyncio.create_task(self._send_message_to_slack(summary))
logger.info(f'[Slack] Summary sent for conversation {conversation_id}')
return
# Add the summary instruction to the event stream
logger.info(
f'[Slack] Sending summary instruction to conversation {conversation_id} {summary_event}'
)
await conversation_manager.send_event_to_conversation(
conversation_id, summary_event
)
logger.info(
f'[Slack] Sent summary instruction to conversation {conversation_id} {summary_event}'
)
except Exception:
logger.error(
'[Slack] Error processing conversation callback',
exc_info=True,
stack_info=True,
)

View File

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

View File

@@ -1,6 +1,9 @@
"""SAAS-specific user models that extend OSS UserInfo with organization fields."""
from pydantic import BaseModel
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.service_types import ProviderType
class SaasUserInfo(UserInfo):
@@ -14,3 +17,10 @@ class SaasUserInfo(UserInfo):
org_name: str | None = None
role: str | None = None
permissions: list[str] | None = None
class GitOrganizationsResponse(BaseModel):
"""Response model for the Git organizations the user belongs to on their active provider."""
provider: ProviderType
organizations: list[str]

View File

@@ -27,8 +27,10 @@ from server.auth.user.user_authorizer import (
depends_user_authorizer,
)
from server.config import sign_token
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from server.constants import (
DEPLOYMENT_MODE,
IS_FEATURE_ENV,
)
from server.services.org_invitation_service import (
EmailMismatchError,
InvitationExpiredError,
@@ -36,6 +38,7 @@ from server.services.org_invitation_service import (
OrgInvitationService,
UserAlreadyMemberError,
)
from server.utils.conversation_utils import get_session_api_key, get_user_id
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
@@ -462,8 +465,20 @@ async def keycloak_callback(
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
response = RedirectResponse(tos_redirect_url, status_code=302)
else:
# User has accepted TOS - check if they need onboarding
# Only redirect to onboarding if user has a valid offline token,
# otherwise they need to complete the Keycloak offline token flow first
if valid_offline_token and await _should_redirect_to_onboarding(user_id, user):
redirect_url = f'{web_url}/onboarding'
logger.info(
'Redirecting returning user to onboarding',
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
)
if invitation_token:
redirect_url = f'{redirect_url}&invitation_success=true'
if '?' in redirect_url:
redirect_url = f'{redirect_url}&invitation_success=true'
else:
redirect_url = f'{redirect_url}?invitation_success=true'
response = RedirectResponse(redirect_url, status_code=302)
set_response_cookie(
@@ -471,7 +486,7 @@ async def keycloak_callback(
response=response,
keycloak_access_token=keycloak_access_token,
keycloak_refresh_token=keycloak_refresh_token,
secure=True if redirect_url.startswith('https') else False,
secure=True if web_url.startswith('https') else False,
accepted_tos=has_accepted_tos,
)
@@ -512,8 +527,23 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
user_id=user_info.sub, offline_token=keycloak_refresh_token
)
user = await UserStore.get_user_by_id(user_info.sub)
has_accepted_tos = user is not None and user.accepted_tos is not None
redirect_url, _, _ = _extract_oauth_state(state)
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
default_url = redirect_url if redirect_url else web_url
final_url = await _get_post_auth_redirect(user_info.sub, default_url, web_url, user)
response = RedirectResponse(final_url, status_code=302)
set_response_cookie(
request=request,
response=response,
keycloak_access_token=keycloak_access_token,
keycloak_refresh_token=keycloak_refresh_token,
secure=True if web_url.startswith('https') else False,
accepted_tos=has_accepted_tos,
)
return response
@oauth_router.get('/github/callback')
@@ -549,6 +579,69 @@ async def authenticate(request: Request):
return response
async def _should_redirect_to_onboarding(user_id: str, user: User) -> bool:
"""Check if user should be redirected to onboarding after TOS acceptance.
Backend always redirects applicable users to /onboarding.
Returns True if:
- User has onboarding_completed explicitly set to False (new users)
- Either:
- Deployment mode is 'cloud' (all users)
- Deployment mode is 'self_hosted' AND user is the super admin
(first owner in their current org to accept TOS)
Returns False if:
- User has onboarding_completed=True (already completed)
- User has onboarding_completed=None (existing users before this feature)
"""
# Already completed onboarding
if user.onboarding_completed is True:
return False
# Existing user before this feature (NULL in database)
if user.onboarding_completed is None:
return False
# Cloud SaaS: all users go to onboarding
if DEPLOYMENT_MODE == 'cloud':
return True
# Self-hosted SaaS: only the super admin (first owner to accept TOS in the org)
if DEPLOYMENT_MODE == 'self_hosted':
first_owner = await UserStore.get_first_owner_in_org(user.current_org_id)
if first_owner and str(first_owner.id) == user_id:
return True
return False
async def _get_post_auth_redirect(
user_id: str, default_url: str, web_url: str, user: User | None = None
) -> str:
"""Determine where to redirect user after authentication completes.
Called after offline token is stored to determine final redirect destination.
Checks for pending user flows (e.g., onboarding) before falling back to default.
Args:
user_id: The user's ID.
default_url: The default URL to redirect to if no special flow is needed.
web_url: The base web URL for constructing absolute paths.
user: Optional user object to avoid refetching.
Returns:
The URL to redirect the user to.
"""
if not user:
user = await UserStore.get_user_by_id(user_id)
if user and await _should_redirect_to_onboarding(user_id, user):
logger.info(
'Redirecting user to onboarding',
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
)
return f'{web_url}/onboarding'
return default_url
@api_router.post('/accept_tos')
async def accept_tos(request: Request):
user_auth = cast(SaasUserAuth, await get_user_auth(request))
@@ -589,6 +682,12 @@ async def accept_tos(request: Request):
logger.info(f'User {user_id} accepted TOS')
# Determine final redirect - but don't override if it's the offline token flow
# (the offline callback will handle post-auth redirect after storing the token)
is_offline_flow = 'offline' in redirect_url
if not is_offline_flow:
redirect_url = await _get_post_auth_redirect(user_id, redirect_url, web_url)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
)
@@ -598,12 +697,42 @@ async def accept_tos(request: Request):
response=response,
keycloak_access_token=access_token.get_secret_value(),
keycloak_refresh_token=refresh_token.get_secret_value(),
secure=not IS_LOCAL_ENV,
secure=True if web_url.startswith('https') else False,
accepted_tos=True,
)
return response
@api_router.post('/complete_onboarding')
async def complete_onboarding(request: Request):
"""Mark onboarding as completed for the current user."""
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_id = await user_auth.get_user_id()
if not user_id:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'User is not authenticated'},
)
user = await UserStore.mark_onboarding_completed(user_id)
if not user:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'User not found'},
)
logger.info(
'User completed onboarding',
extra={'user_id': user_id},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Onboarding completed'},
)
@api_router.post('/logout')
async def logout(request: Request):
# Always create the response object first to ensure we can return it even if errors occur
@@ -641,8 +770,8 @@ async def refresh_tokens(
x_session_api_key: Annotated[str | None, Header(alias='X-Session-API-Key')],
) -> TokenResponse:
"""Return the latest token for a given provider."""
user_id = _get_user_id(sid)
session_api_key = await _get_session_api_key(user_id, sid)
user_id = get_user_id(sid)
session_api_key = await get_session_api_key(sid)
if session_api_key != x_session_api_key:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Forbidden')

View File

@@ -1,247 +0,0 @@
import base64
import json
from enum import Enum
from typing import Annotated, Tuple
from fastapi import (
APIRouter,
BackgroundTasks,
Header,
HTTPException,
Request,
Response,
status,
)
from pydantic import BaseModel
from server.logger import logger
from server.utils.conversation_callback_utils import (
process_event,
update_agent_state,
update_conversation_metadata,
update_conversation_stats,
)
from storage.database import session_maker
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.server.shared import conversation_manager
event_webhook_router = APIRouter(prefix='/event-webhook')
class BatchMethod(Enum):
POST = 'POST'
DELETE = 'DELETE'
class BatchOperation(BaseModel):
method: BatchMethod
path: str
content: str | None = None
encoding: str | None = None
def get_content(self) -> bytes:
if self.content is None:
raise ValueError('empty_content_in_batch')
if self.encoding == 'base64':
return base64.b64decode(self.content.encode('ascii'))
return self.content.encode('utf-8')
def get_content_json(self) -> dict:
return json.loads(self.get_content())
async def _process_batch_operations_background(
batch_ops: list[BatchOperation],
x_session_api_key: str | None,
):
"""Background task to process batched webhook requests with multiple file operations"""
prev_conversation_id = None
user_id = None
for batch_op in batch_ops:
try:
if batch_op.method != BatchMethod.POST:
# Log unhandled methods for future implementation
logger.info(
'invalid_operation_in_batch_webhook',
extra={
'method': str(batch_op.method),
'path': batch_op.path,
},
)
continue
# Updates to certain paths in the nested runtime are ignored
if batch_op.path in {'settings.json', 'secrets.json'}:
continue
conversation_id, subpath = _parse_conversation_id_and_subpath(batch_op.path)
# If the conversation id changes, then we must recheck the session_api_key
if conversation_id != prev_conversation_id:
user_id = _get_user_id(conversation_id)
session_api_key = await _get_session_api_key(user_id, conversation_id)
prev_conversation_id = conversation_id
if session_api_key != x_session_api_key:
logger.error(
'authentication_failed_in_batch_webhook_background',
extra={
'conversation_id': conversation_id,
'user_id': user_id,
'path': batch_op.path,
},
)
continue # Skip this operation but continue with others
if user_id is None:
logger.error(
'user_id_not_set_in_batch_webhook',
extra={
'conversation_id': conversation_id,
'path': batch_op.path,
},
)
continue
if subpath == 'agent_state.pkl':
update_agent_state(user_id, conversation_id, batch_op.get_content())
continue
if subpath == 'conversation_stats.pkl':
update_conversation_stats(
user_id, conversation_id, batch_op.get_content()
)
continue
if subpath == 'metadata.json':
update_conversation_metadata(
conversation_id, batch_op.get_content_json()
)
continue
if subpath.startswith('events/'):
await process_event(
user_id, conversation_id, subpath, batch_op.get_content_json()
)
continue
if subpath.startswith('event_cache'):
# No action required
continue
# Log unhandled paths for future implementation
logger.warning(
'unknown_path_in_batch_webhook',
extra={
'path': subpath,
'user_id': user_id,
'conversation_id': conversation_id,
},
)
except Exception as e:
logger.error(
f'error_processing_batch_operation: {type(e).__name__}: {e}',
extra={
'path': batch_op.path,
'method': str(batch_op.method),
},
exc_info=True,
)
@event_webhook_router.post('/batch')
async def on_batch_write(
batch_ops: list[BatchOperation],
background_tasks: BackgroundTasks,
x_session_api_key: Annotated[str | None, Header()],
):
"""Handle batched webhook requests with multiple file operations in background"""
# Add the batch processing to background tasks
background_tasks.add_task(
_process_batch_operations_background,
batch_ops,
x_session_api_key,
)
# Return immediately
return Response(status_code=status.HTTP_202_ACCEPTED)
@event_webhook_router.post('/{path:path}')
async def on_write(
path: str,
request: Request,
x_session_api_key: Annotated[str | None, Header()],
):
"""Handle writing conversation events and metadata"""
conversation_id, subpath = _parse_conversation_id_and_subpath(path)
user_id = _get_user_id(conversation_id)
# Check the session API key to make sure this is from the correct conversation
session_api_key = await _get_session_api_key(user_id, conversation_id)
if session_api_key != x_session_api_key:
return Response(status_code=status.HTTP_403_FORBIDDEN)
if subpath == 'agent_state.pkl':
content = await request.body()
update_agent_state(user_id, conversation_id, content)
return Response(status_code=status.HTTP_200_OK)
try:
content = await request.json()
except Exception as exc:
return Response(status_code=status.HTTP_400_BAD_REQUEST, content=str(exc))
if subpath == 'metadata.json':
update_conversation_metadata(conversation_id, content)
return Response(status_code=status.HTTP_200_OK)
if subpath.startswith('events/'):
await process_event(user_id, conversation_id, subpath, content)
return Response(status_code=status.HTTP_200_OK)
if subpath.startswith('event_cache'):
# No actionr required
return Response(status_code=status.HTTP_200_OK)
logger.error(
'invalid_subpath_in_webhook',
extra={
'path': path,
'user_id': user_id,
},
)
return Response(status_code=status.HTTP_400_BAD_REQUEST)
@event_webhook_router.delete('/{path:path}')
async def on_delete(path: str, x_session_api_key: Annotated[str | None, Header()]):
return Response(status_code=status.HTTP_200_OK)
def _parse_conversation_id_and_subpath(path: str) -> Tuple[str, str]:
try:
items = path.split('/')
assert items[0] == 'sessions'
conversation_id = items[1]
subpath = '/'.join(items[2:])
return conversation_id, subpath
except (AssertionError, IndexError) as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) from e
def _get_user_id(conversation_id: str) -> str:
with session_maker() as session:
conversation_metadata_saas = (
session.query(StoredConversationMetadataSaas)
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
.first()
)
return str(conversation_metadata_saas.user_id)
async def _get_session_api_key(user_id: str, conversation_id: str) -> str | None:
agent_loop_info = await conversation_manager.get_agent_loop_info(
user_id, filter_to_sids={conversation_id}
)
return agent_loop_info[0].session_api_key

View File

@@ -3,15 +3,20 @@ import hashlib
import hmac
import os
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from integrations.github.data_collector import GitHubDataCollector
from integrations.github.github_manager import GithubManager
from integrations.models import Message, SourceType
from server.auth.constants import GITHUB_APP_WEBHOOK_SECRET
from server.auth.constants import (
AUTOMATION_EVENT_FORWARDING_ENABLED,
GITHUB_APP_WEBHOOK_SECRET,
)
from server.auth.token_manager import TokenManager
from server.services.automation_event_service import AutomationEventService
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderType
# Environment variable to disable GitHub webhooks
GITHUB_WEBHOOKS_ENABLED = os.environ.get('GITHUB_WEBHOOKS_ENABLED', '1') in (
@@ -22,6 +27,7 @@ github_integration_router = APIRouter(prefix='/integration')
token_manager = TokenManager()
data_collector = GitHubDataCollector()
github_manager = GithubManager(token_manager, data_collector)
automation_event_service = AutomationEventService(token_manager)
def verify_github_signature(payload: bytes, signature: str):
@@ -46,13 +52,13 @@ def verify_github_signature(payload: bytes, signature: str):
@github_integration_router.post('/github/events')
async def github_events(
request: Request,
background_tasks: BackgroundTasks,
x_hub_signature_256: str = Header(None),
x_github_event: str = Header(None),
):
# Check if GitHub webhooks are enabled
if not GITHUB_WEBHOOKS_ENABLED:
logger.info(
'GitHub webhooks are disabled by GITHUB_WEBHOOKS_ENABLED environment variable'
)
logger.info('GitHub webhooks disabled by GITHUB_WEBHOOKS_ENABLED env variable')
return JSONResponse(
status_code=200,
content={'message': 'GitHub webhooks are currently disabled.'},
@@ -72,6 +78,16 @@ async def github_events(
content={'error': 'Installation ID is missing in the payload.'},
)
# Forward to automation service (fire-and-forget background task)
if AUTOMATION_EVENT_FORWARDING_ENABLED:
background_tasks.add_task(
automation_event_service.forward_event,
provider=ProviderType.GITHUB,
payload=payload_data,
installation_id=installation_id,
)
# Existing resolver bot processing
message_payload = {'payload': payload_data, 'installation': installation_id}
message = Message(source=SourceType.GITHUB, message=message_payload)
await github_manager.receive_message(message)

View File

@@ -149,7 +149,12 @@ async def verify_jira_signature(body: bytes, signature: str, payload: dict):
workspace_name = jira_manager.get_workspace_name_from_payload(payload)
if workspace_name is None:
logger.warning('[Jira] No workspace name found in webhook payload')
logger.warning(
'[Jira] No workspace name found in webhook payload',
extra={
'payload': payload,
},
)
raise HTTPException(
status_code=403, detail='Workspace name not found in payload'
)

View File

@@ -1,681 +0,0 @@
import json
import os
import re
import uuid
from typing import cast
import requests
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from integrations.linear.linear_manager import LinearManager
from integrations.models import Message, SourceType
from pydantic import BaseModel, Field, field_validator
from server.auth.constants import LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.constants import WEB_HOST
from storage.redis import create_redis_client
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import get_user_auth
# Environment variable to disable Linear webhooks
LINEAR_WEBHOOKS_ENABLED = os.environ.get('LINEAR_WEBHOOKS_ENABLED', '0') in (
'1',
'true',
)
LINEAR_REDIRECT_URI = f'https://{WEB_HOST}/integration/linear/callback'
LINEAR_SCOPES = 'read'
LINEAR_AUTH_URL = 'https://linear.app/oauth/authorize'
LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token'
LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'
# Request/Response models
class LinearWorkspaceCreate(BaseModel):
workspace_name: str = Field(..., description='Workspace display name')
webhook_secret: str = Field(..., description='Webhook secret for verification')
svc_acc_email: str = Field(..., description='Service account email')
svc_acc_api_key: str = Field(..., description='Service account API key')
is_active: bool = Field(
default=False,
description='Indicates if the workspace integration is active',
)
@field_validator('workspace_name')
@classmethod
def validate_workspace_name(cls, v):
if not re.match(r'^[a-zA-Z0-9_.-]+$', v):
raise ValueError(
'workspace_name can only contain alphanumeric characters, hyphens, underscores, and periods'
)
return v
@field_validator('svc_acc_email')
@classmethod
def validate_svc_acc_email(cls, v):
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_pattern, v):
raise ValueError('svc_acc_email must be a valid email address')
return v
@field_validator('webhook_secret')
@classmethod
def validate_webhook_secret(cls, v):
if ' ' in v:
raise ValueError('webhook_secret cannot contain spaces')
return v
@field_validator('svc_acc_api_key')
@classmethod
def validate_svc_acc_api_key(cls, v):
if ' ' in v:
raise ValueError('svc_acc_api_key cannot contain spaces')
return v
class LinearLinkCreate(BaseModel):
workspace_name: str = Field(
..., description='Name of the Linear workspace to link to'
)
@field_validator('workspace_name')
@classmethod
def validate_workspace(cls, v):
if not re.match(r'^[a-zA-Z0-9_.-]+$', v):
raise ValueError(
'workspace can only contain alphanumeric characters, hyphens, underscores, and periods'
)
return v
class LinearWorkspaceResponse(BaseModel):
id: int
name: str
linear_org_id: str
status: str
editable: bool
created_at: str
updated_at: str
class LinearUserResponse(BaseModel):
id: int
keycloak_user_id: str
linear_workspace_id: int
status: str
created_at: str
updated_at: str
workspace: LinearWorkspaceResponse
class LinearValidateWorkspaceResponse(BaseModel):
name: str
status: str
message: str
linear_integration_router = APIRouter(prefix='/integration/linear')
token_manager = TokenManager()
linear_manager = LinearManager(token_manager)
redis_client = create_redis_client()
async def _handle_workspace_link_creation(
user_id: str, linear_user_id: str, target_workspace: str
):
"""Handle the creation or reactivation of a workspace link for a user."""
# Verify workspace exists and is active
workspace = await linear_manager.integration_store.get_workspace_by_name(
target_workspace
)
if not workspace:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Workspace "{target_workspace}" not found',
)
if workspace.status.lower() != 'active':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Workspace "{target_workspace}" is not active',
)
# Check if user currently has an active workspace link
existing_user = await linear_manager.integration_store.get_user_by_active_workspace(
user_id
)
if existing_user:
# User has an active link - check if it's to the same workspace
if existing_user.linear_workspace_id == workspace.id:
# Already linked to this workspace, nothing to do
return
else:
# User is trying to link to a different workspace while having an active link
# This is not allowed - they must unlink first
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You already have an active workspace link. Please unlink from your current workspace before linking to a different one.',
)
# Check if user had a previous link to this specific workspace
existing_link = (
await linear_manager.integration_store.get_user_by_keycloak_id_and_workspace(
user_id, workspace.id
)
)
if existing_link:
# Reactivate previous link to this workspace
await linear_manager.integration_store.update_user_integration_status(
user_id, 'active'
)
else:
# Create new workspace link
await linear_manager.integration_store.create_workspace_link(
keycloak_user_id=user_id,
linear_user_id=linear_user_id,
linear_workspace_id=workspace.id,
)
async def _validate_workspace_update_permissions(user_id: str, target_workspace: str):
"""Validate that user can update the target workspace."""
workspace = await linear_manager.integration_store.get_workspace_by_name(
target_workspace
)
if not workspace:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Workspace "{target_workspace}" not found',
)
# Check if user is the admin of the workspace
if workspace.admin_user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You do not have permission to update this workspace',
)
# Check if user's current link matches the workspace
current_user_link = (
await linear_manager.integration_store.get_user_by_active_workspace(user_id)
)
if current_user_link and current_user_link.linear_workspace_id != workspace.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You can only update the workspace you are currently linked to',
)
return workspace
@linear_integration_router.post('/events')
async def linear_events(
request: Request,
background_tasks: BackgroundTasks,
):
"""Handle Linear webhook events."""
# Check if Linear webhooks are enabled
if not LINEAR_WEBHOOKS_ENABLED:
logger.info(
'Linear webhooks are disabled by LINEAR_WEBHOOKS_ENABLED environment variable'
)
return JSONResponse(
status_code=200,
content={'message': 'Linear webhooks are currently disabled.'},
)
try:
signature_valid, signature, payload = await linear_manager.validate_request(
request
)
if not signature_valid:
logger.warning('[Linear] Invalid webhook signature')
raise HTTPException(status_code=403, detail='Invalid webhook signature!')
# Check for duplicate requests using Redis
key = f'linear:{signature}'
keyExists = redis_client.exists(key)
if keyExists:
logger.info(f'Received duplicate Linear webhook event: {signature}')
return JSONResponse({'success': True})
else:
redis_client.setex(key, 60, 1)
# Process the webhook
message_payload = {'payload': payload}
message = Message(source=SourceType.LINEAR, message=message_payload)
background_tasks.add_task(linear_manager.receive_message, message)
return JSONResponse({'success': True})
except HTTPException:
# Re-raise HTTP exceptions (like signature verification failures)
raise
except Exception as e:
logger.exception(f'Error processing Linear webhook: {e}')
return JSONResponse(
status_code=500,
content={'error': 'Internal server error processing webhook.'},
)
@linear_integration_router.post('/workspaces')
async def create_linear_workspace(
request: Request, workspace_data: LinearWorkspaceCreate
):
"""Create a new Linear workspace registration."""
try:
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_id = await user_auth.get_user_id()
user_email = await user_auth.get_user_email()
state = str(uuid.uuid4())
integration_session = {
'operation_type': 'workspace_integration',
'keycloak_user_id': user_id,
'user_email': user_email,
'target_workspace': workspace_data.workspace_name,
'webhook_secret': workspace_data.webhook_secret,
'svc_acc_email': workspace_data.svc_acc_email,
'svc_acc_api_key': workspace_data.svc_acc_api_key,
'is_active': workspace_data.is_active,
'state': state,
}
created = redis_client.setex(
state,
60,
json.dumps(integration_session),
)
if not created:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create integration session',
)
auth_params = {
'client_id': LINEAR_CLIENT_ID,
'redirect_uri': LINEAR_REDIRECT_URI,
'scope': LINEAR_SCOPES,
'state': state,
'response_type': 'code',
}
auth_url = f"{LINEAR_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
return JSONResponse(
content={
'success': True,
'redirect': True,
'authorizationUrl': auth_url,
}
)
except HTTPException:
raise
except Exception as e:
logger.exception(f'Error creating Linear workspace: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create workspace',
)
@linear_integration_router.post('/workspaces/link')
async def create_workspace_link(request: Request, link_data: LinearLinkCreate):
"""Register a user mapping to a Linear workspace."""
try:
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_id = await user_auth.get_user_id()
user_email = await user_auth.get_user_email()
state = str(uuid.uuid4())
integration_session = {
'operation_type': 'workspace_link',
'keycloak_user_id': user_id,
'user_email': user_email,
'target_workspace': link_data.workspace_name,
'state': state,
}
created = redis_client.setex(
state,
60,
json.dumps(integration_session),
)
if not created:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create integration session',
)
auth_params = {
'client_id': LINEAR_CLIENT_ID,
'redirect_uri': LINEAR_REDIRECT_URI,
'scope': LINEAR_SCOPES,
'state': state,
'response_type': 'code',
}
auth_url = f"{LINEAR_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
return JSONResponse(
content={
'success': True,
'redirect': True,
'authorizationUrl': auth_url,
}
)
except HTTPException:
raise
except Exception as e:
logger.exception(f'Error registering Linear user: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to register user',
)
@linear_integration_router.get('/callback')
async def linear_callback(request: Request, code: str, state: str):
integration_session_json = redis_client.get(state)
if not integration_session_json:
raise HTTPException(
status_code=400, detail='No active integration session found.'
)
integration_session = json.loads(integration_session_json)
# Security check: verify the state parameter
if integration_session.get('state') != state:
raise HTTPException(
status_code=400, detail='State mismatch. Possible CSRF attack.'
)
token_payload = {
'grant_type': 'authorization_code',
'client_id': LINEAR_CLIENT_ID,
'client_secret': LINEAR_CLIENT_SECRET,
'code': code,
'redirect_uri': LINEAR_REDIRECT_URI,
}
response = requests.post(LINEAR_TOKEN_URL, data=token_payload)
if response.status_code != 200:
raise HTTPException(
status_code=400, detail=f'Error fetching token: {response.text}'
)
token_data = response.json()
access_token = token_data['access_token']
# Query Linear API to get workspace information
headers = {'Authorization': f'Bearer {access_token}'}
graphql_query = {
'query': '{ viewer { id name email organization { id name urlKey } } }'
}
response = requests.post(LINEAR_GRAPHQL_URL, json=graphql_query, headers=headers)
if response.status_code != 200:
raise HTTPException(
status_code=400, detail=f'Error fetching workspace: {response.text}'
)
workspace_data = response.json()
workspace_info = (
workspace_data.get('data', {}).get('viewer', {}).get('organization', {})
)
workspace_name = workspace_info.get('urlKey', '').lower()
linear_org_id = workspace_info.get('id', '')
target_workspace = integration_session.get('target_workspace')
# Verify user has access to the target workspace
if workspace_name != target_workspace.lower():
raise HTTPException(
status_code=401,
detail=f'User is not authorized to access workspace: {target_workspace}',
)
user_id = integration_session['keycloak_user_id']
linear_user_id = workspace_data.get('data', {}).get('viewer', {}).get('id')
if integration_session.get('operation_type') == 'workspace_integration':
workspace = await linear_manager.integration_store.get_workspace_by_name(
target_workspace
)
if not workspace:
# Create new workspace if it doesn't exist
encrypted_webhook_secret = token_manager.encrypt_text(
integration_session['webhook_secret']
)
encrypted_svc_acc_api_key = token_manager.encrypt_text(
integration_session['svc_acc_api_key']
)
await linear_manager.integration_store.create_workspace(
name=target_workspace,
linear_org_id=linear_org_id,
admin_user_id=integration_session['keycloak_user_id'],
encrypted_webhook_secret=encrypted_webhook_secret,
svc_acc_email=integration_session['svc_acc_email'],
encrypted_svc_acc_api_key=encrypted_svc_acc_api_key,
status='active' if integration_session['is_active'] else 'inactive',
)
# Create a workspace link for the user (admin automatically gets linked)
await _handle_workspace_link_creation(
user_id, linear_user_id, target_workspace
)
else:
# Workspace exists - validate user can update it
await _validate_workspace_update_permissions(user_id, target_workspace)
encrypted_webhook_secret = token_manager.encrypt_text(
integration_session['webhook_secret']
)
encrypted_svc_acc_api_key = token_manager.encrypt_text(
integration_session['svc_acc_api_key']
)
# Update workspace details
await linear_manager.integration_store.update_workspace(
id=workspace.id,
linear_org_id=linear_org_id,
encrypted_webhook_secret=encrypted_webhook_secret,
svc_acc_email=integration_session['svc_acc_email'],
encrypted_svc_acc_api_key=encrypted_svc_acc_api_key,
status='active' if integration_session['is_active'] else 'inactive',
)
await _handle_workspace_link_creation(
user_id, linear_user_id, target_workspace
)
return RedirectResponse(
url='/settings/integrations',
status_code=status.HTTP_302_FOUND,
)
elif integration_session.get('operation_type') == 'workspace_link':
await _handle_workspace_link_creation(user_id, linear_user_id, target_workspace)
return RedirectResponse(
url='/settings/integrations', status_code=status.HTTP_302_FOUND
)
else:
raise HTTPException(status_code=400, detail='Invalid operation type')
@linear_integration_router.get(
'/workspaces/link',
response_model=LinearUserResponse,
)
async def get_current_workspace_link(request: Request):
"""Get current user's Linear integration details."""
try:
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_id = await user_auth.get_user_id()
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user = await linear_manager.integration_store.get_user_by_active_workspace(
user_id
)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User is not registered for Linear integration',
)
workspace = await linear_manager.integration_store.get_workspace_by_id(
user.linear_workspace_id
)
if not workspace:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Workspace not found for the user',
)
return LinearUserResponse(
id=user.id,
keycloak_user_id=user.keycloak_user_id,
linear_workspace_id=user.linear_workspace_id,
status=user.status,
created_at=user.created_at.isoformat(),
updated_at=user.updated_at.isoformat(),
workspace=LinearWorkspaceResponse(
id=workspace.id,
name=workspace.name,
linear_org_id=workspace.linear_org_id,
status=workspace.status,
editable=workspace.admin_user_id == user.keycloak_user_id,
created_at=workspace.created_at.isoformat(),
updated_at=workspace.updated_at.isoformat(),
),
)
except HTTPException:
raise
except Exception as e:
logger.exception(f'Error retrieving Linear user: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve user',
)
@linear_integration_router.post('/workspaces/unlink')
async def unlink_workspace(request: Request):
"""Unlink user from Linear integration by setting status to inactive."""
try:
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_id = await user_auth.get_user_id()
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user = await linear_manager.integration_store.get_user_by_active_workspace(
user_id
)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User is not registered for Linear integration',
)
workspace = await linear_manager.integration_store.get_workspace_by_id(
user.linear_workspace_id
)
if not workspace:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Workspace not found for the user',
)
if workspace.admin_user_id == user_id:
await linear_manager.integration_store.deactivate_workspace(
workspace_id=workspace.id,
)
else:
await linear_manager.integration_store.update_user_integration_status(
user_id, 'inactive'
)
return JSONResponse({'success': True})
except HTTPException:
raise
except Exception as e:
logger.exception(f'Error unlinking Linear user: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to unlink user',
)
@linear_integration_router.get(
'/workspaces/validate/{workspace_name}',
response_model=LinearValidateWorkspaceResponse,
)
async def validate_workspace_integration(request: Request, workspace_name: str):
"""Validate if the workspace has an active Linear integration."""
try:
# Validate workspace_name format
if not re.match(r'^[a-zA-Z0-9_.-]+$', workspace_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='workspace_name can only contain alphanumeric characters, hyphens, underscores, and periods',
)
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_email = await user_auth.get_user_email()
if not user_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unable to retrieve user email',
)
# Check if workspace exists
workspace = await linear_manager.integration_store.get_workspace_by_name(
workspace_name
)
if not workspace:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Workspace with name '{workspace_name}' not found",
)
# Check if workspace is active
if workspace.status.lower() != 'active':
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Workspace '{workspace.name}' is not active",
)
return LinearValidateWorkspaceResponse(
name=workspace.name,
status=workspace.status,
message='Workspace integration is active',
)
except HTTPException:
raise
except Exception as e:
logger.exception(f'Error validating Linear workspace: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to validate workspace',
)

View File

@@ -180,6 +180,18 @@ async def device_token(device_code: str = Form(...)):
)
if device_code_entry.status == 'authorized':
# Verify user_id is set (should always be true for authorized status)
if not device_code_entry.keycloak_user_id:
logger.error(
'Authorized device code missing user_id',
extra={'user_code': device_code_entry.user_code},
)
return _oauth_error(
status.HTTP_500_INTERNAL_SERVER_ERROR,
'server_error',
'User identification missing',
)
# Retrieve the specific API key for this device using the user_code
api_key_store = ApiKeyStore.get_instance()
device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})'

View File

@@ -1,4 +1,4 @@
from typing import Annotated
from typing import Annotated, Any
from pydantic import (
BaseModel,
@@ -7,11 +7,16 @@ from pydantic import (
SecretStr,
StringConstraints,
field_validator,
model_validator,
)
from server.constants import LITE_LLM_API_URL
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
from openhands.sdk.settings import AgentSettings, ConversationSettings
from openhands.utils.llm import MASKED_API_KEY, resolve_llm_base_url
class OrgCreationError(Exception):
"""Base exception for organization creation errors."""
@@ -144,21 +149,16 @@ class OrgResponse(BaseModel):
contact_name: str
contact_email: str
conversation_expiration: int | None = None
agent: str | None = None
default_max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
enable_default_condenser: bool = True
billing_margin: float | None = None
enable_proactive_conversation_starters: bool = True
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
org_version: int = 0
mcp_config: dict | None = None
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
conversation_settings: ConversationSettings = Field(
default_factory=ConversationSettings
)
search_api_key: str | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = None
@@ -171,33 +171,14 @@ class OrgResponse(BaseModel):
def from_org(
cls, org: Org, credits: float | None = None, user_id: str | None = None
) -> 'OrgResponse':
"""Create an OrgResponse from an Org entity.
Args:
org: The organization entity to convert
credits: Optional credits value (defaults to None)
user_id: Optional user ID to determine if org is personal (defaults to None)
Returns:
OrgResponse: The response model instance
"""
"""Create an OrgResponse from an Org entity."""
return cls(
id=str(org.id),
name=org.name,
contact_name=org.contact_name,
contact_email=org.contact_email,
conversation_expiration=org.conversation_expiration,
agent=org.agent,
default_max_iterations=org.default_max_iterations,
security_analyzer=org.security_analyzer,
confirmation_mode=org.confirmation_mode,
default_llm_model=org.default_llm_model,
default_llm_api_key_for_byor=None,
default_llm_base_url=org.default_llm_base_url,
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
billing_margin=org.billing_margin,
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
if org.enable_proactive_conversation_starters is not None
@@ -205,7 +186,12 @@ class OrgResponse(BaseModel):
sandbox_base_container_image=org.sandbox_base_container_image,
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
org_version=org.org_version if org.org_version is not None else 0,
mcp_config=org.mcp_config,
agent_settings=AgentSettings.model_validate(
dict(org.agent_settings) if org.agent_settings else {}
),
conversation_settings=ConversationSettings.model_validate(
dict(org.conversation_settings) if org.conversation_settings else {}
),
search_api_key=None,
sandbox_api_key=None,
max_budget_per_task=org.max_budget_per_task,
@@ -225,9 +211,13 @@ class OrgPage(BaseModel):
class OrgUpdate(BaseModel):
"""Request model for updating an organization."""
"""Request model for updating an organization.
``agent_settings_diff`` and ``conversation_settings_diff`` are sparse diffs
that are deep-merged into the org row and then validated as full settings
before persistence.
"""
# Basic organization information (any authenticated user can update)
name: Annotated[
str | None,
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
@@ -235,7 +225,6 @@ class OrgUpdate(BaseModel):
contact_name: str | None = None
contact_email: EmailStr | None = None
conversation_expiration: int | None = None
default_max_iterations: int | None = Field(default=None, gt=0)
remote_runtime_resource_factor: int | None = Field(default=None, gt=0)
billing_margin: float | None = Field(default=None, ge=0, le=1)
enable_proactive_conversation_starters: bool | None = None
@@ -245,31 +234,152 @@ class OrgUpdate(BaseModel):
max_budget_per_task: float | None = Field(default=None, gt=0)
enable_solvability_analysis: bool | None = None
v1_enabled: bool | None = None
# LLM settings (require admin/owner role)
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
search_api_key: str | None = None
security_analyzer: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
llm_api_key: str | None = None
agent_settings_diff: dict[str, Any] | None = None
conversation_settings_diff: dict[str, Any] | None = None
@model_validator(mode='after')
def _normalize_settings_diffs(self) -> 'OrgUpdate':
"""Normalize sparse settings diffs before merge/persistence."""
self._normalize_agent_settings_diff()
self._cleanup_empty_diff('agent_settings_diff', nested_key='llm')
self._cleanup_empty_diff('conversation_settings_diff')
return self
def _normalize_agent_settings_diff(self) -> None:
"""Normalize nested LLM settings inside ``agent_settings_diff``."""
llm_diff = self._get_agent_llm_diff()
if llm_diff is None:
return
self._lift_and_mask_llm_api_key(llm_diff)
self._resolve_agent_llm_base_url(llm_diff)
def _get_agent_llm_diff(self) -> dict[str, Any] | None:
"""Return the nested ``llm`` diff when present and dictionary-shaped."""
if self.agent_settings_diff is None:
return None
llm_diff = self.agent_settings_diff.get('llm')
return llm_diff if isinstance(llm_diff, dict) else None
def _lift_and_mask_llm_api_key(self, llm_diff: dict[str, Any]) -> None:
"""Lift nested api keys to ``llm_api_key`` and mask the JSON diff."""
if 'api_key' not in llm_diff:
return
nested_key = llm_diff.pop('api_key')
if (
self.llm_api_key is None
and nested_key is not None
and nested_key != MASKED_API_KEY
):
self.llm_api_key = nested_key
if nested_key is not None:
llm_diff['api_key'] = MASKED_API_KEY
def _resolve_agent_llm_base_url(self, llm_diff: dict[str, Any]) -> None:
"""Fill provider-default base URLs for sparse LLM diffs when needed."""
resolved_base_url = resolve_llm_base_url(
model=llm_diff.get('model'),
base_url=llm_diff.get('base_url'),
managed_proxy_url=LITE_LLM_API_URL,
)
if resolved_base_url is not None:
llm_diff['base_url'] = resolved_base_url
def _cleanup_empty_diff(
self,
field_name: str,
nested_key: str | None = None,
) -> None:
"""Drop empty nested diffs and collapse empty diff payloads to ``None``."""
settings_diff = getattr(self, field_name)
if not isinstance(settings_diff, dict):
if not settings_diff:
setattr(self, field_name, None)
return
if nested_key is not None and not settings_diff.get(nested_key):
settings_diff.pop(nested_key, None)
if not settings_diff:
setattr(self, field_name, None)
def updated_fields(self) -> set[str]:
"""Return the public field names explicitly present on the update."""
return {
field
for field in type(self).model_fields
if getattr(self, field) is not None
}
def has_updates(self) -> bool:
"""Check if any public update field is set (not None)."""
return bool(self.updated_fields())
def touches_org_defaults(self) -> bool:
"""Whether this update touches shared organization defaults."""
return bool(
self.updated_fields()
& {
'agent_settings_diff',
'conversation_settings_diff',
'search_api_key',
'llm_api_key',
}
)
def restricted_fields(self) -> set[str]:
"""Return fields that require elevated org settings permissions."""
return self.updated_fields() & {
'agent_settings_diff',
'conversation_settings_diff',
'search_api_key',
'sandbox_api_key',
'llm_api_key',
}
def model_update_dict(self) -> dict[str, Any]:
"""Return JSON-serializable scalar fields for persistence."""
return self.model_dump(
mode='json',
exclude_none=True,
exclude={'agent_settings_diff', 'conversation_settings_diff'},
)
def apply_to_org(self, org: Org) -> None:
"""Apply non-settings fields directly to the organization model."""
for key, value in self.model_update_dict().items():
if hasattr(org, key):
setattr(org, key, value)
def get_member_updates(self) -> 'OrgMemberSettingsUpdate | None':
"""Get shared updates that need to be propagated to org members.
An empty ``llm_api_key`` means the org-wide custom key is being cleared
(e.g. owner switching to a managed/OpenHands provider). It must not
land in member rows — ``OrgMember.llm_api_key``'s setter has no
``if raw else None`` guard because the column is ``nullable=False``,
so an empty string would become an encrypted empty blob rather than a
cleared value. Coerce ``""`` to ``None`` so member rows are untouched.
"""
member_settings = OrgMemberSettingsUpdate(
agent_settings_diff=self.agent_settings_diff,
conversation_settings_diff=self.conversation_settings_diff,
llm_api_key=self.llm_api_key or None,
)
return member_settings if member_settings.has_updates() else None
class OrgLLMSettingsResponse(BaseModel):
"""Response model for organization LLM settings."""
class OrgDefaultsSettingsResponse(BaseModel):
"""Response model for organization default settings."""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
conversation_settings: ConversationSettings = Field(
default_factory=ConversationSettings
)
llm_api_key_set: bool = False
search_api_key: str | None = None # Masked in response
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool = True
condenser_max_size: int | None = None
default_max_iterations: int | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str | None:
@@ -284,87 +394,84 @@ class OrgLLMSettingsResponse(BaseModel):
return '****' + raw[-4:]
@classmethod
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
"""Create response from Org entity."""
def from_org(cls, org: Org) -> 'OrgDefaultsSettingsResponse':
"""Create response from Org entity.
Denormalizes the SDK's ``litellm_proxy/`` prefix back to
``openhands/`` so the frontend's basic-view provider/model dropdowns
can be populated, and nulls ``api_key`` so neither the raw secret
nor the ``MASKED_API_KEY`` marker leaks in the response.
``base_url`` is returned exactly as stored so ``org.agent_settings``,
``org_member.agent_settings_diff`` and this response always carry
the same value.
"""
agent_settings = AgentSettings.model_validate(
dict(org.agent_settings) if org.agent_settings else {}
)
cls._denormalize_llm_for_response(agent_settings)
return cls(
default_llm_model=org.default_llm_model,
default_llm_base_url=org.default_llm_base_url,
agent_settings=agent_settings,
conversation_settings=ConversationSettings.model_validate(
dict(org.conversation_settings) if org.conversation_settings else {}
),
llm_api_key_set=org.llm_api_key is not None,
search_api_key=cls._mask_key(org.search_api_key),
agent=org.agent,
confirmation_mode=org.confirmation_mode,
security_analyzer=org.security_analyzer,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
condenser_max_size=org.condenser_max_size,
default_max_iterations=org.default_max_iterations,
)
@staticmethod
def _denormalize_llm_for_response(agent_settings: AgentSettings) -> None:
"""Rewrite ``agent_settings.llm`` in-place for UI consumption.
class OrgMemberLLMSettings(BaseModel):
"""LLM settings to propagate to organization members.
* ``litellm_proxy/X`` → ``openhands/X`` so the basic-view provider
dropdown matches (the SDK's ``AgentSettings`` validator
normalizes the other direction on load).
* ``base_url`` is returned **as stored** so the three sync targets
(``org.agent_settings.llm.base_url``,
``org_member.agent_settings_diff.llm.base_url``, and the GET
response) always agree. The frontend is responsible for
recognizing the managed LiteLLM proxy URL / provider-default URL
as "basic mode" — see ``KNOWN_PROVIDER_DEFAULT_BASE_URLS`` in
``frontend/src/routes/llm-settings.tsx``.
* ``api_key`` is nulled so neither the raw secret nor the
``MASKED_API_KEY`` marker leaks in the response — the frontend
reads ``llm_api_key_set`` to know whether a key exists.
Field names match OrgMember DB columns.
Pydantic v2 field assignment bypasses ``field_validator`` /
``model_validator`` by default (``validate_assignment`` is off on
the SDK's ``LLM`` model), so the rename survives without being
re-normalized back to ``litellm_proxy/``.
"""
llm = agent_settings.llm
if llm.model and llm.model.startswith('litellm_proxy/'):
llm.model = f'openhands/{llm.model.removeprefix("litellm_proxy/")}'
llm.api_key = None
class OrgMemberSettingsUpdate(BaseModel):
"""Shared settings updates that may be propagated to organization members.
``llm_api_key`` is typed as ``SecretStr`` so the raw value never ends up
in logs or ``model_dump(mode='json')`` output by accident — the
column-backed ``OrgMember.llm_api_key`` setter accepts ``SecretStr``
directly and unwraps via ``get_secret_value()``.
``has_custom_llm_api_key`` propagates through
``update_all_members_settings_async`` so an org-defaults save can
reset every member's "I have a personal BYOR key" flag in one pass —
managed-mode switches rely on this to stop load-time fallthrough from
returning stale custom markers.
"""
llm_model: str | None = None
llm_base_url: str | None = None
max_iterations: int | None = None
llm_api_key: str | None = None
agent_settings_diff: dict[str, Any] | None = None
conversation_settings_diff: dict[str, Any] | None = None
llm_api_key: SecretStr | None = None
has_custom_llm_api_key: bool | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
class OrgLLMSettingsUpdate(BaseModel):
"""Request model for updating organization LLM settings.
Field names match Org DB columns exactly.
"""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
search_api_key: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
default_max_iterations: int | None = Field(default=None, gt=0)
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
def apply_to_org(self, org: Org) -> None:
"""Apply non-None settings to the organization model.
Args:
org: Organization entity to update in place
"""
for field_name in self.model_fields:
value = getattr(self, field_name)
# Skip llm_api_key - it's only for member propagation, not org-level
if value is not None and field_name != 'llm_api_key':
setattr(org, field_name, value)
def get_member_updates(self) -> OrgMemberLLMSettings | None:
"""Get updates that need to be propagated to org members.
Returns:
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
default_max_iterations → max_iterations, llm_api_key → llm_api_key
"""
member_settings = OrgMemberLLMSettings(
llm_model=self.default_llm_model,
llm_base_url=self.default_llm_base_url,
max_iterations=self.default_max_iterations,
llm_api_key=self.llm_api_key,
return any(
getattr(self, field) is not None for field in type(self).model_fields
)
return member_settings if member_settings.has_updates() else None
class OrgMemberResponse(BaseModel):
@@ -393,25 +500,28 @@ class OrgMemberUpdate(BaseModel):
class MeResponse(BaseModel):
"""Response model for the current user's membership in an organization."""
"""Response model for the current user's membership in an organization.
``agent_settings_diff`` and ``conversation_settings_diff`` carry the
member-level overrides on top of the organization defaults.
"""
org_id: str
user_id: str
email: str
role: str
llm_api_key: str
max_iterations: int | None = None
llm_model: str | None = None
llm_api_key_for_byor: str | None = None
llm_base_url: str | None = None
agent_settings_diff: dict[str, Any] = Field(default_factory=dict)
conversation_settings_diff: dict[str, Any] = Field(default_factory=dict)
status: str | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str:
def _mask_key(secret: str | SecretStr | None) -> str:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return ''
raw = secret.get_secret_value()
raw = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
if not raw:
return ''
if len(raw) <= 4:
@@ -419,27 +529,22 @@ class MeResponse(BaseModel):
return '****' + raw[-4:]
@classmethod
def from_org_member(cls, member: OrgMember, role: Role, email: str) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email.
Args:
member: The OrgMember entity
role: The Role entity (provides role name)
email: The user's email address
Returns:
MeResponse with masked API keys
"""
def from_org_member(
cls,
member: OrgMember,
role: Role,
email: str,
) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email."""
return cls(
org_id=str(member.org_id),
user_id=str(member.user_id),
email=email,
role=role.name,
llm_api_key=cls._mask_key(member.llm_api_key),
max_iterations=member.max_iterations,
llm_model=member.llm_model,
llm_api_key_for_byor=cls._mask_key(member.llm_api_key_for_byor) or None,
llm_base_url=member.llm_base_url,
agent_settings_diff=dict(member.agent_settings_diff or {}),
conversation_settings_diff=dict(member.conversation_settings_diff or {}),
status=member.status,
)

View File

@@ -24,8 +24,7 @@ from server.routes.org_models import (
OrgAuthorizationError,
OrgCreate,
OrgDatabaseError,
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgDefaultsSettingsResponse,
OrgMemberFinancialPage,
OrgMemberNotFoundError,
OrgMemberPage,
@@ -43,15 +42,12 @@ from server.services.org_app_settings_service import (
OrgAppSettingsService,
OrgAppSettingsServiceInjector,
)
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.org_store import OrgStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
@@ -60,9 +56,6 @@ from openhands.server.user_auth import get_user_id
# Initialize API router
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
# Create injector instance and dependency for LLM settings
_org_llm_settings_injector = OrgLLMSettingsServiceInjector()
org_llm_settings_service_dependency = Depends(_org_llm_settings_injector.depends)
# Create injector instance and dependency at module level
_org_app_settings_injector = OrgAppSettingsServiceInjector()
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
@@ -228,34 +221,15 @@ async def create_org(
)
@org_router.get(
'/llm',
response_model=OrgLLMSettingsResponse,
dependencies=[Depends(require_permission(Permission.VIEW_LLM_SETTINGS))],
)
async def get_org_llm_settings(
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
) -> OrgLLMSettingsResponse:
"""Get LLM settings for the user's current organization.
This endpoint retrieves the LLM configuration settings for the
authenticated user's current organization. All organization members
can view these settings.
Args:
service: OrgLLMSettingsService (injected by dependency)
Returns:
OrgLLMSettingsResponse: The organization's LLM settings
Raises:
HTTPException: 401 if not authenticated
HTTPException: 403 if not a member of any organization
HTTPException: 404 if current organization not found
HTTPException: 500 if retrieval fails
"""
@org_router.get('/{org_id}/settings', response_model=OrgDefaultsSettingsResponse)
async def get_org_defaults_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
) -> OrgDefaultsSettingsResponse:
"""Get org-default settings for a specific organization."""
try:
return await service.get_org_llm_settings()
org = await OrgService.get_org_by_id(org_id=org_id, user_id=user_id)
return OrgDefaultsSettingsResponse.from_org(org)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -263,45 +237,45 @@ async def get_org_llm_settings(
)
except Exception as e:
logger.exception(
'Error getting organization LLM settings',
extra={'error': str(e)},
'Error getting organization defaults settings',
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 retrieve LLM settings',
detail='Failed to retrieve organization defaults settings',
)
@org_router.post(
'/llm',
response_model=OrgLLMSettingsResponse,
dependencies=[Depends(require_permission(Permission.EDIT_LLM_SETTINGS))],
)
async def update_org_llm_settings(
settings: OrgLLMSettingsUpdate,
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
) -> OrgLLMSettingsResponse:
"""Update LLM settings for the user's current organization.
This endpoint updates the LLM configuration settings for the
authenticated user's current organization. Only admins and owners
can update these settings.
Args:
settings: The LLM settings to update (only non-None fields are updated)
service: OrgLLMSettingsService (injected by dependency)
Returns:
OrgLLMSettingsResponse: The updated organization's LLM settings
Raises:
HTTPException: 401 if not authenticated
HTTPException: 403 if user lacks EDIT_LLM_SETTINGS permission
HTTPException: 404 if current organization not found
HTTPException: 500 if update fails
"""
@org_router.patch('/{org_id}/settings', response_model=OrgDefaultsSettingsResponse)
async def update_org_defaults_settings(
org_id: UUID,
settings: OrgUpdate,
user_id: str = Depends(require_permission(Permission.EDIT_ORG_SETTINGS)),
) -> OrgDefaultsSettingsResponse:
"""Update org-default settings for a specific organization."""
try:
return await service.update_org_llm_settings(settings)
allowed_fields = {
'agent_settings_diff',
'conversation_settings_diff',
'search_api_key',
'llm_api_key',
}
invalid_fields = settings.updated_fields() - allowed_fields
if invalid_fields:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
'Only organization default settings fields are supported on '
'/api/organizations/{org_id}/settings'
),
)
updated_org = await OrgService.update_org_with_permissions(
org_id=org_id,
update_data=settings,
user_id=user_id,
)
return OrgDefaultsSettingsResponse.from_org(updated_org)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -309,21 +283,94 @@ async def update_org_llm_settings(
)
except OrgDatabaseError as e:
logger.error(
'Database error updating LLM settings',
extra={'error': str(e)},
'Database error updating organization defaults settings',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update LLM settings',
detail='Failed to update organization defaults settings',
)
except HTTPException:
raise
except Exception as e:
logger.exception(
'Error updating organization LLM settings',
extra={'error': str(e)},
'Error updating organization defaults settings',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update LLM settings',
detail='Failed to update organization defaults settings',
)
@org_router.get(
'/llm',
response_model=OrgDefaultsSettingsResponse,
deprecated=True,
)
async def get_legacy_org_defaults_settings(
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
) -> OrgDefaultsSettingsResponse:
"""Get org-default settings through the deprecated ``/llm`` wrapper."""
try:
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
if not org:
raise OrgNotFoundError('current')
return await get_org_defaults_settings(org_id=org.id, user_id=user_id)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except HTTPException:
raise
except Exception as e:
logger.exception(
'Error getting legacy organization defaults settings',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve organization defaults settings',
)
@org_router.post(
'/llm',
response_model=OrgDefaultsSettingsResponse,
deprecated=True,
)
async def update_legacy_org_defaults_settings(
settings: OrgUpdate,
user_id: str = Depends(require_permission(Permission.EDIT_LLM_SETTINGS)),
) -> OrgDefaultsSettingsResponse:
"""Update org-default settings through the deprecated ``/llm`` wrapper."""
try:
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
if not org:
raise OrgNotFoundError('current')
if not settings.has_updates():
return OrgDefaultsSettingsResponse.from_org(org)
return await update_org_defaults_settings(
org_id=org.id,
settings=settings,
user_id=user_id,
)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except HTTPException:
raise
except Exception as e:
logger.exception(
'Error updating legacy organization defaults settings',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update organization defaults settings',
)
@@ -417,31 +464,17 @@ async def update_org_app_settings(
)
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
@org_router.get(
'/{org_id}',
response_model=OrgResponse,
status_code=status.HTTP_200_OK,
deprecated=True,
)
async def get_org(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
) -> OrgResponse:
"""Get organization details by ID.
This endpoint retrieves details for a specific organization. Access requires
the VIEW_ORG_SETTINGS permission, which is granted to all organization members
(member, admin, and owner roles).
Args:
org_id: Organization ID (UUID)
user_id: Authenticated user ID (injected by require_permission dependency)
Returns:
OrgResponse: The organization details
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks VIEW_ORG_SETTINGS permission
HTTPException: 404 if organization not found
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
HTTPException: 500 if retrieval fails
"""
"""Get organization details by ID through the deprecated detail route."""
logger.info(
'Retrieving organization details',
extra={
@@ -451,15 +484,11 @@ async def get_org(
)
try:
# Use service layer to get organization with membership validation
org = await OrgService.get_org_by_id(
org_id=org_id,
user_id=user_id,
)
# Retrieve credits from LiteLLM
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNotFoundError as e:
raise HTTPException(

View File

@@ -1,441 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
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,
PaginatedBranchesResponse,
ProviderType,
Repository,
SuggestedTask,
User,
)
from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.server.routes.git import (
get_repository_branches,
get_repository_microagent_content,
get_repository_microagents,
get_suggested_tasks,
get_user,
get_user_installations,
get_user_repositories,
search_branches,
search_repositories,
)
from openhands.server.user_auth import (
get_access_token,
get_provider_tokens,
get_user_id,
)
saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies())
token_manager = TokenManager()
@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),
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
return await get_user_installations(
provider=provider,
provider_tokens=provider_tokens,
access_token=access_token,
user_id=user_id,
)
@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,
page: int | None = None,
per_page: int | None = None,
installation_id: str | None = None,
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),
) -> list[Repository] | JSONResponse:
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value=[],
)
if retval is not None:
return retval
return await get_user_repositories(
sort=sort,
selected_provider=selected_provider,
page=page,
per_page=per_page,
installation_id=installation_id,
provider_tokens=provider_tokens,
access_token=access_token,
user_id=user_id,
)
@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(
content='User is not authenticated.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
user_info = await token_manager.get_user_info(access_token.get_secret_value())
# Prefer email from DB; fall back to Keycloak if not yet persisted
email = user_info.email
sub = user_info.sub
if sub:
db_user = await UserStore.get_user_by_id(sub)
if db_user and db_user.email is not None:
email = db_user.email
user_info_dict = user_info.model_dump(exclude_none=True)
retval = await _check_idp(
access_token=access_token,
default_value=User(
id=sub,
login=user_info.preferred_username or '',
avatar_url='',
email=email,
name=resolve_display_name(user_info_dict),
company=user_info.company,
),
user_info=user_info_dict,
)
if retval is not None:
return retval
return await get_user(
provider_tokens=provider_tokens, access_token=access_token, user_id=user_id
)
@saas_user_router.get('/search/repositories', response_model=list[Repository])
async def saas_search_repositories(
query: str,
per_page: int = 5,
sort: str = 'stars',
order: str = 'desc',
selected_provider: ProviderType | None = None,
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),
) -> list[Repository] | JSONResponse:
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value=[],
)
if retval is not None:
return retval
return await search_repositories(
query=query,
per_page=per_page,
sort=sort,
order=order,
selected_provider=selected_provider,
provider_tokens=provider_tokens,
access_token=access_token,
user_id=user_id,
)
@saas_user_router.get('/suggested-tasks', response_model=list[SuggestedTask])
async def saas_get_suggested_tasks(
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),
) -> list[SuggestedTask] | JSONResponse:
"""Get suggested tasks for the authenticated user across their most recently pushed repositories.
Returns:
- PRs owned by the user
- Issues assigned to the user.
"""
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value=[],
)
if retval is not None:
return retval
return await get_suggested_tasks(
provider_tokens=provider_tokens, access_token=access_token, user_id=user_id
)
@saas_user_router.get('/repository/branches', response_model=PaginatedBranchesResponse)
async def saas_get_repository_branches(
repository: str,
page: int = 1,
per_page: int = 30,
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),
) -> PaginatedBranchesResponse | JSONResponse:
"""Get branches for a repository.
Args:
repository: The repository name in the format 'owner/repo'
Returns:
A list of branches for the repository
"""
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value=[],
)
if retval is not None:
return retval
return await get_repository_branches(
repository=repository,
page=page,
per_page=per_page,
provider_tokens=provider_tokens,
access_token=access_token,
user_id=user_id,
)
@saas_user_router.get('/search/branches', response_model=list[Branch])
async def saas_search_branches(
repository: str,
query: str,
per_page: int = 30,
selected_provider: ProviderType | None = None,
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),
) -> list[Branch] | JSONResponse:
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value=[],
)
if retval is not None:
return retval
return await search_branches(
repository=repository,
query=query,
per_page=per_page,
selected_provider=selected_provider,
provider_tokens=provider_tokens,
access_token=access_token,
user_id=user_id,
)
@saas_user_router.get(
'/repository/{repository_name:path}/microagents',
response_model=list[MicroagentResponse],
)
async def saas_get_repository_microagents(
repository_name: str,
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),
) -> list[MicroagentResponse] | JSONResponse:
"""Scan the microagents directory of a repository and return the list of microagents.
The microagents directory location depends on the git provider and actual repository name:
- If git provider is not GitLab and actual repository name is ".openhands": scans "microagents" folder
- If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder
- Otherwise: scans ".openhands/microagents" folder
Note: This API returns microagent metadata without content for performance.
Use the separate content API to fetch individual microagent content.
Args:
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
provider_tokens: Provider tokens for authentication
access_token: Access token for external authentication
user_id: User ID for authentication
Returns:
List of microagents found in the repository's microagents directory (without content)
"""
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value=[],
)
if retval is not None:
return retval
return await get_repository_microagents(
repository_name=repository_name,
provider_tokens=provider_tokens,
access_token=access_token,
user_id=user_id,
)
@saas_user_router.get(
'/repository/{repository_name:path}/microagents/content',
response_model=MicroagentContentResponse,
)
async def saas_get_repository_microagent_content(
repository_name: str,
file_path: str = Query(
..., description='Path to the microagent file within the repository'
),
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),
) -> MicroagentContentResponse | JSONResponse:
"""Fetch the content of a specific microagent file from a repository.
Args:
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
file_path: Query parameter - Path to the microagent file within the repository
provider_tokens: Provider tokens for authentication
access_token: Access token for external authentication
user_id: User ID for authentication
Returns:
Microagent file content and metadata
Example:
GET /api/user/repository/owner/repo/microagents/content?file_path=.openhands/microagents/my-agent.md
"""
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value=MicroagentContentResponse(content='', path=''),
)
if retval is not None:
return retval
return await get_repository_microagent_content(
repository_name=repository_name,
file_path=file_path,
provider_tokens=provider_tokens,
access_token=access_token,
user_id=user_id,
)
async def _check_idp(
access_token: SecretStr | None,
default_value: Any,
user_info: dict | None = None,
):
if not access_token:
return JSONResponse(
content='User is not authenticated.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
if user_info is None:
user_info_model = await token_manager.get_user_info(
access_token.get_secret_value()
)
user_info = user_info_model.model_dump(exclude_none=True)
idp: str | None = user_info.get('identity_provider')
if not idp:
return JSONResponse(
content='IDP not found.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
if ':' in idp:
idp, _ = idp.rsplit(':', 1)
# Will return empty dict if IDP doesn't support provider tokens
if not await token_manager.get_idp_tokens_from_keycloak(
access_token.get_secret_value(), ProviderType(idp)
):
return default_value
return None

View File

@@ -5,17 +5,24 @@ user endpoints with organization context (org_id, org_name, role, permissions).
"""
import logging
from types import MappingProxyType
from typing import Any, cast
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 server.models.user_models import GitOrganizationsResponse, SaasUserInfo
from openhands.app_server.config import depends_user_context
from openhands.app_server.config import (
depends_user_context,
resolve_provider_llm_base_url,
)
from openhands.app_server.sandbox.session_auth import validate_session_key_ownership
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import ProviderType
_logger = logging.getLogger(__name__)
@@ -25,6 +32,30 @@ saas_users_v1_router = APIRouter(
user_dependency = depends_user_context()
def _inject_sdk_compat_fields(
content: dict[str, Any], *, include_api_key: bool
) -> None:
"""Inject flat top-level convenience fields for the SDK.
The SDK's ``get_llm()`` and ``get_mcp_config()`` read ``llm_model``,
``llm_api_key``, ``llm_base_url``, and ``mcp_config`` from the top
level of the ``/api/v1/users/me`` response. These values live inside
the nested ``agent_settings`` structure, so we mirror them at the top
level for backward compatibility.
The canonical representation is ``agent_settings``; these flat fields
exist solely for SDK backward compatibility.
"""
agent_settings = content.get('agent_settings') or {}
llm = agent_settings.get('llm') or {}
model = llm.get('model')
content['llm_model'] = model
content['llm_base_url'] = resolve_provider_llm_base_url(model, llm.get('base_url'))
if include_api_key:
content['llm_api_key'] = llm.get('api_key')
content['mcp_config'] = agent_settings.get('mcp_config')
@saas_users_v1_router.get('/me')
async def get_current_user_saas(
user_context: UserContext = user_dependency,
@@ -63,10 +94,52 @@ async def get_current_user_saas(
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})
content = user_info.model_dump(mode='json', context={'expose_secrets': True})
_inject_sdk_compat_fields(content, include_api_key=True)
return JSONResponse(content=content) # type: ignore[return-value]
content = user_info.model_dump(mode='json')
_inject_sdk_compat_fields(content, include_api_key=False)
return JSONResponse(content=content) # type: ignore[return-value]
@saas_users_v1_router.get('/git-organizations')
async def get_current_user_git_organizations(
user_context: UserContext = user_dependency,
) -> GitOrganizationsResponse:
"""Return the Git organizations, groups, or workspaces the user belongs to
on their active provider.
In SAAS mode users sign in with one provider at a time, so the response
reflects that single provider.
"""
provider_tokens = await user_context.get_provider_tokens()
if not provider_tokens:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Git provider token required.',
)
return user_info
user_id = await user_context.get_user_id()
client = ProviderHandler(
provider_tokens=MappingProxyType(provider_tokens), # type: ignore[arg-type]
external_auth_id=user_id,
)
provider = cast(ProviderType, 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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Provider {provider.value} doesn't support git organizations",
)
return GitOrganizationsResponse(provider=provider, organizations=orgs)
async def _get_org_info_from_context(user_context: UserContext) -> dict | None:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,517 @@
"""
Service for forwarding Git provider webhook events to the automation service.
This service is optimized for high-traffic scenarios:
1. Resolves Git org → OpenHands org_id (via cached OrgGitClaim lookup)
2. For personal repos, resolves to personal org (via cached provider→Keycloak mapping)
3. Forwards minimal payload to automation service (just org_id + payload)
4. Access control checks are deferred to automation execution time
Supports multiple Git providers (GitHub, GitLab, Bitbucket, etc.).
The lazy access control approach means:
- Most webhooks only do cached lookups + HTTP forward
- Membership checks only happen when an automation actually matches
Security notes:
- Uses AUTOMATION_WEBHOOK_SECRET (not provider webhook secret) for signing
- Negative results are cached to prevent DoS via repeated lookups
"""
import asyncio
import hashlib
import hmac
import json
from dataclasses import dataclass
from typing import Any
from uuid import UUID
import aiohttp
from integrations.resolver_org_router import resolve_org_for_repo
from server.auth.constants import (
AUTOMATION_SERVICE_TIMEOUT,
AUTOMATION_SERVICE_URL,
AUTOMATION_WEBHOOK_SECRET,
)
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderType
from openhands.server.shared import sio
# Cache TTL constants
ORG_CLAIM_CACHE_TTL_SECONDS = 3600 # 1 hour for org claims (rarely change)
USER_ID_CACHE_TTL_SECONDS = 86400 # 24 hours for user ID mappings (never change)
# Cache key prefixes (provider is appended dynamically)
ORG_CLAIM_CACHE_PREFIX = 'automation:org_claim'
USER_ID_CACHE_PREFIX = 'automation:idp_to_kc_user'
@dataclass
class OrgContext:
"""Context for the resolved organization."""
org_id: UUID
git_org: str
class AutomationEventService:
"""
Service for forwarding webhook events to the automation service.
Optimized for high traffic with:
- Redis caching for org claim lookups (1 hour TTL)
- Redis caching for provider→Keycloak user ID mappings (24 hour TTL)
- Lazy access control (membership checks deferred to execution time)
Supports multiple Git providers (GitHub, GitLab, Bitbucket, etc.).
"""
def __init__(self, token_manager: TokenManager):
from server.auth.constants import AUTOMATION_EVENT_FORWARDING_ENABLED
self.token_manager = token_manager
# Fail fast if forwarding is enabled but misconfigured
if AUTOMATION_EVENT_FORWARDING_ENABLED:
if not AUTOMATION_SERVICE_URL:
raise ValueError(
'AUTOMATION_EVENT_FORWARDING_ENABLED=true but '
'AUTOMATION_SERVICE_URL is not configured'
)
if not AUTOMATION_WEBHOOK_SECRET:
raise ValueError(
'AUTOMATION_EVENT_FORWARDING_ENABLED=true but '
'AUTOMATION_WEBHOOK_SECRET is not configured'
)
async def forward_event(
self,
provider: ProviderType,
payload: dict[str, Any],
installation_id: int | str,
) -> None:
"""
Forward a Git provider webhook event to the automation service.
This is designed to be called as a fire-and-forget background task.
The forward path is optimized for speed - only org resolution is done here.
Access control checks are deferred to automation execution time.
Args:
provider: The Git provider type (e.g., GITHUB, GITLAB, BITBUCKET)
payload: The raw webhook payload from the provider
installation_id: The provider's installation/webhook ID
"""
org_id: UUID | None = None
try:
# Resolve org context (org_id and git_org name) - uses Redis cache
org_context = await self._resolve_org_context(provider, payload)
if not org_context:
return
org_id = org_context.org_id
# Build minimal payload and forward immediately
# Access control is NOT computed here - it's deferred to execution time
event_payload = self._build_event_payload(org_context, payload)
await self._send_to_automation_service(provider, org_id, event_payload)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
# Network errors are expected and recoverable
logger.error(
f'[AutomationEventService] Network error forwarding '
f'{provider.value} event (org_id={org_id}): {e}',
exc_info=True,
extra={'installation_id': installation_id},
)
except Exception as e:
# Log unexpected errors. Note: This is a background task, so exceptions
# won't surface to the HTTP caller - they're logged for debugging only.
logger.error(
f'[AutomationEventService] Unexpected error forwarding '
f'{provider.value} event (org_id={org_id}): {e}',
exc_info=True,
extra={'installation_id': installation_id},
)
# Don't re-raise in background task - just log for debugging
async def _resolve_org_context(
self, provider: ProviderType, payload: dict[str, Any]
) -> OrgContext | None:
"""
Resolve the organization context from the webhook payload.
Uses Redis caching for both org claims and user ID mappings.
Returns None if the org cannot be resolved (not claimed, no personal org).
Args:
provider: The Git provider type
payload: The webhook payload from the provider
"""
git_org_name, owner_type, owner_id = self._extract_owner_info(provider, payload)
if not git_org_name:
logger.warning(
f'[AutomationEventService] No repository owner in '
f'{provider.value} payload, skipping'
)
return None
# Try to resolve via OrgGitClaim
org_id = await self._resolve_git_org(provider, git_org_name)
# Fallback for personal repos (owner_type indicates individual user)
if not org_id and owner_type == 'User':
org_id = await self._resolve_personal_org(provider, owner_id)
if org_id:
logger.info(
f'[AutomationEventService] Resolved personal repo owner '
f'{git_org_name} to personal org {org_id} ({provider.value})'
)
if not org_id:
logger.warning(
f'[AutomationEventService] {provider.value} org {git_org_name} '
f'not claimed and no personal org found, skipping'
)
return None
return OrgContext(org_id=org_id, git_org=git_org_name)
def _extract_owner_info(
self, provider: ProviderType, payload: dict[str, Any]
) -> tuple[str | None, str | None, int | None]:
"""
Extract owner information from the webhook payload.
Different providers structure their payloads differently, so this method
normalizes the extraction.
Args:
provider: The Git provider type
payload: The webhook payload
Returns:
Tuple of (git_org_name, owner_type, owner_id)
- git_org_name: The organization/user name that owns the repo
- owner_type: 'User' or 'Organization' (or provider-specific equivalent)
- owner_id: The numeric ID of the owner (for personal org resolution)
"""
# Compare using .value to handle different ProviderType enum instances
# (e.g., test mocks may use a different enum class with the same values)
if provider == ProviderType.GITHUB:
repo = payload.get('repository', {})
owner = repo.get('owner', {})
return owner.get('login'), owner.get('type'), owner.get('id')
logger.warning(f'Unsupported provider ({provider.value})')
return None, None, None
def _build_event_payload(
self,
org_context: OrgContext,
payload: dict[str, Any],
) -> dict[str, Any]:
"""
Build the minimal event payload to forward to the automation service.
Access control is NOT included here - it's deferred to execution time.
This keeps the forward path fast for high-traffic scenarios.
"""
return {
'organization': {
'git_org': org_context.git_org,
'openhands_org_id': str(org_context.org_id),
},
'payload': payload,
}
# =========================================================================
# Cached Org Resolution Methods
# =========================================================================
async def _resolve_git_org(
self, provider: ProviderType, git_org_name: str
) -> UUID | None:
"""
Resolve a Git organization name to an OpenHands org_id.
Uses Redis caching with 1-hour TTL. Caches both positive and negative
results to avoid repeated DB queries for unclaimed orgs.
Args:
provider: The Git provider type
git_org_name: The organization/user name from the provider
Note: Org names are normalized to lowercase for both cache keys and
DB queries. This matches the OrgGitClaim schema which stores
git_organization as lowercase.
"""
normalized_org = git_org_name.lower()
cache_key = f'{ORG_CLAIM_CACHE_PREFIX}:{provider.value}:{normalized_org}'
# Check cache first
cached = await self._get_cached_value(cache_key)
if cached is not None:
if cached == 'none':
logger.debug(
f'[AutomationEventService] Cache hit (negative): '
f'{provider.value} org {git_org_name} not claimed'
)
return None
logger.debug(
f'[AutomationEventService] Cache hit: '
f'{provider.value} org {git_org_name} -> {cached}'
)
return UUID(cached)
# Cache miss - use resolve_org_for_repo without user_id (no membership check)
# Construct a minimal repo name since resolve_org_for_repo extracts the org
org_id = await resolve_org_for_repo(
provider=provider.value,
full_repo_name=f'{normalized_org}/',
)
# Cache the result (including negative results)
if org_id:
await self._set_cached_value(
cache_key, str(org_id), ORG_CLAIM_CACHE_TTL_SECONDS
)
return org_id
else:
# Cache negative result to avoid repeated DB queries
await self._set_cached_value(cache_key, 'none', ORG_CLAIM_CACHE_TTL_SECONDS)
return None
async def _resolve_personal_org(
self, provider: ProviderType, provider_user_id: int | str | None
) -> UUID | None:
"""
Resolve a provider user to their personal OpenHands org.
For personal repos (owner type is 'User'), the OpenHands org_id
is the user's keycloak user ID. This allows users to set up
automations on their personal repos without needing an OrgGitClaim.
Uses Redis caching for the provider→Keycloak user ID mapping (24h TTL).
Args:
provider: The Git provider type
provider_user_id: The user ID from the provider (numeric or string UUID)
"""
if not provider_user_id:
return None
keycloak_id = await self._get_keycloak_user_id_cached(
provider, provider_user_id
)
if keycloak_id:
return UUID(keycloak_id)
return None
async def _get_keycloak_user_id_cached(
self, provider: ProviderType, provider_user_id: int | str
) -> str | None:
"""
Convert a provider user ID to a Keycloak user ID.
Uses Redis caching with 24-hour TTL since this mapping never changes.
Caches negative results to avoid repeated Keycloak queries.
Args:
provider: The Git provider type
provider_user_id: The user ID from the provider
"""
cache_key = f'{USER_ID_CACHE_PREFIX}:{provider.value}:{provider_user_id}'
# Check cache first
cached = await self._get_cached_value(cache_key)
if cached is not None:
if cached == 'none':
logger.debug(
f'[AutomationEventService] Cache hit (negative): '
f'{provider.value} user {provider_user_id} not in Keycloak'
)
return None
logger.debug(
f'[AutomationEventService] Cache hit: '
f'{provider.value} user {provider_user_id} -> Keycloak {cached}'
)
return cached
# Cache miss - query Keycloak
try:
keycloak_id = await self.token_manager.get_user_id_from_idp_user_id(
str(provider_user_id), provider
)
# Cache the result (including negative results)
if keycloak_id:
await self._set_cached_value(
cache_key, keycloak_id, USER_ID_CACHE_TTL_SECONDS
)
else:
# Cache negative result to prevent repeated Keycloak queries
await self._set_cached_value(
cache_key, 'none', USER_ID_CACHE_TTL_SECONDS
)
return keycloak_id
except Exception as e:
# Log at warning level to surface programmer errors and API issues
logger.warning(
f'[AutomationEventService] Failed to get keycloak ID for '
f'{provider.value} user {provider_user_id}: {e}'
)
return None
# =========================================================================
# Generic Redis Cache Helpers
# =========================================================================
async def _get_cached_value(self, cache_key: str) -> str | None:
"""
Get a cached value from Redis.
Returns the cached string value, or None if not cached or Redis unavailable.
Falls back to DB/API queries if Redis is unavailable (graceful degradation).
Warning: When Redis is unavailable, every webhook will hit the DB directly.
Monitor logs for 'Redis unavailable' warnings to detect degradation.
"""
try:
redis = getattr(sio.manager, 'redis', None)
if not redis:
# Log at warning level - this is a significant degradation that
# will cause DB load. Monitor these logs for alerting.
logger.warning(
'[AutomationEventService] Redis unavailable for cache read, '
'falling back to direct DB queries (this will increase DB load)'
)
return None
cached = await redis.get(cache_key)
if cached is None:
return None
# Redis returns bytes, decode to string
return cached.decode('utf-8') if isinstance(cached, bytes) else cached
except Exception as e:
# Log at warning level - cache errors cause DB fallback
logger.warning(
f'[AutomationEventService] Redis cache read error '
f'(falling back to DB): {e}'
)
return None
async def _set_cached_value(
self, cache_key: str, value: str, ttl_seconds: int
) -> None:
"""
Set a cached value in Redis with TTL.
Fails silently if Redis is unavailable (graceful degradation).
"""
try:
redis = getattr(sio.manager, 'redis', None)
if not redis:
# Silent failure - read path already logs the warning
return
await redis.setex(cache_key, ttl_seconds, value)
except Exception as e:
# Log at warning level for visibility
logger.warning(f'[AutomationEventService] Redis cache write error: {e}')
def _sign_payload(self, payload_bytes: bytes) -> str:
"""
Sign a payload using the dedicated automation shared secret.
Uses AUTOMATION_WEBHOOK_SECRET (not GitHub webhook secret) to maintain
separate trust boundaries between GitHub webhooks and internal services.
Returns the signature in the format 'sha256=<hex_digest>'.
"""
signature = hmac.new(
AUTOMATION_WEBHOOK_SECRET.encode('utf-8'),
msg=payload_bytes,
digestmod=hashlib.sha256,
).hexdigest()
return f'sha256={signature}'
async def _send_to_automation_service(
self,
provider: ProviderType,
org_id: UUID,
payload: dict[str, Any],
) -> None:
"""
Send the normalized payload to the automation service.
The payload is signed using AUTOMATION_WEBHOOK_SECRET so the
automation service can verify it came from the OpenHands server.
Args:
provider: The Git provider type
org_id: The OpenHands organization ID
payload: The event payload to send
"""
if not AUTOMATION_SERVICE_URL:
logger.warning(
'[AutomationEventService] AUTOMATION_SERVICE_URL not configured'
)
return
# Build endpoint URL. AUTOMATION_SERVICE_URL may include path segments
# (e.g., https://example.com/api/automation), so we strip trailing slash
# and append our path. The provider is included in the URL path.
base_url = AUTOMATION_SERVICE_URL.rstrip('/')
url = f'{base_url}/v1/events/{org_id}/{provider.value}'
# Serialize payload to JSON bytes for signing
payload_bytes = json.dumps(payload, separators=(',', ':')).encode('utf-8')
signature = self._sign_payload(payload_bytes)
headers = {
'Content-Type': 'application/json',
'X-Hub-Signature-256': signature,
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
url,
data=payload_bytes,
headers=headers,
timeout=aiohttp.ClientTimeout(total=AUTOMATION_SERVICE_TIMEOUT),
) as resp:
if resp.status >= 400:
# Try JSON first (expected interface), fall back to text
# for infrastructure errors (502/503 from load balancer)
try:
body = await resp.json()
except (aiohttp.ContentTypeError, ValueError):
body = await resp.text()
logger.warning(
f'[AutomationEventService] Automation service returned '
f'{resp.status} for {provider.value} org {org_id}: {body}'
)
else:
data = await resp.json()
matched = data.get('matched', 0)
logger.info(
f'[AutomationEventService] Forwarded {provider.value} '
f'event to org {org_id}: {matched} automations matched'
)
except asyncio.TimeoutError:
logger.warning(
f'[AutomationEventService] Timeout ({AUTOMATION_SERVICE_TIMEOUT}s) '
f'forwarding {provider.value} event to automation service'
)
except aiohttp.ClientError as e:
logger.warning(
f'[AutomationEventService] HTTP error forwarding '
f'{provider.value} event to automation service: {e}'
)

View File

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

View File

@@ -1,130 +0,0 @@
"""Service class for managing organization LLM settings.
Separates business logic from route handlers.
Uses dependency injection for db_session and user_context.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncGenerator
from fastapi import Request
from server.routes.org_models import (
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgNotFoundError,
)
from storage.org_llm_settings_store import OrgLLMSettingsStore
from openhands.app_server.services.injector import Injector, InjectorState
from openhands.app_server.user.user_context import UserContext
from openhands.core.logger import openhands_logger as logger
@dataclass
class OrgLLMSettingsService:
"""Service for org LLM settings with injected dependencies."""
store: OrgLLMSettingsStore
user_context: UserContext
async def get_org_llm_settings(self) -> OrgLLMSettingsResponse:
"""Get LLM settings for user's current organization.
User ID is obtained from the injected user_context.
Returns:
OrgLLMSettingsResponse: The organization's LLM settings
Raises:
ValueError: If user is not authenticated
OrgNotFoundError: If current organization not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Getting organization LLM settings',
extra={'user_id': user_id},
)
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('No current organization')
return OrgLLMSettingsResponse.from_org(org)
async def update_org_llm_settings(
self,
update_data: OrgLLMSettingsUpdate,
) -> OrgLLMSettingsResponse:
"""Update LLM settings for user's current organization.
Only updates fields that are explicitly provided in update_data.
User ID is obtained from the injected user_context.
Session auto-commits at request end via DbSessionInjector.
Args:
update_data: The update data from the request
Returns:
OrgLLMSettingsResponse: The updated organization's LLM settings
Raises:
ValueError: If user is not authenticated
OrgNotFoundError: If current organization not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Updating organization LLM settings',
extra={'user_id': user_id},
)
# Check if any fields are provided
if not update_data.has_updates():
# No fields to update, just return current settings
return await self.get_org_llm_settings()
# Get user's current org first
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('No current organization')
# Update the org LLM settings
updated_org = await self.store.update_org_llm_settings(
org_id=org.id,
update_data=update_data,
)
if not updated_org:
raise OrgNotFoundError(str(org.id))
logger.info(
'Organization LLM settings updated successfully',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return OrgLLMSettingsResponse.from_org(updated_org)
class OrgLLMSettingsServiceInjector(Injector[OrgLLMSettingsService]):
"""Injector that composes store and user_context for OrgLLMSettingsService."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[OrgLLMSettingsService, None]:
# Local imports to avoid circular dependencies
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,
):
store = OrgLLMSettingsStore(db_session=db_session)
yield OrgLLMSettingsService(store=store, user_context=user_context)

View File

@@ -101,19 +101,36 @@ async def search_shared_events(
] = 100,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> EventPage:
"""Search / List events for a shared conversation."""
page = await shared_event_service.search_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
"""Search / List events for a shared conversation.
Because non-viewable events (e.g. ``ConversationStateUpdateEvent``) are
filtered out after fetching, a single backend page may yield fewer items
than *limit*. This method transparently fetches additional backend pages
until the requested *limit* is reached or there are no more results.
"""
conv_id = UUID(conversation_id)
viewable: list[Event] = []
cursor = page_id
while len(viewable) < limit:
remaining = limit - len(viewable)
page = await shared_event_service.search_shared_events(
conversation_id=conv_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=cursor,
limit=remaining,
)
viewable.extend(e for e in page.items if _is_viewable(e))
cursor = page.next_page_id
if cursor is None:
break
return EventPage(
items=[e for e in page.items if _is_viewable(e)],
next_page_id=page.next_page_id,
items=viewable[:limit],
next_page_id=cursor,
)

View File

@@ -26,13 +26,13 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.agent_server.utils import utc_now
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
StoredConversationMetadata,
)
from openhands.app_server.services.injector import InjectorState
from openhands.integrations.provider import ProviderType
from openhands.sdk.llm import MetricsSnapshot
from openhands.sdk.llm.utils.metrics import TokenUsage
from openhands.sdk.llm import MetricsSnapshot, TokenUsage
logger = logging.getLogger(__name__)
@@ -147,9 +147,15 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
updated_at=updated_at,
)
def _fix_timezone(self, value: datetime) -> datetime:
def _fix_timezone(self, value: datetime | None) -> datetime:
"""Sqlite does not store timezones - and since we can't update the existing models
we assume UTC if the timezone is missing."""
we assume UTC if the timezone is missing. Returns current UTC time if value is None.
"""
if value is None:
# Fallback for legacy data: use current time to match model defaults.
# The DB columns have default=utc_now, so None only occurs in legacy records.
# Using utc_now() keeps the API model non-nullable and matches new record behavior.
return utc_now()
if not value.tzinfo:
value = value.replace(tzinfo=UTC)
return value

View File

@@ -0,0 +1,72 @@
"""Utility functions for conversation operations."""
from uuid import UUID
from storage.database import session_maker
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
def get_user_id(conversation_id: str) -> str:
"""Get the user ID for a conversation from the metadata.
Args:
conversation_id: The conversation ID
Returns:
The user ID as a string
"""
with session_maker() as session:
conversation_metadata_saas = (
session.query(StoredConversationMetadataSaas)
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
.first()
)
if not conversation_metadata_saas:
raise ValueError(f'Conversation not found: {conversation_id}')
return str(conversation_metadata_saas.user_id)
async def get_session_api_key(conversation_id: str) -> str | None:
"""Get the session API key for a conversation.
This retrieves the session API key from the V1 sandbox system.
Args:
conversation_id: The conversation ID
Returns:
The session API key, or None if not available
"""
from openhands.app_server.config import (
get_app_conversation_info_service,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
):
# Get the conversation info to find the sandbox_id
app_conversation_info = (
await app_conversation_info_service.get_app_conversation_info(
UUID(conversation_id)
)
)
if not app_conversation_info:
return None
# Get the sandbox to retrieve the session API key
sandbox = await sandbox_service.get_sandbox(app_conversation_info.sandbox_id)
if not sandbox:
return None
return sandbox.session_api_key

View File

@@ -5,7 +5,7 @@ from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from sqlalchemy import func, select
from sqlalchemy import ColumnElement, func, select
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from storage.user import User
@@ -242,7 +242,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
):
"""Apply filters to query that includes SAAS metadata."""
# Apply the same filters as the base class
conditions = []
conditions: list[ColumnElement[bool]] = []
if title__contains is not None:
conditions.append(
StoredConversationMetadata.title.like(f'%{title__contains}%')
@@ -350,8 +350,8 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
# Convert string user_id to UUID
user_id_uuid = UUID(user_id_str)
user_query = select(User).where(User.id == user_id_uuid)
result = await self.db_session.execute(user_query)
user = result.scalar_one_or_none()
user_result = await self.db_session.execute(user_query)
user = user_result.scalar_one_or_none()
assert user
# Determine org_id: prefer API key's org_id if authenticated via API key
@@ -372,8 +372,8 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)
)
result = await self.db_session.execute(saas_query)
existing_saas_metadata = result.scalar_one_or_none()
saas_result = await self.db_session.execute(saas_query)
existing_saas_metadata = saas_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 == org_id

View File

@@ -1,17 +1,15 @@
"""Store for managing verified LLM models in the database."""
from dataclasses import dataclass
from datetime import datetime
from server.verified_models.verified_model_models import (
VerifiedModel,
VerifiedModelPage,
)
from sqlalchemy import (
Boolean,
Column,
DateTime,
Identity,
Integer,
String,
UniqueConstraint,
and_,
@@ -20,13 +18,14 @@ from sqlalchemy import (
text,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from storage.base import Base
from openhands.app_server.config import depends_db_session
from openhands.core.logger import openhands_logger as logger
class StoredVerifiedModel(Base): # type: ignore
class StoredVerifiedModel(Base):
"""A verified LLM model available in the model selector.
The composite unique constraint on (model_name, provider) allows the same
@@ -39,14 +38,16 @@ class StoredVerifiedModel(Base): # type: ignore
UniqueConstraint('model_name', 'provider', name='uq_verified_model_provider'),
)
id = Column(Integer, Identity(), primary_key=True)
model_name = Column(String(255), nullable=False)
provider = Column(String(100), nullable=False, index=True)
is_enabled = Column(
Boolean, nullable=False, default=True, server_default=text('true')
id: Mapped[int] = mapped_column(Identity(), primary_key=True)
model_name: Mapped[str] = mapped_column(String(255), nullable=False)
provider: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
is_enabled: Mapped[bool] = mapped_column(
nullable=False, default=True, server_default=text('true')
)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
@@ -137,7 +138,8 @@ class VerifiedModelService:
)
)
result = await self.db_session.execute(query)
return result.scalars().first()
stored = result.scalars().first()
return verified_model(stored) if stored else None
async def create_verified_model(
self,

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