Compare commits

..

374 Commits

Author SHA1 Message Date
openhands
eb42efa5b8 Add secret name validation with blocked names and LLM_* prefix
- Create openhands/app_server/constants.py with:
  - BLOCKED_SECRET_NAMES: Container env vars that could break sandbox
  - BLOCKED_SECRET_PREFIXES: ('LLM_',) to enforce LLM controls
  - OVERRIDABLE_SYSTEM_SECRETS: Git tokens and AWS credentials
  - validate_secret_name() function

- Update live_status_app_conversation_service.py to validate secrets

Blocked names include container config vars (OH_*, WORKER_*, etc.)
that are injected at startup. LLM_* prefix is blocked to prevent
users from escaping app-server LLM configuration controls.
2026-04-23 14:24:42 +00:00
hieptl
ff1d472473 Merge branch 'main' into add-secrets-to-conversation-start-request 2026-04-23 19:26:07 +07: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
John-Mason P. Shackelford
c69318f2c3 Merge branch 'main' into add-secrets-to-conversation-start-request 2026-04-22 08:07:28 -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
openhands
dbdfd978bb chore: Update agent-server image to 2ecddab-python for SDK PR #2873 testing
Update to latest SDK PR build (2ecddab) after merge with main that fixed:
- OpenAPI schema generation error
- mcp_config serialization handling

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 00:19:38 +00: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
Tim O'Farrell
e92704a04a Merge branch 'main' into add-secrets-to-conversation-start-request 2026-04-21 09:11:31 -06: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
openhands
7c9ee87b47 chore: remove e2e test script (testing complete) 2026-04-18 09:50:03 +00:00
openhands
2dfee00e8d revert: remove SDK pin, keep only agent-server image tag update
The deploy workflow extracts AGENT_SERVER_IMAGE from sandbox_spec_service.py
at the OPENHANDS_SHA commit, so we only need to update that file to use
the SDK PR's agent-server image (8a68c66-python).

The SDK pinning in pyproject.toml was unnecessary and caused enterprise
Docker build issues.
2026-04-18 02:05:53 +00:00
openhands
2939f1d520 fix: exclude git-based SDK deps from enterprise requirements.txt
The enterprise Docker build exports dependencies via poetry and installs
with hash verification. Git-based dependencies (used when pinning SDK to
a PR commit) don't have hashes, causing the build to fail.

This fix removes openhands-sdk, openhands-agent-server, openhands-tools,
and any git+ deps from requirements.txt since they're already installed
in the base image (which has the pinned SDK version).
2026-04-18 01:59:17 +00:00
openhands
18cf56ddb6 test: add e2e test script for secrets at conversation start
This script tests the new 'secrets' field in AppConversationStartRequest.
It will be used to provide evidence for the PR review.
2026-04-18 01:51:06 +00:00
openhands
8834d166a1 chore: pin SDK to PR #2873 for e2e testing
Pin SDK packages to commit 8a68c66 from software-agent-sdk PR #2873
which adds per-conversation secrets support for MCP config expansion.

This pin is temporary for e2e testing and must be removed before merge.

Files changed:
- pyproject.toml: Pin all 3 SDK packages to git commit
- sandbox_spec_service.py: Update AGENT_SERVER_IMAGE to 8a68c66-python
- docker-compose.yml: Update default image tag
- containers/dev/compose.yml: Update default image tag
- poetry.lock, uv.lock, enterprise/poetry.lock: Regenerated
2026-04-18 01:49:23 +00:00
openhands
82ef032a38 docs: add warning about silent override behavior in secrets field description
Address review feedback by adding explicit warning that providing a secret
with the same name as an existing one will silently override it.
2026-04-18 01:44:07 +00:00
openhands
3d40056941 style: fix import order using ruff 0.11.8
Use the same ruff version as CI (v0.11.8) to ensure consistent import ordering:
- Move pydantic import to third-party section (after pytest)
- Sort all openhands.* imports alphabetically within first-party section
- Remove blank lines between local imports in test functions
2026-04-18 01:23:05 +00:00
openhands
b8cf0de2ac style: fix formatting using correct ruff config (single quotes)
Use dev_config/python/ruff.toml to ensure consistent formatting with CI
2026-04-18 01:12:18 +00:00
openhands
e17c455e87 style: fix formatting in test file (ruff-format)
Apply ruff-format to fix formatting in the test file:
- Function signatures on single line where appropriate
- Consistent quote style (double quotes)
2026-04-18 01:07:49 +00:00
openhands
395272e8b0 feat: add secrets field to AppConversationStartRequest for direct API secret passing
This commit adds the ability for API callers to pass secrets directly when starting
a conversation, without requiring them to be pre-stored in the database.

Changes:
- Added 'secrets' field to AppConversationStartRequest model (dict[str, SecretStr])
- Updated _build_start_conversation_request_for_user() to accept and merge API secrets
- API-provided secrets are converted to StaticSecret and merged with existing secrets
- API-provided secrets take precedence over existing secrets with the same name
- Added comprehensive unit tests for the new functionality

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

View File

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

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

@@ -80,5 +80,4 @@ These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`)
|----------|-------|
| `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

8
.github/CODEOWNERS vendored
View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4
@@ -192,7 +192,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: tests/e2e/test-results/
@@ -200,7 +200,7 @@ jobs:
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: openhands-logs
path: |

View File

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

View File

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

View File

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

View File

@@ -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,247 +16,45 @@ 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: blacksmith
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v3
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
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
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@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v3
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: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main 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
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: useblacksmith/build-push-action@v1
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: useblacksmith/build-push-action@v1
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v6
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
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@v4
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@v3
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@v5
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}}
flavor: |
latest=auto
prefix=
suffix=
env:
DOCKER_METADATA_PR_HEAD_SHA: true
- name: Determine app image tag
shell: bash
run: |
# Duplicated with build.sh
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
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: blacksmith-4vcpu-ubuntu-2204
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]
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: build_app
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- 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:
@@ -271,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

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

View File

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

View File

@@ -18,7 +18,7 @@ concurrency:
jobs:
check-version:
name: Check if version has changed
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
defaults:
run:
shell: bash
@@ -27,7 +27,7 @@ jobs:
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 2 # Need previous commit to compare
@@ -55,7 +55,7 @@ jobs:
publish:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
@@ -63,7 +63,7 @@ jobs:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -86,7 +86,7 @@ jobs:
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -131,7 +131,7 @@ jobs:
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_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
GITHUB_TOKEN: ${{ github.token }}
run: |
@@ -185,16 +185,16 @@ jobs:
fi
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.PAT_TOKEN || github.token }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || 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
uses: actions/github-script@v9
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
script: |
const issueType = process.env.ISSUE_TYPE;
github.rest.issues.createComment({
@@ -206,7 +206,7 @@ jobs:
- name: Install OpenHands
id: install_openhands
uses: actions/github-script@v7
uses: actions/github-script@v9
env:
COMMENT_BODY: ${{ github.event.comment.body || '' }}
REVIEW_BODY: ${{ github.event.review.body || '' }}
@@ -242,7 +242,7 @@ jobs:
- name: Attempt to resolve issue
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
GITHUB_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
@@ -269,7 +269,7 @@ jobs:
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always() # Upload even if the previous steps fail
with:
name: resolver-output
@@ -279,7 +279,7 @@ jobs:
- 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_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
@@ -305,13 +305,13 @@ jobs:
# Step leaves comment for when agent is invoked on PR
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
uses: actions/github-script@v7
uses: actions/github-script@v9
if: always()
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
script: |
const fs = require('fs');
const issueNumber = process.env.ISSUE_NUMBER;
@@ -341,14 +341,14 @@ jobs:
# Step leaves comment for when agent is invoked on issue
- name: Comment on issue # Comment link to either PR or branch created by agent
uses: actions/github-script@v7
uses: actions/github-script@v9
if: always() # Comment on issue even if the previous steps fail
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
script: |
const fs = require('fs');
const path = require('path');
@@ -416,12 +416,12 @@ jobs:
# Leave error comment when both PR/Issue comment handling fail
- name: Fallback Error Comment
uses: actions/github-script@v7
uses: actions/github-script@v9
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
env:
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
script: |
const issueNumber = process.env.ISSUE_NUMBER;

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

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

View File

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

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

View File

@@ -19,7 +19,7 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
@@ -30,20 +30,22 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install tmux
run: sudo apt-get update && sudo apt-get install -y tmux
- name: Setup Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -55,7 +57,7 @@ jobs:
- name: Build Environment
run: make build
- name: Run Unit Tests
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest -n auto -s ./tests/unit --cov=openhands --cov-branch
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
@@ -63,7 +65,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: coverage-openhands
path: |
@@ -73,16 +75,16 @@ jobs:
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -91,11 +93,11 @@ jobs:
run: poetry install --with dev,test
- name: Run Unit Tests
# Use base working directory for coverage paths to line up.
run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"
@@ -111,9 +113,9 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v8
id: download
with:
pattern: coverage-*

View File

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

View File

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

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

@@ -19,10 +19,10 @@ concurrency:
jobs:
ui-build:
name: Build openhands-ui
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"

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}`;

6
.gitignore vendored
View File

@@ -234,6 +234,8 @@ yarn-error.log*
logs
ralph/
# agent
.envrc
/workspace
@@ -252,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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.2-trixie-slim AS frontend-builder
FROM node:25.9-trixie-slim AS frontend-builder
WORKDIR /app
@@ -20,9 +20,11 @@ ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# Pin Poetry version to match the version used to generate poetry.lock
ARG POETRY_VERSION=2.3.3
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential jq gettext \
&& python3 -m pip install poetry --break-system-packages
&& python3 -m pip install "poetry==${POETRY_VERSION}" --break-system-packages
COPY pyproject.toml poetry.lock ./
RUN touch README.md
@@ -50,7 +52,7 @@ RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE
RUN apt-get update -y \
&& apt-get install -y curl ssh sudo \
&& apt-get install -y curl git ssh sudo \
&& rm -rf /var/lib/apt/lists/*
# Default is 1000, but OSX is often 501
@@ -73,13 +75,22 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
# Pin pip to a known-good version (reproducible builds) and fix CVE-2025-8869
# Pin both venv pip and system pip (Trivy scans both)
# - `python -m pip` uses the venv because `PATH` is prefixed with `${VIRTUAL_ENV}/bin`
# - `/usr/local/bin/python3 -m pip` uses the system interpreter regardless of `PATH`
ARG PIP_VERSION=26.0.1
RUN python -m pip install --no-cache-dir "pip==${PIP_VERSION}"
USER root
RUN /usr/local/bin/python3 -m pip install --no-cache-dir "pip==${PIP_VERSION}" --break-system-packages
USER openhands
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
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

@@ -1,182 +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
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--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 " --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 ;;
--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"
# Modify the platform selection based on --load flag
if [[ $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

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

View File

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

@@ -58,6 +58,9 @@ repos:
types-Markdown,
pydantic,
lxml,
"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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -723,11 +723,15 @@
"https://$WEB_HOST/slack/keycloak-callback",
"https://$WEB_HOST/oauth/device/keycloak-callback",
"https://$WEB_HOST/api/email/verified",
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*",
"https://laminar.$WEB_HOST/api/auth/callback/keycloak",
"https://analytics.$WEB_HOST/api/auth/callback/keycloak"
],
"webOrigins": [
"https://$WEB_HOST",
"https://$AUTH_WEB_HOST"
"https://$AUTH_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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,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,229 +0,0 @@
from dataclasses import dataclass
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
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 openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
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:
agent_loop_info = await create_new_conversation(
user_id=self.linear_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.LINEAR,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
self.conversation_id = agent_loop_info.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

@@ -1,7 +1,9 @@
from uuid import UUID
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.integrations.service_types import ProviderType, UserGitInfo
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -12,8 +14,10 @@ class ResolverUserContext(UserContext):
def __init__(
self,
saas_user_auth: UserAuth,
resolver_org_id: UUID | None = None,
):
self.saas_user_auth = saas_user_auth
self.resolver_org_id = resolver_org_id
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
@@ -60,7 +64,9 @@ class ResolverUserContext(UserContext):
return provider_token.token.get_secret_value()
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
async def get_provider_tokens(
self, as_env_vars: bool = False
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, SecretSource]:
@@ -79,3 +85,6 @@ class ResolverUserContext(UserContext):
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()
async def get_user_git_info(self) -> UserGitInfo | None:
return await self.saas_user_auth.get_user_git_info()

View File

@@ -0,0 +1,78 @@
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
This module provides a reusable utility for routing resolver conversations
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
workspace based on claimed Git organizations.
"""
from uuid import UUID
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
async def resolve_org_for_repo(
provider: str,
full_repo_name: str,
keycloak_user_id: str | 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, 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 (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 when
keycloak_user_id is provided), else None
"""
git_org = full_repo_name.split('/')[0].lower()
try:
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider, git_org
)
if not claim:
logger.debug(
f'[OrgResolver] No claim found for {provider}/{git_org}',
)
return None
# 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)
)
if not member:
logger.debug(
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
f'{claim.org_id} (claimed {provider}/{git_org}). '
f'Falling back to personal workspace.',
)
return None
logger.info(
f'[OrgResolver] Routing conversation to org {claim.org_id} '
f'for {provider}/{git_org} (user {keycloak_user_id})',
)
return claim.org_id
except Exception as e:
logger.error(
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
exc_info=True,
)
return None

View File

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

@@ -40,16 +40,20 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for Slack V1 integration."""
# Only handle ConversationStateUpdateEvent
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
if event.key != 'execution_status':
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[Slack V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
try:
summary = await self._request_summary(conversation_id)
await self._post_summary_to_slack(summary)
@@ -107,9 +111,11 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
try:
# Post the summary as a threaded reply
# Use markdown_text instead of text to properly render standard Markdown
# (e.g., **bold**, [link](url)) which is used throughout the codebase
response = client.chat_postMessage(
channel=channel_id,
text=summary,
markdown_text=summary,
thread_ts=thread_ts,
unfurl_links=False,
unfurl_media=False,

View File

@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.slack.slack_types import (
SlackMessageView,
SlackViewInterface,
@@ -13,7 +14,6 @@ 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
@@ -33,16 +33,8 @@ 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, ProviderType
from openhands.integrations.provider import ProviderHandler
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
@@ -200,56 +192,26 @@ 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()
# 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
)
# Determine git provider from repository
git_provider = None
# Determine git provider from repository (needed for both org routing and conversation creation)
self._resolved_git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
self._resolved_git_provider = repository.git_provider
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_instructions,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
# Resolve target org based on claimed git organizations
self.resolved_org_id = None
if self._resolved_git_provider and self.selected_repo:
self.resolved_org_id = await resolve_org_for_repo(
provider=self._resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
)
self.conversation_id = agent_loop_info.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."""
@@ -265,13 +227,8 @@ class SlackNewConversationView(SlackViewInterface):
# Create the Slack V1 callback processor
slack_callback_processor = self._create_slack_v1_callback_processor()
# Determine git provider from repository
git_provider = None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = ProviderType(repository.git_provider.value)
# Use git provider resolved in create_or_update_conversation
git_provider = self._resolved_git_provider
# Get the app conversation service and start the conversation
injector_state = InjectorState()
@@ -292,7 +249,10 @@ class SlackNewConversationView(SlackViewInterface):
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
slack_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(
@@ -345,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
@@ -486,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
@@ -498,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:
@@ -100,27 +100,28 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
return bool(payment_methods.data)
async def migrate_customer(user_id: str, org: Org):
async with a_session_maker() as session:
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
async def migrate_customer(session, user_id: str, org: Org):
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
# 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',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
await session.commit()
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)

View File

@@ -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,9 +6,15 @@ 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 # noqa: E402
from sqlalchemy import create_engine, text # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata
@@ -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.
@@ -109,6 +121,10 @@ def run_migrations_online() -> None:
version_table_schema=target_metadata.schema,
)
# Lock number must be unique — md5 hash of 'openhands_enterprise_migrations'
# Lock is released when the connection context manager exits
connection.execute(text('SELECT pg_advisory_lock(3617572382373537863)'))
with context.begin_transaction():
context.run_migrations()

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
"""Add mcp_config to org_member for user-specific MCP settings.
Revision ID: 103
Revises: 102
Create Date: 2026-03-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '103'
down_revision: Union[str, None] = '102'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
# Migrate existing org-level MCP configs to all members in each org.
# This preserves existing configurations while transitioning to user-specific settings.
# 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:
op.drop_column('org_member', 'mcp_config')

View File

@@ -0,0 +1,29 @@
"""Add disabled_skills column to user table.
Migration 102 added disabled_skills to the legacy user_settings table,
but the active SaaS flow (SaasSettingsStore) reads from/writes to the
user table. This migration adds the column where it is actually needed.
Revision ID: 104
Revises: 103
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '104'
down_revision: Union[str, None] = '103'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('disabled_skills', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'disabled_skills')

View File

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

View File

@@ -0,0 +1,32 @@
"""Add tags column to conversation_metadata table.
Tags store key-value pairs for automation context (trigger type, automation_id),
skills used, and other metadata. This enables querying conversations by
automation source and associating SDK-provided context with conversations.
Revision ID: 106
Revises: 105
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '106'
down_revision: Union[str, None] = '105'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'conversation_metadata',
sa.Column('tags', sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column('conversation_metadata', 'tags')

View File

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

View File

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

4941
enterprise/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -61,8 +61,10 @@ types-requests = "^2.32.4.20250611"
pytest = "*"
pytest-cov = "*"
pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
flake8 = "*"
freezegun = "^1.5.1"
openai = "*"
opencv-python = "*"
pandas = "*"

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
@@ -46,8 +43,11 @@ 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.user import saas_user_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.routes.users_v1 import ( # noqa: E402
override_users_me_endpoint,
)
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
@@ -82,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
@@ -112,6 +111,7 @@ if GITLAB_APP_CLIENT_ID:
base_app.include_router(gitlab_integration_router)
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(service_router) # Add routes for internal service API
base_app.include_router(org_router) # Add routes for organization management
base_app.include_router(
verified_models_router
@@ -121,6 +121,10 @@ base_app.include_router(
# This must happen after all routers are included
override_llm_models_dependency(base_app)
# Override the /api/v1/users/me endpoint to include organization info
# This replaces the OSS endpoint with a SAAS version that adds org_id, org_name, role, permissions
override_users_me_endpoint(base_app)
base_app.include_router(invitation_router) # Add routes for org invitation management
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
add_github_proxy_routes(base_app)
@@ -129,8 +133,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
@@ -139,9 +141,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

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

View File

@@ -6,7 +6,6 @@ GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
GITHUB_APP_PRIVATE_KEY = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace('\\n', '\n')
KEYCLOAK_SERVER_URL = os.getenv('KEYCLOAK_SERVER_URL', '').rstrip('/')
KEYCLOAK_REALM_NAME = os.getenv('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.getenv('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_SERVER_URL_EXT = os.getenv(
@@ -57,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

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

View File

@@ -1,6 +1,7 @@
import time
from dataclasses import dataclass
from types import MappingProxyType
from uuid import UUID
import jwt
from fastapi import Request
@@ -13,6 +14,10 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.authorization import (
get_role_permissions,
get_user_org_role,
)
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
from server.auth.token_manager import TokenManager
from server.config import get_config
@@ -22,10 +27,12 @@ from sqlalchemy import delete, select
from storage.api_key_store import ApiKeyStore
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
from storage.org_store import OrgStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from storage.user_authorization import UserAuthorizationType
from storage.user_authorization_store import UserAuthorizationStore
from storage.user_store import UserStore
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
from openhands.integrations.provider import (
@@ -59,6 +66,25 @@ class SaasUserAuth(UserAuth):
_secrets: Secrets | None = None
accepted_tos: bool | None = None
auth_type: AuthType = AuthType.COOKIE
# API key context fields - populated when authenticated via API key
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
# Organization context fields - populated lazily via get_org_info()
_org_id: str | None = None
_org_name: str | None = None
_role: str | None = None
_permissions: list[str] | None = None
_org_info_loaded: bool = False
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
Returns:
The org_id if authenticated via API key with org binding, None otherwise
(cookie auth or legacy API keys without org binding).
"""
return self.api_key_org_id
async def get_user_id(self) -> str | None:
return self.user_id
@@ -228,6 +254,72 @@ class SaasUserAuth(UserAuth):
)
return mcp_api_key
async def get_org_info(self) -> dict | None:
"""Get organization info for the current user.
Lazily loads and caches organization data including:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission names for the role
Returns:
dict with org_id, org_name, role, permissions or None if not available
"""
if self._org_info_loaded:
if self._org_id is None:
return None
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
# Mark as loaded to avoid repeated attempts on failure
self._org_info_loaded = True
try:
# Get user and their current org
user = await UserStore.get_user_by_id(self.user_id)
if not user:
logger.warning(f'User {self.user_id} not found for org info')
return None
# Get the current org
org = await OrgStore.get_org_by_id(user.current_org_id)
if not org:
logger.warning(
f'Organization {user.current_org_id} not found for user {self.user_id}'
)
return None
# Get user's role in the current org
role = await get_user_org_role(self.user_id, user.current_org_id)
role_name = role.name if role else None
# Get permissions for the role
permissions: list[str] = []
if role_name:
role_permissions = get_role_permissions(role_name)
permissions = [p.value for p in role_permissions]
# Cache the results
self._org_id = str(user.current_org_id)
self._org_name = org.name
self._role = role_name
self._permissions = permissions
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
except Exception as e:
logger.error(f'Error fetching org info for user {self.user_id}: {e}')
return None
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
logger.debug('saas_user_auth_get_instance')
@@ -283,14 +375,19 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
return None
api_key_store = ApiKeyStore.get_instance()
user_id = await api_key_store.validate_api_key(api_key)
if not user_id:
validation_result = await api_key_store.validate_api_key(api_key)
if not validation_result:
return None
offline_token = await token_manager.load_offline_token(user_id)
offline_token = await token_manager.load_offline_token(
validation_result.user_id
)
saas_user_auth = SaasUserAuth(
user_id=user_id,
user_id=validation_result.user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
api_key_org_id=validation_result.org_id,
api_key_id=validation_result.key_id,
api_key_name=validation_result.key_name,
)
await saas_user_auth.refresh()
return saas_user_auth

View File

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

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