Compare commits

..

52 Commits

Author SHA1 Message Date
openhands cf877b5628 Require newer Poetry in app image
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:31:26 +00:00
openhands fb9958aff8 Normalize migration style
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:22:34 +00:00
openhands c1f5861eaf Fix migration lint
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:16:37 +00:00
openhands fa7f58b7c5 Consolidate enterprise user settings
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:08:45 +00:00
openhands a691bec7fc fix(settings): keep persistence user-facing
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 23:08:16 +00:00
openhands 7eb77c131d Fix frozen settings normalization
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:54:38 +00:00
openhands 858870a095 Format enterprise settings changes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:42:57 +00:00
openhands d65e5b5e46 Fix post-merge lint regressions
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:35:51 +00:00
openhands 2b0816f53a Merge main into settings schema PR
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:10:16 +00:00
openhands ab9536dc6b Scope SaaS agent settings to member fields
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:37:24 +00:00
openhands 9f5888315a Harden SaaS settings schema migration
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:31:39 +00:00
openhands fcefb872b6 Normalize canonical settings fields in frontend
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:13:31 +00:00
openhands b91cd0570e Fix enterprise settings schema for preview auth
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 00:33:22 +00:00
openhands e18775b391 fix: avoid websocket send fallback race in frontend tests
- send via the websocket hook's live ref-backed sender
- treat OPEN connection state as authoritative for socket delivery
- prevent flaky fallback to pending-message REST queue during sends

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 22:46:54 +00:00
openhands 495de35139 fix: restore enterprise settings compatibility for SDK schema PR
- add backward-compatible Settings setters and sdk_settings_values alias
- update SaaS settings store org default mapping for agent_settings
- refresh enterprise test helper for agent_settings-backed settings

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 22:14:11 +00:00
openhands be29d89b3c Merge main and refresh settings schema PR for current CI
- merge latest main into the gui settings schema branch
- regenerate root and enterprise lockfiles after dependency changes
- fix stale llm-settings test to use sdk_settings_schema

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 21:59:17 +00:00
Graham Neubig 2890b8c6ff fix: default settings entry point to LLM page instead of Integrations
Home page 'Settings' buttons (ConnectToProviderMessage, task-suggestions)
linked to /settings/integrations. Change to /settings so users land on the
LLM settings page (first nav item in OSS mode).

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 05:19:22 +09:00
Graham Neubig 39a846ccc3 fix: persist LLM provider prefix and show API key set indicator
- llm-settings.tsx: construct full model name (provider/model) in
  CriticalFields onChange, matching the convention used everywhere else
- settings.py: redact set secrets to '<hidden>' instead of None so the
  frontend can distinguish 'set but redacted' from 'not set'
- settings.py: reject '<hidden>' sentinel in _apply_settings_payload to
  prevent accidental overwrite of real secrets
- Fix llm-settings test to use agent_settings/agent_settings_schema names

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 04:57:50 +09:00
openhands cae7b6e72f chore: refresh SDK lockfile refs again
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:46:06 +00:00
openhands 7ca41486be chore: refresh SDK lockfile refs
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:39:41 +00:00
openhands 81c02623a1 merge: update settings schema branch with main and SDK
- merge latest main into the GUI settings schema branch
- keep schema-driven LLM settings page and tests after conflict resolution
- update lockfiles to SDK branch head c333aedd

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:35:30 +00:00
openhands 38dcf959bc fix: restore search_api_key check in hasAdvancedSettingsSet lost during merge
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:41:56 +00:00
openhands ef3acf726c style: apply ruff-format fixes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:35:25 +00:00
openhands 017d758a76 fix: skip frozen fields when applying settings payload
The secrets_store field on Settings is frozen, so setattr() raised a
validation error when the POST /api/settings payload included that key.

Filter out any frozen field before calling setattr in
_apply_settings_payload. Added a regression test.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:04:46 +00:00
openhands 3ed37e18ac Merge main into openhands/issue-2228-gui-settings-schema
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:00:39 +00:00
openhands 1322f944be refactor: settings are transparent — no backend auto-fill
critic_server_url and critic_model_name are now user-facing settings
exposed in the GUI. The backend no longer mutates them based on proxy
detection. _get_agent_settings is now a pure pass-through with model
override only.

SDK pin: b69f7cee

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 12:06:58 +00:00
openhands 5925483f6b chore: update SDK pin to 0030eed1 (mcp_config + schema versioning)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:58:15 +00:00
openhands 0144424c8e fix: remove dead security_analyzer override on Agent
security_analyzer lives in VerificationSettings, not on Agent.
The model_copy override was a no-op.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:54:20 +00:00
openhands f07ce85b45 refactor: flow mcp_config through settings, not runtime override
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:51:01 +00:00
openhands bc5a46dcee simplify: settings-transparent agent creation
- _get_agent_settings resolves ALL settings (model, critic endpoint)
- _create_agent just calls settings.create_agent() + runtime overrides
- Eliminated _get_default_critic, _apply_critic_proxy, _CRITIC_PROXY_PATTERN
- Removed legacy path (agent_settings is always present)
- Replaced mock-heavy tests with real-object assertions (-200 lines)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:44:41 +00:00
openhands 9990870060 refactor: use AgentSettings.create_agent() for V1 agent creation
Replace manual Agent construction with settings.create_agent() when
SDK AgentSettings are available.  This makes settings the single
source of truth for LLM, tools, condenser, critic, and agent context.

Key changes:
- _get_default_critic() eliminated — replaced by _apply_critic_proxy()
  which sets critic_server_url/critic_model_name on the settings, then
  lets the SDK's build_critic() do the construction.
- _create_agent_with_context() settings path: populate tools +
  agent_context on settings, call create_agent(), apply runtime-only
  overrides (mcp_config, system_prompt) via model_copy.
- Legacy path (no agent_settings) kept for backward compat.
- Tests updated: agent_context now in Agent() constructor,
  mcp_config/system_prompt in model_copy.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:30:02 +00:00
openhands bab9a45590 chore: regenerate lock files to pick up SDK VerificationSettings commit
Update poetry.lock, enterprise/poetry.lock, and uv.lock to resolve
to SDK commit bb601665 which includes the merged VerificationSettings.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 02:44:33 +00:00
openhands b4107ff9dc settings: merge Critic+Security into Verification, remove OpenHandsAgentSettings
- SDK: combined CriticSettings + SecuritySettings into VerificationSettings
  with backward-compat property accessors and type aliases
- Removed OpenHandsAgentSettings subclass — use AgentSettings directly
- Nav order: LLM → Condenser → Verification (was separate Security + Critic)
- Single verification-settings route replaces critic-settings + security-settings
- Updated _SDK_TO_FLAT_SETTINGS keys to verification.* namespace
- All 119 backend tests pass, frontend builds, lint clean

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 02:31:05 +00:00
openhands 3e04713097 Merge remote-tracking branch 'origin/main' into openhands/issue-2228-gui-settings-schema 2026-03-17 02:28:36 +00:00
openhands 77f868081c feat: add Security settings section via OpenHandsAgentSettings
Create OpenHandsAgentSettings(AgentSettings) in the OpenHands codebase
that extends the SDK's AgentSettings with a 'security' section containing
confirmation_mode (critical) and security_analyzer (major). The SDK's
export_schema() picks these up automatically via its metadata conventions.

Backend:
- SecuritySettings pydantic model with SDK metadata annotations
- OpenHandsAgentSettings subclass used by _get_sdk_settings_schema()
- _SDK_TO_FLAT_SETTINGS bridges dotted SDK keys to flat Settings attrs
  so existing consumers (session init, security-analyzer setup) work
- _extract_sdk_settings_values seeds from flat fields for UI display

Frontend:
- /settings/security route renders the security schema section
- Nav: LLM -> Security -> Condenser -> Critic (both SAAS and OSS)
- Removed empty General page (no schema section exists for it yet)

Tests:
- New test_get_sdk_settings_schema_includes_security_section
- All 119 backend + 10 frontend tests pass

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 02:08:07 +00:00
openhands 3a12924bc8 refactor: add General/Security pages, remove SDK_LEGACY_FIELD_MAP, fix inferInitialView
- Add /settings/general and /settings/security sidebar pages rendering
  their respective SDK schema sections
- Reorder nav: General above LLM, Security below LLM (both SAAS + OSS)
- Remove SDK_LEGACY_FIELD_MAP and all legacy field bridging — the only
  canonical store for SDK settings is now sdk_settings_values
- Simplify to_agent_settings(), _extract_sdk_settings_values(), and
  _apply_settings_payload() to read/write sdk_settings_values only
- Fix inferInitialView to accept an optional schemaOverride so
  SdkSectionPage passes filteredSchema (prevents cross-section
  minor-value overrides from elevating the view tier on unrelated pages)
- Add SETTINGS$NAV_GENERAL and SETTINGS$NAV_SECURITY i18n keys with
  translations for all 14 languages
- Use lock.svg for Security icon and settings.svg for General icon

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 01:46:11 +00:00
openhands cfa7def554 feat: split Condenser and Critic into separate sidebar settings pages
- Add /settings/condenser and /settings/critic routes alongside LLM
- Extract reusable SdkSectionPage component with render-prop header
- Extract SchemaField + FIELD_HELP_LINKS into shared sdk-settings module
- LLM page now only renders 'llm' section; condenser/critic each render
  their own section using the same generic SdkSectionPage
- Add nav items with MemoryIcon (Condenser) and LightbulbIcon (Critic)
  to both SAAS_NAV_ITEMS and OSS_NAV_ITEMS
- Add SETTINGS$NAV_CONDENSER and SETTINGS$NAV_CRITIC i18n keys with
  translations for all 14 supported languages
- Update tests to reflect LLM-only page scope

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 01:18:29 +00:00
openhands 33d6a11abf feat: three-tier view (Basic/Advanced/All), CriticalFields with ModelSelector, help links, and schema-driven sections
- Rewrite llm-settings.tsx with SettingsView = 'basic' | 'advanced' | 'all'
- Critical fields (llm.model, llm.api_key, llm.base_url) rendered with purpose-built
  components at the top: ModelSelector for model, SettingsInput for API key/base URL
- Generic schema-driven fields rendered below, excluding specially-rendered keys
- UI-only help link mapping (FIELD_HELP_LINKS) for API key guidance
- Add SETTINGS$ALL i18n key with translations for all supported languages
- Update sdk-settings-schema.ts with isFieldVisibleInView, inferInitialView,
  hasAdvancedSettings, hasMinorSettings, SPECIALLY_RENDERED_KEYS
- Fix no-continue lint error
- Add llm.base_url to mock schema and test fixtures
- Update all tests for three-tier view and CriticalFields rendering
- Remove search_api_key from has-advanced-settings-set.ts
- Merge main into branch

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 22:49:37 +00:00
openhands 71d5aa5aa8 Merge remote-tracking branch 'origin/main' into openhands/issue-2228-gui-settings-schema 2026-03-16 22:44:22 +00:00
openhands 90d2681e34 fix: handle git-based SDK deps in enterprise Docker build
Strip git-based openhands SDK dependencies from the exported
requirements.txt in the enterprise Dockerfile. These packages are
already installed via the base app image and cannot have their hashes
verified by pip when using git branch references.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 19:18:46 +00:00
openhands 565a5702c3 fix: pin Poetry >=2.3.0 in Dockerfile to match lockfile hash algorithm
Poetry 2.3.0 changed the content-hash algorithm to include dependency
groups. The Docker build cache had an older Poetry version that computed
a different hash, causing 'pyproject.toml changed significantly' errors.
Pinning >=2.3.0 ensures the Dockerfile installs a compatible version and
also busts the stale cache layer.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 19:04:55 +00:00
openhands 4b9097068d chore: regenerate lockfiles for Docker build compatibility
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 18:50:50 +00:00
openhands c9a5834164 Merge main and resolve conflicts for SDK settings schema PR
- Resolved merge conflicts in 5 files keeping both PR and main changes
- Fixed TestLoadHooksFromWorkspace missing pending_message_service and
  max_num_conversations_per_sandbox constructor args
- Removed unused UUID import flagged by ruff

All 118 targeted tests pass, frontend builds cleanly, pre-commit checks pass.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 17:55:03 +00:00
openhands 19a089aa4b Merge main and fix settings schema CI
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-15 19:51:27 +00:00
openhands 918c44d164 Merge main and align settings schema with latest SDK
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 13:40:07 +00:00
openhands e06e20a5ba fix: refresh SDK locks and settings schema coverage
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 17:55:59 +00:00
openhands 430ee1c9fd Point OpenHands dependencies at the SDK branch
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 01:32:57 +00:00
openhands a03377698c Consume SDK AgentSettings schema in OpenHands
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 01:18:53 +00:00
openhands 9dab5b1bbf test: stub SDK schema in settings API coverage (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 21:43:35 +00:00
openhands 135d5fbd38 settings: fix schema-driven settings follow-ups (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 21:09:10 +00:00
openhands ad615ebc8b settings: use generic sdk settings values in OpenHands
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 20:42:35 +00:00
openhands 424f6b30d1 settings: expose SDK settings schema to OpenHands (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 20:10:48 +00:00
597 changed files with 21917 additions and 32108 deletions
+8
View File
@@ -0,0 +1,8 @@
# 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
+3 -5
View File
@@ -4,7 +4,7 @@ updates:
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 5
open-pull-requests-limit: 1
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: 5
open-pull-requests-limit: 1
groups:
docusaurus:
patterns:
@@ -51,7 +51,7 @@ updates:
schedule:
interval: "weekly"
day: "wednesday"
open-pull-requests-limit: 5
open-pull-requests-limit: 1
groups:
docusaurus:
patterns:
@@ -72,11 +72,9 @@ updates:
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
- package-ecosystem: "docker"
directories:
- "containers/*"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
+27 -35
View File
@@ -1,46 +1,38 @@
<!-- Keep this PR as draft until it is ready for review. -->
<!-- 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 -->
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
## Summary of PR
- [ ] A human has tested these changes.
<!-- Summarize what the PR does -->
---
## Demo Screenshots/Videos
## Why
<!-- 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. -->
<!-- Describe problem, motivation, etc.-->
## Change Type
## 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
<!-- Choose the types that apply to your PR -->
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] New feature
- [ ] Breaking change
- [ ] Docs / chore
- [ ] Refactor
- [ ] Other (dependency update, docs, typo fixes, etc.)
## Notes
## 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. -->
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->
- [ ] 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.
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v6
uses: actions/checkout@v4
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@v4
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
+3 -5
View File
@@ -17,20 +17,18 @@ concurrency:
jobs:
fe-e2e-test:
name: FE E2E Tests
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
+3 -5
View File
@@ -21,20 +21,18 @@ jobs:
# Run frontend unit tests
fe-test:
name: FE Unit Tests
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
+21 -27
View File
@@ -30,42 +30,37 @@ env:
jobs:
define-matrix:
runs-on: ubuntu-latest
runs-on: blacksmith
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
platforms: ${{ steps.define-base-images.outputs.platforms }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
platforms="linux/amd64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
platforms="linux/amd64,linux/arm64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
needs: define-matrix
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -87,12 +82,12 @@ jobs:
- name: Build and push app image
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push -p ${{ needs.define-matrix.outputs.platforms }}
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
runs-on: ubuntu-22.04
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
@@ -103,7 +98,7 @@ jobs:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -122,7 +117,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
@@ -141,7 +136,7 @@ jobs:
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
@@ -149,7 +144,7 @@ jobs:
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
with:
push: true
tags: ${{ env.DOCKER_TAGS }}
@@ -163,7 +158,7 @@ jobs:
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
@@ -176,7 +171,7 @@ jobs:
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: ubuntu-22.04
runs-on: blacksmith-8vcpu-ubuntu-2204
permissions:
contents: read
packages: write
@@ -185,7 +180,7 @@ jobs:
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -215,7 +210,6 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=match,pattern=cloud-\d+\.\d+\.\d+
flavor: |
latest=auto
prefix=
@@ -229,7 +223,7 @@ jobs:
# rather than a mutable branch tag like "main" which can serve stale cached layers.
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
with:
context: .
file: enterprise/Dockerfile
@@ -248,7 +242,7 @@ jobs:
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -257,10 +251,10 @@ jobs:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
needs: [ghcr_build_runtime]
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Get short SHA
id: short_sha
+9 -10
View File
@@ -9,12 +9,12 @@ jobs:
lint-fix-frontend:
if: github.event.label.name == 'lint-fix'
name: Fix frontend linting issues
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -22,14 +22,13 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 22
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
run: |
cd frontend
npm install --frozen-lockfile
- name: Generate i18n and route types
run: |
cd frontend
@@ -59,12 +58,12 @@ jobs:
lint-fix-python:
if: github.event.label.name == 'lint-fix'
name: Fix Python linting issues
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -72,7 +71,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
+13 -14
View File
@@ -19,35 +19,34 @@ jobs:
# Run lint on the frontend code
lint-frontend:
name: Lint frontend
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install Node.js 22
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
run: |
cd frontend
npm install --frozen-lockfile
- name: Lint, TypeScript compilation, and translation checks
run: |
cd frontend
npm run lint
npm run make-i18n && npx tsc
npm run make-i18n && tsc
npm run check-translation-completeness
# Run lint on the python code
lint-python:
name: Lint python
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
@@ -58,13 +57,13 @@ jobs:
lint-enterprise-python:
name: Lint enterprise python
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
+4 -4
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
check-version:
name: Check if version has changed
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
defaults:
run:
shell: bash
@@ -27,7 +27,7 @@ jobs:
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need previous commit to compare
@@ -55,7 +55,7 @@ jobs:
publish:
name: Publish to npm
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
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@v6
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Download review trace artifact
id: download-trace
uses: dawidd6/action-download-artifact@v15
uses: dawidd6/action-download-artifact@v6
continue-on-error: true
with:
workflow: pr-review-by-openhands.yml
+9 -11
View File
@@ -19,7 +19,7 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
@@ -30,22 +30,20 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- 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: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
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: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -75,16 +73,16 @@ jobs:
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -113,9 +111,9 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v6
id: download
with:
pattern: coverage-*
+5 -5
View File
@@ -17,14 +17,14 @@ on:
jobs:
release:
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-'
runs-on: blacksmith-4vcpu-ubuntu-2204
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli'
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/checkout@v4
- uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
- name: Install Poetry
+2 -2
View File
@@ -8,10 +8,10 @@ on:
jobs:
stale:
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v10
- uses: actions/stale@v9
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.'
+2 -2
View File
@@ -19,10 +19,10 @@ concurrency:
jobs:
ui-build:
name: Build openhands-ui
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
+15 -36
View File
@@ -1,6 +1,21 @@
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
## Repository Memory
- Legacy `/api/settings` responses can bridge to the SDK by returning `sdk_settings_schema` from `openhands.sdk.settings` when that package is available. Use this as the compatibility handoff while V1 settings work moves into the SDK and newer clients.
- The legacy LLM settings screen now renders SDK-backed sections from `sdk_settings_schema` and reads/writes values through the generic settings blob. The canonical backend field is `agent_settings`; `sdk_settings_values` is a compatibility alias for older callers.
- In enterprise mode, persist the generic SDK settings blob in `agent_settings` on `enterprise/storage/org_member.py` and `enterprise/storage/user_settings.py`. Keep a `sdk_settings_values` alias only for compatibility with older tests/callers.
- Persisted SaaS `agent_settings` should carry a `schema_version` and canonical dotted keys, but should not duplicate secret SDK values like `llm.api_key` in plaintext JSON. Reconstruct those from encrypted legacy columns on load, and backfill/migrate rows on read/write.
- The frontend settings query still normalizes canonical backend fields (`agent_settings`, `agent_settings_schema`) back into legacy `sdk_settings_values` / `sdk_settings_schema` for existing settings screens. Strip both canonical and legacy schema/value blobs from save payloads so redacted GET metadata is never POSTed back.
- The SDK settings schema now uses neutral metadata (`value_type`, `prominence`, `choices`, `depends_on`) instead of legacy UI-only fields like `widget`, `advanced`, or `placeholder`. Frontend helpers should derive control types from `value_type`/`choices`, and dotted `sdk_settings_values` may include structured JSON objects/arrays.
- When constructing runtime `LLM`s for `openhands/*` models, keep explicit user-provided `llm.base_url` overrides, but prefer the app's `openhands_provider_base_url` when the user did not set one. Newer SDK defaults may populate an OpenHands proxy URL automatically, so check persisted user settings rather than `AgentSettings.llm.base_url` alone.
- SDK `AgentSettings` sections are: `llm`, `condenser`, `verification`. The `verification` section merges former `critic` + `security` settings into one `VerificationSettings` model. Backward-compat property accessors (`.critic`, `.security`, `.enabled`, `.mode`, `.threshold`) and type aliases (`CriticSettings`, `SecuritySettings`) are preserved. Do NOT subclass `AgentSettings` in OpenHands — use it directly.
## General Setup:
To set up the entire repo, including frontend and backend, run `make build`.
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
@@ -36,42 +51,6 @@ 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.
+1 -68
View File
@@ -23,6 +23,7 @@
<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>
@@ -83,71 +84,3 @@ 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>
+1 -1
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-slim"
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22"
# Use host network
#use_host_network = false
+2 -15
View File
@@ -20,11 +20,9 @@ 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==${POETRY_VERSION}" --break-system-packages
&& python3 -m pip install "poetry>=2.3.0" --break-system-packages
COPY pyproject.toml poetry.lock ./
RUN touch README.md
@@ -52,7 +50,7 @@ RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE
RUN apt-get update -y \
&& apt-get install -y curl git ssh sudo \
&& apt-get install -y curl ssh sudo \
&& rm -rf /var/lib/apt/lists/*
# Default is 1000, but OSX is often 501
@@ -75,17 +73,6 @@ 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
+3 -8
View File
@@ -8,17 +8,15 @@ push=0
load=0
tag_suffix=""
dry_run=0
platform_override=""
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--dry]"
echo "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 " -p: Platform(s) to build for (e.g. linux/amd64 or linux/amd64,linux/arm64)"
echo " --dry: Don't build, only create build-args.json"
exit 1
}
@@ -31,7 +29,6 @@ while [[ $# -gt 0 ]]; do
--push) push=1; shift ;;
--load) load=1; shift ;;
-t) tag_suffix="$2"; shift 2 ;;
-p) platform_override="$2"; shift 2 ;;
--dry) dry_run=1; shift ;;
*) usage ;;
esac
@@ -137,10 +134,8 @@ fi
echo "Args: $args"
# Determine the platform(s) to build for
if [[ -n "$platform_override" ]]; then
platform="$platform_override"
elif [[ $load -eq 1 ]]; then
# 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
+1 -1
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.15.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
@@ -58,8 +58,6 @@ repos:
types-Markdown,
pydantic,
lxml,
"openhands-sdk==1.14",
"openhands-tools==1.14",
]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/
-8
View File
@@ -14,11 +14,3 @@ 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
+1 -1
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.15.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.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:
+2 -1
View File
@@ -33,7 +33,8 @@ RUN cd /tmp/enterprise && \
# Export only main dependencies with hashes for supply chain security
/app/.venv/bin/poetry export --only main -o requirements.txt && \
# Remove the local path dependency (openhands-ai is already in base image)
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
# and git-based SDK dependencies (already installed via the base app image)
sed -i '/^-e /d; /openhands-ai/d; /^openhands-.*@ git+/d' requirements.txt && \
# Install pinned dependencies from lock file
/app/.venv/bin/pip install -r requirements.txt && \
# Cleanup - return to /app before removing /tmp/enterprise
@@ -723,13 +723,11 @@
"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/*",
"https://laminar.$WEB_HOST/api/auth/callback/keycloak"
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
],
"webOrigins": [
"https://$WEB_HOST",
"https://$AUTH_WEB_HOST",
"https://laminar.$WEB_HOST"
"https://$AUTH_WEB_HOST"
],
"notBefore": 0,
"bearerOnly": false,
@@ -1729,7 +1727,7 @@
"syncMode": "IMPORT",
"clientSecret": "$GITHUB_APP_CLIENT_SECRET",
"caseSensitiveOriginalUsername": "false",
"defaultScope": "openid email profile notifications",
"defaultScope": "openid email profile",
"baseUrl": "$GITHUB_BASE_URL"
}
},
@@ -43,20 +43,15 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitHub V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
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
)
+13 -33
View File
@@ -10,7 +10,6 @@ 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,
@@ -27,7 +26,6 @@ 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
@@ -43,14 +41,16 @@ from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import start_conversation
from openhands.server.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,17 +154,12 @@ 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}'
)
@@ -178,28 +173,16 @@ class GithubIssue(ResolverViewInterface):
selected_repository=self.full_repo_name,
)
# 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,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
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,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -311,10 +294,7 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@@ -342,7 +322,7 @@ class GithubIssue(ResolverViewInterface):
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
},
should_request_summary=self.send_summary_instruction,
send_summary_instruction=self.send_summary_instruction,
)
@@ -496,7 +476,7 @@ class GithubInlinePRComment(GithubPRComment):
'comment_id': self.comment_id,
},
inline_pr_comment=True,
should_request_summary=self.send_summary_instruction,
send_summary_instruction=self.send_summary_instruction,
)
@@ -41,20 +41,15 @@ class GitlabV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitLab V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
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
)
+10 -33
View File
@@ -3,7 +3,6 @@ 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,
@@ -15,7 +14,6 @@ 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
@@ -31,13 +29,15 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import start_conversation
from openhands.server.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,14 +118,6 @@ 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
@@ -136,28 +128,16 @@ class GitlabIssue(ResolverViewInterface):
selected_repository=self.full_repo_name,
)
# 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,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
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,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -248,10 +228,7 @@ class GitlabIssue(ResolverViewInterface):
)
# Set up the GitLab user context for the V1 system
gitlab_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
async with get_app_conversation_service(
@@ -283,7 +260,7 @@ class GitlabIssue(ResolverViewInterface):
'is_mr': self.is_mr,
'discussion_id': getattr(self, 'discussion_id', None),
},
should_request_summary=self.send_summary_instruction,
send_summary_instruction=self.send_summary_instruction,
)
+9 -68
View File
@@ -7,7 +7,6 @@ Views are responsible for:
"""
from dataclasses import dataclass, field
from uuid import uuid4
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
@@ -16,25 +15,18 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from jinja2 import Environment
from server.config import get_config
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from storage.saas_conversation_store import SaasConversationStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import start_conversation
from openhands.server.services.conversation_service import create_new_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
@@ -174,68 +166,20 @@ class JiraNewConversationView(JiraViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
user_id = self.jira_user.keycloak_user_id
# Resolve git provider from repository
resolved_git_provider = None
if provider_tokens:
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(
self.selected_repo
)
resolved_git_provider = repository.git_provider
except Exception as e:
logger.warning(
f'[Jira] Failed to resolve git provider for {self.selected_repo}: {e}'
)
# Resolve target org based on claimed git organizations
resolved_org_id = None
if resolved_git_provider and self.selected_repo:
try:
resolved_org_id = await resolve_org_for_repo(
provider=resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=user_id,
)
except Exception as e:
logger.warning(
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
)
# Create the conversation store with resolver org routing
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.JIRA,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
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,
git_provider=resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
conversation_trigger=ConversationTrigger.JIRA,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
self.conversation_id = conversation_id
self.conversation_id = agent_loop_info.conversation_id
logger.info(
'[Jira] Created conversation',
@@ -243,9 +187,6 @@ class JiraNewConversationView(JiraViewInterface):
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
'resolved_org_id': str(resolved_org_id)
if resolved_org_id
else None,
},
)
+9 -68
View File
@@ -1,34 +1,25 @@
from dataclasses import dataclass
from uuid import uuid4
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from server.config import get_config
from storage.linear_conversation import LinearConversation
from storage.linear_integration_store import LinearIntegrationStore
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from storage.saas_conversation_store import SaasConversationStore
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
integration_store = LinearIntegrationStore.get_instance()
@@ -70,70 +61,20 @@ class LinearNewConversationView(LinearViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
user_id = self.linear_user.keycloak_user_id
# Resolve git provider from repository
resolved_git_provider = None
if provider_tokens:
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(
self.selected_repo
)
resolved_git_provider = repository.git_provider
except Exception as e:
logger.warning(
f'[Linear] Failed to resolve git provider for {self.selected_repo}: {e}'
)
# Resolve target org based on claimed git organizations
resolved_org_id = None
if resolved_git_provider and self.selected_repo:
try:
resolved_org_id = await resolve_org_for_repo(
provider=resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=user_id,
)
except Exception as e:
logger.warning(
f'[Linear] Failed to resolve org for {self.selected_repo}: {e}'
)
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.LINEAR,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
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,
git_provider=resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
conversation_trigger=ConversationTrigger.LINEAR,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
self.conversation_id = conversation_id
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Linear] Created conversation {self.conversation_id}')
+1 -8
View File
@@ -1,9 +1,7 @@
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, UserGitInfo
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -14,10 +12,8 @@ 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:
@@ -85,6 +81,3 @@ 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()
@@ -1,68 +0,0 @@
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
This module provides a reusable utility for routing resolver conversations
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
workspace based on claimed Git organizations.
"""
from uuid import UUID
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
async def resolve_org_for_repo(
provider: str,
full_repo_name: str,
keycloak_user_id: str,
) -> UUID | None:
"""Determine the OpenHands org_id for a resolver conversation.
If the repo's git organization is claimed by an OpenHands org AND the user
is a member of that org, returns the claiming org's ID. Otherwise returns
None (caller should fall back to user.current_org_id / personal workspace).
Args:
provider: Git provider name ("github", "gitlab", "bitbucket")
full_repo_name: Full repository name (e.g., "OpenHands/foo")
keycloak_user_id: The user's Keycloak UUID string
Returns:
The org_id if the repo's org is claimed and user is a member, else None
"""
git_org = full_repo_name.split('/')[0].lower()
try:
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider, git_org
)
if not claim:
logger.debug(
f'[OrgResolver] No claim found for {provider}/{git_org}',
)
return None
member = await OrgMemberStore.get_org_member(
claim.org_id, UUID(keycloak_user_id)
)
if not member:
logger.debug(
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
f'{claim.org_id} (claimed {provider}/{git_org}). '
f'Falling back to personal workspace.',
)
return None
logger.info(
f'[OrgResolver] Routing conversation to org {claim.org_id} '
f'for {provider}/{git_org} (user {keycloak_user_id})',
)
return claim.org_id
except Exception as e:
logger.error(
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
exc_info=True,
)
return None
+35 -82
View File
@@ -239,14 +239,12 @@ 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 with immediate "No Repository" button and search dropdown.
"""Generate a repo selection form using external_select for dynamic loading.
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.
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)
Args:
message_ts: The message timestamp for tracking
@@ -268,22 +266,12 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': 'Select a repository or continue without one:',
'text': 'Type to search your repositories:',
},
},
{
'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}',
@@ -291,8 +279,8 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'plain_text',
'text': 'Search repositories...',
},
'min_query_length': 0,
},
'min_query_length': 0, # Load initial options immediately
}
],
},
]
@@ -300,11 +288,8 @@ class SlackManager(Manager[SlackViewInterface]):
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
"""Build Slack options list from repositories.
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.
Always includes a "No Repository" option at the top, followed by up to 99
repositories (Slack has a 100 option limit for external_select).
Args:
repos: List of Repository objects
@@ -312,7 +297,13 @@ class SlackManager(Manager[SlackViewInterface]):
Returns:
List of Slack option objects
"""
return [
options: list[dict[str, Any]] = [
{
'text': {'type': 'plain_text', 'text': 'No Repository'},
'value': '-',
}
]
options.extend(
{
'text': {
'type': 'plain_text',
@@ -320,8 +311,9 @@ class SlackManager(Manager[SlackViewInterface]):
},
'value': repo.full_name,
}
for repo in repos[:100]
]
for repo in repos[:99] # Leave room for "No Repository" option
)
return options
async def search_repos_for_slack(
self, user_auth: UserAuth, query: str, per_page: int = 20
@@ -371,69 +363,33 @@ class SlackManager(Manager[SlackViewInterface]):
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
)
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 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).
"""Process a Slack form interaction (repository selection).
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
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.
Args:
slack_payload: The raw Slack interaction payload
"""
# Extract fields from the Slack interaction payload
action = slack_payload['actions'][0]
selected_repository = slack_payload['actions'][0]['selected_option']['value']
if selected_repository == '-':
selected_repository = None
slack_user_id = slack_payload['user']['id']
channel_id = slack_payload['container']['channel_id']
team_id = slack_payload['team']['id']
# 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
# 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
message_ts, thread_ts, selected_value = parsed
# Build partial payload for error handling
# Build partial payload for error handling during Redis retrieval
payload = {
'team_id': team_id,
'channel_id': channel_id,
@@ -442,9 +398,6 @@ 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)
@@ -40,20 +40,16 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for Slack V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
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)
@@ -111,11 +107,9 @@ 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,
markdown_text=summary,
text=summary,
thread_ts=thread_ts,
unfurl_links=False,
unfurl_media=False,
+25 -58
View File
@@ -4,7 +4,6 @@ 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,
@@ -18,9 +17,7 @@ from integrations.utils import (
get_user_v1_enabled_setting,
)
from jinja2 import Environment
from server.config import get_config
from slack_sdk import WebClient
from storage.saas_conversation_store import SaasConversationStore
from storage.slack_conversation import SlackConversation
from storage.slack_conversation_store import SlackConversationStore
from storage.slack_team_store import SlackTeamStore
@@ -39,20 +36,18 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import GENERAL_TIMEOUT
from openhands.utils.conversation_summary import get_default_conversation_title
# =================================================
# SECTION: Slack view types
@@ -207,22 +202,6 @@ class SlackNewConversationView(SlackViewInterface):
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Determine git provider from repository (needed for both org routing and conversation creation)
self._resolved_git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
self._resolved_git_provider = repository.git_provider
# Resolve target org based on claimed git organizations
self.resolved_org_id = None
if self._resolved_git_provider and self.selected_repo:
self.resolved_org_id = await resolve_org_for_repo(
provider=self._resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
)
# Check if V1 conversations are enabled for this user
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
self.slack_to_openhands_user.keycloak_user_id
@@ -245,44 +224,30 @@ class SlackNewConversationView(SlackViewInterface):
jinja
)
user_id = self.slack_to_openhands_user.keycloak_user_id
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
self.resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.SLACK,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
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,
git_provider=self._resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = conversation_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)
@@ -300,8 +265,13 @@ class SlackNewConversationView(SlackViewInterface):
# Create the Slack V1 callback processor
slack_callback_processor = self._create_slack_v1_callback_processor()
# Use git provider resolved in create_or_update_conversation
git_provider = self._resolved_git_provider
# Determine git provider from repository
git_provider = None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = ProviderType(repository.git_provider.value)
# Get the app conversation service and start the conversation
injector_state = InjectorState()
@@ -322,10 +292,7 @@ class SlackNewConversationView(SlackViewInterface):
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(
+23 -21
View File
@@ -100,25 +100,27 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
return bool(payment_methods.data)
async def migrate_customer(session, user_id: str, org: Org):
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
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)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
await session.commit()
+1 -5
View File
@@ -8,7 +8,7 @@ logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine, text # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata
@@ -109,10 +109,6 @@ 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()
@@ -0,0 +1,142 @@
"""Add agent_settings columns to enterprise settings tables.
Revision ID: 102
Revises: 101
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 = '102'
down_revision: Union[str, None] = '101'
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(
'org_member',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
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 = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'max_iterations', max_iterations
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.alter_column('user_settings', 'agent_settings', server_default=None)
op.alter_column('org_member', 'agent_settings', 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')
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.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.drop_column('org_member', 'agent_settings')
op.drop_column('user_settings', 'agent_settings')
@@ -1,28 +0,0 @@
"""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')
@@ -1,42 +0,0 @@
"""Add mcp_config to org_member for user-specific MCP settings.
Revision ID: 103
Revises: 102
Create Date: 2026-03-26
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '103'
down_revision: Union[str, None] = '102'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
# Migrate existing org-level MCP configs to all members in each org.
# This preserves existing configurations while transitioning to user-specific settings.
conn = op.get_bind()
orgs_with_config = conn.execute(
sa.text('SELECT id, mcp_config FROM org WHERE mcp_config IS NOT NULL')
).fetchall()
for org_id, mcp_config in orgs_with_config:
conn.execute(
sa.text(
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
),
{'config': json.dumps(mcp_config), 'org_id': str(org_id)},
)
def downgrade() -> None:
op.drop_column('org_member', 'mcp_config')
@@ -1,29 +0,0 @@
"""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')
@@ -1,37 +0,0 @@
"""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')
@@ -1,32 +0,0 @@
"""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')
+1201 -1354
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -64,7 +64,6 @@ pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
flake8 = "*"
freezegun = "^1.5.1"
openai = "*"
opencv-python = "*"
pandas = "*"
-7
View File
@@ -49,9 +49,6 @@ from server.routes.readiness import readiness_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.routes.users_v1 import ( # noqa: E402
override_users_me_endpoint,
)
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
@@ -126,10 +123,6 @@ 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)
+1 -101
View File
@@ -41,7 +41,7 @@ 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_auth, get_user_id
from openhands.server.user_auth import get_user_id
class Permission(str, Enum):
@@ -84,9 +84,6 @@ class Permission(str, Enum):
# Temporary permissions until we finish the API updates.
EDIT_ORG_SETTINGS = 'edit_org_settings'
# Git organization claims
MANAGE_ORG_CLAIMS = 'manage_org_claims'
class RoleName(str, Enum):
"""Role names used in the system."""
@@ -121,8 +118,6 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Organization Management (Owner only)
Permission.CHANGE_ORGANIZATION_NAME,
Permission.DELETE_ORGANIZATION,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
]
),
RoleName.ADMIN: frozenset(
@@ -144,8 +139,6 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Organization Management
Permission.VIEW_ORG_SETTINGS,
Permission.EDIT_ORG_SETTINGS,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
]
),
RoleName.MEMBER: frozenset(
@@ -318,96 +311,3 @@ 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
+1
View File
@@ -6,6 +6,7 @@ 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(
+2 -1
View File
@@ -4,6 +4,7 @@ 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,
@@ -11,7 +12,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_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
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}'
)
_keycloak_instances = {}
-78
View File
@@ -14,10 +14,6 @@ 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
@@ -27,12 +23,10 @@ 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 (
@@ -70,12 +64,6 @@ class SaasUserAuth(UserAuth):
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
# Organization context fields - populated lazily via get_org_info()
_org_id: str | None = None
_org_name: str | None = None
_role: str | None = None
_permissions: list[str] | None = None
_org_info_loaded: bool = False
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
@@ -254,72 +242,6 @@ 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')
+2 -3
View File
@@ -80,11 +80,10 @@ def setup_json_logger(
handler.setLevel(level)
formatter = JsonFormatter(
'%(message)s%(levelname)s%(module)s%(funcName)s%(lineno)d',
'{message}{levelname}',
style='{',
rename_fields={'levelname': 'severity'},
json_serializer=custom_json_serializer,
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)
timestamp='ts' if not LOG_JSON_FOR_CONSOLE else False,
)
handler.setFormatter(formatter)
-1
View File
@@ -1 +0,0 @@
# Enterprise server models
-16
View File
@@ -1,16 +0,0 @@
"""SAAS-specific user models that extend OSS UserInfo with organization fields."""
from openhands.app_server.user.user_models import UserInfo
class SaasUserInfo(UserInfo):
"""User info model for SAAS mode with organization context.
Extends the base UserInfo with SAAS-specific fields for organization
membership, role, and permissions.
"""
org_id: str | None = None
org_name: str | None = None
role: str | None = None
permissions: list[str] | None = None
-17
View File
@@ -172,23 +172,6 @@ async def keycloak_callback(
authorization = await user_authorizer.authorize_user(user_info)
if not authorization.success:
# For duplicate_email errors, clean up the newly created Keycloak user
# (only if they're not already in our UserStore, i.e., they're a new user)
if authorization.error_detail == 'duplicate_email':
try:
existing_user = await UserStore.get_user_by_id(user_info.sub)
if not existing_user:
# New user created during OAuth should be deleted from Keycloak
await token_manager.delete_keycloak_user(user_info.sub)
logger.info(
f'Deleted orphaned Keycloak user {user_info.sub} '
'after duplicate_email rejection'
)
except Exception as e:
# Log but don't fail - user should still get 401 response
logger.warning(
f'Failed to clean up orphaned Keycloak user {user_info.sub}: {e}'
)
# Return unauthorized
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+1 -1
View File
@@ -7,8 +7,8 @@ from storage.database import a_session_maker
from storage.feedback import ConversationFeedback
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.events.event_store import EventStore
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import file_store
from openhands.server.user_auth import get_user_id
@@ -335,9 +335,6 @@ async def on_options_load(request: Request, background_tasks: BackgroundTasks):
2. Searches for repositories matching the user's query
3. Returns up to 100 options for the dropdown
Note: "No Repository" is handled by a separate button in the form, so it's
not included in the dropdown options. Error cases return an empty list.
Configuration: Set the Options Load URL in Slack App settings to:
https://your-domain/slack/on-options-load
"""
@@ -120,18 +120,3 @@ class BatchInvitationResponse(BaseModel):
successful: list[InvitationResponse]
failed: list[InvitationFailure]
class AcceptInvitationRequest(BaseModel):
"""Request model for accepting an invitation via POST."""
token: str
class AcceptInvitationResponse(BaseModel):
"""Response model for successful invitation acceptance."""
success: bool
org_id: str
org_name: str
role: str
+39 -77
View File
@@ -5,8 +5,6 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from server.routes.org_invitation_models import (
AcceptInvitationRequest,
AcceptInvitationResponse,
BatchInvitationResponse,
EmailMismatchError,
InsufficientPermissionError,
@@ -19,11 +17,10 @@ from server.routes.org_invitation_models import (
)
from server.services.org_invitation_service import OrgInvitationService
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from storage.org_store import OrgStore
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth.user_auth import get_user_auth
# Router for invitation operations on an organization (requires org_id)
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
@@ -126,93 +123,70 @@ async def create_invitation(
@accept_router.get('/accept')
async def accept_invitation_redirect(
async def accept_invitation(
token: str,
request: Request,
):
"""Redirect invitation acceptance to frontend.
"""Accept an organization invitation via token.
This endpoint is accessed via the link in the invitation email.
It always redirects to the home page with the invitation token,
allowing the frontend to handle the acceptance flow via a modal.
This approach works with SameSite='strict' cookies because:
- Cross-site navigation (clicking email link) doesn't send cookies
- But same-origin POST requests (from frontend) DO send cookies
Flow:
1. If user is authenticated: Accept invitation directly and redirect to home
2. If user is not authenticated: Redirect to login page with invitation token
- Frontend stores token and includes it in OAuth state during login
- After authentication, keycloak_callback processes the invitation
Args:
token: The invitation token from the email link
request: FastAPI request
Returns:
RedirectResponse: Redirect to home page with invitation_token query param
RedirectResponse: Redirect to home page on success, or login page if not authenticated,
or home page with error query params on failure
"""
base_url = str(request.base_url).rstrip('/')
logger.info(
'Invitation accept: redirecting to frontend for acceptance',
extra={'token_prefix': token[:10] + '...'},
)
return RedirectResponse(f'{base_url}/?invitation_token={token}', status_code=302)
@accept_router.post('/accept', response_model=AcceptInvitationResponse)
async def accept_invitation(
request_data: AcceptInvitationRequest,
user_id: str = Depends(get_user_id),
):
"""Accept an organization invitation via authenticated POST request.
This endpoint is called by the frontend after displaying the acceptance modal.
Requires authentication - cookies are sent because this is a same-origin request.
Args:
request_data: Contains the invitation token
user_id: Authenticated user ID (from dependency)
Returns:
AcceptInvitationResponse: Success response with organization details
Raises:
HTTPException 400: Invalid or expired token
HTTPException 403: Email mismatch
HTTPException 409: User already a member
"""
token = request_data.token
# Try to get user_id from auth (may not be authenticated)
user_id = None
try:
invitation = await OrgInvitationService.accept_invitation(token, UUID(user_id))
user_auth = await get_user_auth(request)
if user_auth:
user_id = await user_auth.get_user_id()
except Exception:
pass
# Get organization and role details for response
org = await OrgStore.get_org_by_id(invitation.org_id)
role = await RoleStore.get_role_by_id(invitation.role_id)
if not user_id:
# User not authenticated - redirect to login page with invitation token
# Frontend will store the token and include it in OAuth state during login
logger.info(
'Invitation accept: redirecting unauthenticated user to login',
extra={'token_prefix': token[:10] + '...'},
)
login_url = f'{base_url}/login?invitation_token={token}'
return RedirectResponse(login_url, status_code=302)
# User is authenticated - process the invitation directly
try:
await OrgInvitationService.accept_invitation(token, UUID(user_id))
logger.info(
'Invitation accepted via API',
'Invitation accepted successfully',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'org_id': str(invitation.org_id),
},
)
return AcceptInvitationResponse(
success=True,
org_id=str(invitation.org_id),
org_name=org.name if org else '',
role=role.name if role else '',
)
# Redirect to home page on success
return RedirectResponse(f'{base_url}/', status_code=302)
except InvitationExpiredError:
logger.warning(
'Invitation accept failed: expired',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='invitation_expired',
)
return RedirectResponse(f'{base_url}/?invitation_expired=true', status_code=302)
except InvitationInvalidError as e:
logger.warning(
@@ -223,20 +197,14 @@ async def accept_invitation(
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='invitation_invalid',
)
return RedirectResponse(f'{base_url}/?invitation_invalid=true', status_code=302)
except UserAlreadyMemberError:
logger.info(
'Invitation accept: user already member',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='already_member',
)
return RedirectResponse(f'{base_url}/?already_member=true', status_code=302)
except EmailMismatchError as e:
logger.warning(
@@ -247,21 +215,15 @@ async def accept_invitation(
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='email_mismatch',
)
return RedirectResponse(f'{base_url}/?email_mismatch=true', status_code=302)
except Exception as e:
logger.exception(
'Unexpected error accepting invitation via API',
'Unexpected error accepting invitation',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
return RedirectResponse(f'{base_url}/?invitation_error=true', status_code=302)
+1 -69
View File
@@ -241,6 +241,7 @@ class OrgUpdate(BaseModel):
enable_proactive_conversation_starters: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
mcp_config: dict | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = Field(default=None, gt=0)
enable_solvability_analysis: bool | None = None
@@ -483,72 +484,3 @@ class OrgAppSettingsUpdate(BaseModel):
if v is not None and v <= 0:
raise ValueError('max_budget_per_task must be greater than 0')
return v
VALID_GIT_PROVIDERS = {'github', 'gitlab', 'bitbucket'}
class GitOrgClaimRequest(BaseModel):
"""Request model for claiming a Git organization."""
provider: str
git_organization: str
@field_validator('provider')
@classmethod
def validate_provider(cls, v: str) -> str:
v = v.lower().strip()
if v not in VALID_GIT_PROVIDERS:
raise ValueError(
f'Invalid provider: "{v}". Must be one of: {", ".join(sorted(VALID_GIT_PROVIDERS))}'
)
return v
@field_validator('git_organization')
@classmethod
def validate_git_organization(cls, v: str) -> str:
v = v.strip().lower()
if not v:
raise ValueError('git_organization must not be empty')
return v
class GitOrgClaimResponse(BaseModel):
"""Response model for a Git organization claim."""
id: str
org_id: str
provider: str
git_organization: str
claimed_by: str
claimed_at: str
class GitOrgAlreadyClaimedError(Exception):
"""Raised when a Git organization is already claimed by another OpenHands org."""
def __init__(self, provider: str, git_organization: str):
self.provider = provider
self.git_organization = git_organization
super().__init__(
f'Git organization "{git_organization}" on {provider} is already claimed by another organization'
)
class OrgMemberFinancialResponse(BaseModel):
"""Financial data for a single organization member."""
user_id: str
email: str | None
lifetime_spend: float # Total amount spent (from LiteLLM)
current_budget: float # Remaining budget (max_budget - spend)
max_budget: float | None # Total allocated budget (None = unlimited)
class OrgMemberFinancialPage(BaseModel):
"""Paginated response for organization member financial data."""
items: list[OrgMemberFinancialResponse]
current_page: int = 1
per_page: int = 10
next_page_id: str | None = None
-284
View File
@@ -4,15 +4,11 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from server.auth.authorization import (
Permission,
require_financial_data_access,
require_permission,
)
from server.email_validation import get_admin_user_id
from server.routes.org_models import (
CannotModifySelfError,
GitOrgAlreadyClaimedError,
GitOrgClaimRequest,
GitOrgClaimResponse,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
@@ -26,7 +22,6 @@ from server.routes.org_models import (
OrgDatabaseError,
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgMemberFinancialPage,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
@@ -47,10 +42,7 @@ from server.services.org_llm_settings_service import (
OrgLLMSettingsService,
OrgLLMSettingsServiceInjector,
)
from server.services.org_member_financial_service import OrgMemberFinancialService
from server.services.org_member_service import OrgMemberService
from sqlalchemy.exc import IntegrityError
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_service import OrgService
from storage.user_store import UserStore
@@ -891,104 +883,6 @@ async def get_org_members_count(
)
@org_router.get(
'/{org_id}/members/financial',
response_model=OrgMemberFinancialPage,
)
async def get_org_members_financial(
org_id: UUID,
page_id: Annotated[
str | None,
Query(
title='Pagination offset encoded as string',
description='Offset for pagination (e.g., "0", "10", "20")',
),
] = None,
limit: Annotated[
int,
Query(
title='Maximum items per page',
gt=0,
le=100,
),
] = 10,
email: Annotated[
str | None,
Query(
title='Filter members by email (case-insensitive partial match)',
min_length=1,
max_length=255,
),
] = None,
user_id: str = Depends(require_financial_data_access),
) -> OrgMemberFinancialPage:
"""Get paginated financial data for organization members.
Returns financial information (lifetime spend, current budget) for all members
within the specified organization. Access is restricted to:
- Organization Admins
- Organization Owners
- OpenHands members (users with @openhands.dev emails)
Args:
org_id: Organization ID (UUID)
page_id: Optional pagination offset encoded as string
limit: Maximum items per page (1-100, default 10)
email: Optional email filter (case-insensitive partial match)
user_id: Authenticated user ID (injected by require_financial_data_access)
Returns:
OrgMemberFinancialPage: Paginated response with member financial data
- items: List of members with user_id, email, lifetime_spend,
current_budget, and max_budget
- current_page: Current page number (1-indexed)
- per_page: Items per page
- next_page_id: Offset for next page, or None if no more pages
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks access (not admin/owner and not @openhands.dev)
HTTPException: 400 if page_id is invalid
HTTPException: 500 if retrieval fails
"""
logger.info(
'Getting financial data for organization members',
extra={
'org_id': str(org_id),
'user_id': user_id,
'page_id': page_id,
'limit': limit,
'email_filter': email,
},
)
try:
return await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id=page_id,
limit=limit,
email_filter=email,
)
except ValueError as e:
logger.warning(
'Invalid page_id for financial data request',
extra={'org_id': str(org_id), 'page_id': page_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception:
logger.exception(
'Error retrieving organization member financial data',
extra={'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve member financial data',
)
@org_router.delete('/{org_id}/members/{user_id}')
async def remove_org_member(
org_id: UUID,
@@ -1217,181 +1111,3 @@ async def update_org_member(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)
@org_router.get(
'/{org_id}/git-claims',
response_model=list[GitOrgClaimResponse],
)
async def get_git_claims(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> list[GitOrgClaimResponse]:
"""Get all Git organization claims for an OpenHands organization.
Only admin and owner roles can view Git organization claims.
Args:
org_id: OpenHands organization UUID
user_id: Authenticated user ID (injected by permission check)
Returns:
List of GitOrgClaimResponse with claim details
"""
try:
claims = await OrgGitClaimStore.get_claims_by_org_id(org_id=org_id)
return [
GitOrgClaimResponse(
id=str(claim.id),
org_id=str(claim.org_id),
provider=claim.provider,
git_organization=claim.git_organization,
claimed_by=str(claim.claimed_by),
claimed_at=claim.claimed_at.isoformat(),
)
for claim in claims
]
except Exception:
logger.exception('Error fetching Git organization claims')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to fetch Git organization claims',
)
@org_router.post(
'/{org_id}/git-claims',
response_model=GitOrgClaimResponse,
status_code=status.HTTP_201_CREATED,
)
async def claim_git_organization(
org_id: UUID,
request: GitOrgClaimRequest,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> GitOrgClaimResponse:
"""Claim a Git organization for an OpenHands organization.
Only admin and owner roles can claim Git organizations.
A Git organization can only be claimed by one OpenHands organization at a time.
Args:
org_id: OpenHands organization UUID
request: Claim request with provider and git_organization
user_id: Authenticated user ID (injected by permission check)
Returns:
GitOrgClaimResponse with the created claim details
Raises:
HTTPException 409: If the Git organization is already claimed
HTTPException 403: If user lacks permission
"""
try:
# Check if this Git org is already claimed (early feedback for the common case)
existing_claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider=request.provider,
git_organization=request.git_organization,
)
if existing_claim:
raise GitOrgAlreadyClaimedError(
provider=request.provider,
git_organization=request.git_organization,
)
# Create the claim — the DB unique constraint handles the race condition
# where two concurrent requests both pass the check above.
claim = await OrgGitClaimStore.create_claim(
org_id=org_id,
provider=request.provider,
git_organization=request.git_organization,
claimed_by=UUID(user_id),
)
return GitOrgClaimResponse(
id=str(claim.id),
org_id=str(claim.org_id),
provider=claim.provider,
git_organization=claim.git_organization,
claimed_by=str(claim.claimed_by),
claimed_at=claim.claimed_at.isoformat(),
)
except GitOrgAlreadyClaimedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
except IntegrityError as e:
# Only treat the unique constraint violation as a duplicate claim.
# Other integrity errors (e.g. FK violations) should surface as 500s.
if 'uq_provider_git_org' in str(e.orig):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(
GitOrgAlreadyClaimedError(
provider=request.provider,
git_organization=request.git_organization,
)
),
)
logger.exception('Integrity error claiming Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to claim Git organization',
)
except Exception:
logger.exception('Error claiming Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to claim Git organization',
)
@org_router.delete(
'/{org_id}/git-claims/{claim_id}',
status_code=status.HTTP_200_OK,
)
async def disconnect_git_organization(
org_id: UUID,
claim_id: UUID,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> dict:
"""Remove a Git organization claim from an OpenHands organization.
Only admin and owner roles can disconnect Git organization claims.
Args:
org_id: OpenHands organization UUID
claim_id: Claim UUID to remove
user_id: Authenticated user ID (injected by permission check)
Returns:
dict: Confirmation message on successful deletion
Raises:
HTTPException 404: If the claim is not found for this organization
HTTPException 403: If user lacks permission
"""
try:
deleted = await OrgGitClaimStore.delete_claim(
claim_id=claim_id,
org_id=org_id,
)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Git organization claim not found',
)
return {'message': 'Git organization claim removed successfully'}
except HTTPException:
raise
except Exception:
logger.exception('Error disconnecting Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to disconnect Git organization',
)
+6 -6
View File
@@ -5,7 +5,7 @@ This module provides endpoints for trusted internal services (e.g., automations
to perform privileged operations like creating API keys on behalf of users.
Authentication is via a shared secret (X-Service-API-Key header) configured
through the AUTOMATIONS_SERVICE_KEY environment variable.
through the AUTOMATIONS_SERVICE_API_KEY environment variable.
"""
import os
@@ -20,7 +20,7 @@ from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
# Environment variable for the service API key
AUTOMATIONS_SERVICE_KEY = os.getenv('AUTOMATIONS_SERVICE_KEY', '').strip()
AUTOMATIONS_SERVICE_API_KEY = os.getenv('AUTOMATIONS_SERVICE_API_KEY', '').strip()
service_router = APIRouter(prefix='/api/service', tags=['Service'])
@@ -70,9 +70,9 @@ async def validate_service_api_key(
HTTPException: 401 if key is missing or invalid
HTTPException: 503 if service auth is not configured
"""
if not AUTOMATIONS_SERVICE_KEY:
if not AUTOMATIONS_SERVICE_API_KEY:
logger.warning(
'Service authentication not configured (AUTOMATIONS_SERVICE_KEY not set)'
'Service authentication not configured (AUTOMATIONS_SERVICE_API_KEY not set)'
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -85,7 +85,7 @@ async def validate_service_api_key(
detail='X-Service-API-Key header is required',
)
if x_service_api_key != AUTOMATIONS_SERVICE_KEY:
if x_service_api_key != AUTOMATIONS_SERVICE_API_KEY:
logger.warning('Invalid service API key attempted')
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -104,7 +104,7 @@ async def service_health() -> dict:
"""
return {
'status': 'ok',
'service_auth_configured': bool(AUTOMATIONS_SERVICE_KEY),
'service_auth_configured': bool(AUTOMATIONS_SERVICE_API_KEY),
}
+4 -63
View File
@@ -7,10 +7,8 @@ from server.auth.token_manager import TokenManager
from storage.user_store import UserStore
from utils.identity import resolve_display_name
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
)
from openhands.integrations.service_types import (
Branch,
@@ -24,6 +22,7 @@ from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.routes.git import (
get_repository_branches,
get_repository_microagent_content,
@@ -45,12 +44,7 @@ saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies()
token_manager = TokenManager()
@saas_user_router.get(
'/installations',
response_model=list[str],
deprecated=True,
description='Deprecated: Use `/api/v1/git/installations` instead.',
)
@saas_user_router.get('/installations', response_model=list[str])
async def saas_get_user_installations(
provider: ProviderType,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
@@ -73,59 +67,7 @@ async def saas_get_user_installations(
)
@saas_user_router.get('/git-organizations')
async def saas_get_user_git_organizations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value={},
)
if retval is not None:
return retval
# _check_idp returned None (tokens refreshed on Keycloak side),
# but provider_tokens is still None for this request.
return JSONResponse(
content='Git provider token required.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
# SaaS users sign in with one provider at a time
provider = next(iter(provider_tokens))
if provider == ProviderType.GITHUB:
orgs = await client.get_github_organizations()
elif provider == ProviderType.GITLAB:
orgs = await client.get_gitlab_groups()
elif provider == ProviderType.BITBUCKET:
orgs = await client.get_bitbucket_workspaces()
else:
return JSONResponse(
content=f"Provider {provider.value} doesn't support git organizations",
status_code=status.HTTP_400_BAD_REQUEST,
)
return {
'provider': provider.value,
'organizations': orgs,
}
@saas_user_router.get(
'/repositories',
response_model=list[Repository],
deprecated=True,
description='Deprecated: Use `/api/v1/git/repositories` instead.',
)
@saas_user_router.get('/repositories', response_model=list[Repository])
async def saas_get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
@@ -156,13 +98,12 @@ async def saas_get_user_repositories(
)
@saas_user_router.get('/info', response_model=User, deprecated=True)
@saas_user_router.get('/info', response_model=User)
async def saas_get_user(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> User | JSONResponse:
"""Get the current user git info. Use GET /api/v1/users/git-info instead"""
if not provider_tokens:
if not access_token:
return JSONResponse(
-106
View File
@@ -1,106 +0,0 @@
"""SAAS-specific extensions for the /api/v1/users endpoints.
This module provides SAAS-specific implementations that extend the OSS
user endpoints with organization context (org_id, org_name, role, permissions).
"""
import logging
from fastapi import APIRouter, FastAPI, Header, HTTPException, Query, status
from fastapi.responses import JSONResponse
from server.auth.saas_user_auth import SaasUserAuth
from server.models.user_models import SaasUserInfo
from openhands.app_server.config import depends_user_context
from openhands.app_server.sandbox.session_auth import validate_session_key_ownership
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.dependencies import get_dependencies
_logger = logging.getLogger(__name__)
saas_users_v1_router = APIRouter(
prefix='/api/v1/users', tags=['User'], dependencies=get_dependencies()
)
user_dependency = depends_user_context()
@saas_users_v1_router.get('/me')
async def get_current_user_saas(
user_context: UserContext = user_dependency,
expose_secrets: bool = Query(
default=False,
description='If true, return unmasked secret values (e.g. llm_api_key). '
'Requires a valid X-Session-API-Key header for an active sandbox '
'owned by the authenticated user.',
),
x_session_api_key: str | None = Header(default=None),
) -> SaasUserInfo:
"""Get the current authenticated user with SAAS-specific org info.
Returns user settings along with organization context:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission strings for the role
"""
# Get base user info from the context
base_user_info = await user_context.get_user_info()
if base_user_info is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
# Build SAAS user info from base settings
user_info_data = base_user_info.model_dump(
mode='json', context={'expose_secrets': True}
)
# Add org info if available (from SaasUserAuth)
org_info = await _get_org_info_from_context(user_context)
if org_info:
user_info_data.update(org_info)
user_info = SaasUserInfo(**user_info_data)
if expose_secrets:
await validate_session_key_ownership(user_context, x_session_api_key)
return JSONResponse( # type: ignore[return-value]
content=user_info.model_dump(mode='json', context={'expose_secrets': True})
)
return user_info
async def _get_org_info_from_context(user_context: UserContext) -> dict | None:
"""Extract org info from the user context if available.
This works by checking if the underlying user_auth is a SaasUserAuth
instance that has the get_org_info method.
"""
# Check if this is an AuthUserContext with a SaasUserAuth
if isinstance(user_context, AuthUserContext):
user_auth = user_context.user_auth
if isinstance(user_auth, SaasUserAuth):
return await user_auth.get_org_info()
return None
def override_users_me_endpoint(app: FastAPI) -> None:
"""Override the OSS /api/v1/users/me endpoint with SAAS version.
This removes the base OSS endpoint and registers the SAAS version
which includes organization context (org_id, org_name, role, permissions).
Must be called after the app is created in saas_server.py.
"""
# Find and remove the OSS /api/v1/users/me route
routes_to_remove = []
for route in app.routes:
if hasattr(route, 'path') and route.path == '/api/v1/users/me':
routes_to_remove.append(route)
for route in routes_to_remove:
app.routes.remove(route)
_logger.debug('Removed OSS route: %s', route.path)
# Add the SAAS version
app.include_router(saas_users_v1_router)
_logger.debug('Added SAAS /api/v1/users/me endpoint')
@@ -1,171 +0,0 @@
"""Service for managing organization member financial data."""
from uuid import UUID
import httpx
from server.routes.org_models import (
OrgMemberFinancialPage,
OrgMemberFinancialResponse,
)
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
class OrgMemberFinancialService:
"""Service for organization member financial data operations."""
@staticmethod
async def get_org_members_financial_data(
org_id: UUID,
page_id: str | None = None,
limit: int = 10,
email_filter: str | None = None,
) -> OrgMemberFinancialPage:
"""Get paginated financial data for organization members.
Fetches member list from database and joins with financial data from LiteLLM.
Args:
org_id: Organization UUID
page_id: Offset encoded as string (e.g., "0", "10", "20")
limit: Maximum items per page (default 10)
email_filter: Optional case-insensitive partial email match
Returns:
OrgMemberFinancialPage: Paginated response with financial data
Raises:
ValueError: If page_id is invalid
"""
# Parse page_id to get offset
offset = 0
if page_id is not None:
try:
offset = int(page_id)
if offset < 0:
raise ValueError('page_id must be non-negative')
except ValueError as e:
raise ValueError(f'Invalid page_id: {page_id}') from e
# Fetch paginated members from database
members, total_count = await OrgMemberStore.get_org_members_paginated(
org_id=org_id,
offset=offset,
limit=limit,
email_filter=email_filter,
)
if not members:
return OrgMemberFinancialPage(
items=[],
current_page=(offset // limit) + 1,
per_page=limit,
next_page_id=None,
)
# Fetch financial data from LiteLLM for the entire team
# This is a single API call that returns all team members' data
try:
financial_data = await LiteLlmManager.get_team_members_financial_data(
str(org_id)
)
except httpx.HTTPStatusError as e:
# Re-raise auth errors - these indicate configuration issues that need fixing
if e.response.status_code in (401, 403):
logger.error(
'LiteLLM authentication/authorization failed',
extra={
'org_id': str(org_id),
'status_code': e.response.status_code,
'error': str(e),
},
)
raise
# For other HTTP errors (404, 500, etc.), use graceful degradation
logger.warning(
'Failed to fetch financial data from LiteLLM',
extra={
'org_id': str(org_id),
'status_code': e.response.status_code,
'error_type': type(e).__name__,
'error': str(e),
},
)
financial_data = {}
except Exception as e:
# For network errors, timeouts, etc., use graceful degradation
logger.warning(
'Failed to fetch financial data from LiteLLM',
extra={
'org_id': str(org_id),
'error_type': type(e).__name__,
'error': str(e),
},
)
financial_data = {}
# Extract team-level data for shared budget calculation
team_spend = financial_data.get('team_spend', 0) or 0
members_financial = financial_data.get('members', {})
# Build response items by joining DB members with LiteLLM financial data
items: list[OrgMemberFinancialResponse] = []
for member in members:
user = member.user
user_id_str = str(member.user_id)
# Get financial data for this user (or defaults if not found)
user_financial = members_financial.get(user_id_str, {})
individual_spend = user_financial.get('spend', 0) or 0
max_budget = user_financial.get('max_budget')
uses_shared_budget = user_financial.get('uses_shared_budget', False)
# Calculate current budget (remaining)
# For shared team budgets, use team_spend to calculate remaining budget
# This ensures all members see the same remaining budget
if max_budget is not None:
if uses_shared_budget:
# Shared budget - use team's total spend
current_budget = max(max_budget - team_spend, 0)
else:
# Individual budget - use individual spend
current_budget = max(max_budget - individual_spend, 0)
else:
# If no max_budget, current_budget is unlimited (represented as 0)
current_budget = 0
items.append(
OrgMemberFinancialResponse(
user_id=user_id_str,
email=user.email if user else None,
lifetime_spend=individual_spend,
current_budget=current_budget,
max_budget=max_budget,
)
)
# Calculate current page (1-indexed)
current_page = (offset // limit) + 1
# Calculate next_page_id
next_offset = offset + limit
next_page_id = str(next_offset) if next_offset < total_count else None
logger.debug(
'OrgMemberFinancialService:get_org_members_financial_data:success',
extra={
'org_id': str(org_id),
'items_count': len(items),
'current_page': current_page,
'total_count': total_count,
},
)
return OrgMemberFinancialPage(
items=items,
current_page=current_page,
per_page=limit,
next_page_id=next_page_id,
)
@@ -1,143 +0,0 @@
"""Implementation of SharedEventService.
This implementation provides read-only access to events from shared conversations:
- Validates that the conversation is shared before returning events
- Uses existing EventService for actual event retrieval
- Uses SharedConversationInfoService for shared conversation validation
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoService,
)
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.config import get_global_config
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event.filesystem_event_service import FilesystemEventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
logger = logging.getLogger(__name__)
@dataclass
class FilesystemSharedEventService(SharedEventService):
"""Implementation of SharedEventService that validates shared access."""
shared_conversation_info_service: SharedConversationInfoService
persistence_dir: Path
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
return None
return FilesystemEventService(
prefix=self.persistence_dir,
user_id=shared_conversation_info.created_by_user_id,
app_conversation_info_service=None,
app_conversation_info_load_tasks={},
)
async def get_shared_event(
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
return None
# If conversation is shared, get the event
return await event_service.get_event(conversation_id, event_id)
async def search_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await event_service.search_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
async def count_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return 0
# If conversation is shared, count events for this conversation
return await event_service.count_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
)
class FilesystemSharedEventServiceInjector(SharedEventServiceInjector):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import get_db_session
async with get_db_session(state, request) as db_session:
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
service = FilesystemSharedEventService(
shared_conversation_info_service=shared_conversation_info_service,
persistence_dir=get_global_config().persistence_dir,
)
yield service
@@ -33,12 +33,6 @@ def get_shared_event_service_injector() -> SharedEventServiceInjector:
)
return AwsSharedEventServiceInjector()
elif provider == StorageProvider.FILESYSTEM:
from server.sharing.filesystem_shared_event_service import (
FilesystemSharedEventServiceInjector,
)
return FilesystemSharedEventServiceInjector()
else:
# GCP is the default for shared events (including filesystem fallback)
from server.sharing.google_cloud_shared_event_service import (
@@ -354,20 +354,6 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
user = result.scalar_one_or_none()
assert user
# Determine org_id: prefer API key's org_id if authenticated via API key
org_id = user.current_org_id # Default fallback
if hasattr(self.user_context, 'user_auth'):
user_auth = self.user_context.user_auth
if hasattr(user_auth, 'get_api_key_org_id'):
api_key_org_id = user_auth.get_api_key_org_id()
if api_key_org_id is not None:
org_id = api_key_org_id
# Override with resolver org_id if set (from git org claim resolution)
resolver_org_id = getattr(self.user_context, 'resolver_org_id', None)
if resolver_org_id is not None:
org_id = resolver_org_id
# Check if SAAS metadata already exists
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)
@@ -376,15 +362,16 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
existing_saas_metadata = result.scalar_one_or_none()
assert existing_saas_metadata is None or (
existing_saas_metadata.user_id == user_id_uuid
and existing_saas_metadata.org_id == org_id
and existing_saas_metadata.org_id == user.current_org_id
)
if not existing_saas_metadata:
# Create new SAAS metadata with the determined org_id
# Create new SAAS metadata
# Set org_id to user_id as specified in requirements
saas_metadata = StoredConversationMetadataSaas(
conversation_id=str(info.id),
user_id=user_id_uuid,
org_id=org_id,
org_id=user.current_org_id,
)
self.db_session.add(saas_metadata)
+1 -4
View File
@@ -29,10 +29,7 @@ def get_cookie_domain() -> str | None:
def get_cookie_samesite() -> Literal['lax', 'strict']:
# Use 'strict' in production for maximum CSRF protection
# Use 'lax' for local development and staging environments
# Note: For invitation links from emails, the frontend handles acceptance via
# an authenticated POST request (same-origin), which works with 'strict' cookies
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
web_url = get_global_config().web_url
return (
'strict'
@@ -17,7 +17,7 @@ from server.verified_models.verified_model_service import (
from openhands.app_server.config import get_db_session
from openhands.server.routes import public
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
from openhands.utils.llm import get_supported_llm_models
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
@@ -117,7 +117,7 @@ async def delete_verified_model(
)
async def get_saas_llm_models_dependency(request: Request) -> ModelsResponse:
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
"""SaaS implementation for the LLM models endpoint."""
async with get_db_session(request.state, request) as db_session:
# Prevent circular import
-2
View File
@@ -19,7 +19,6 @@ from storage.linear_workspace import LinearWorkspace
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from storage.openhands_pr import OpenhandsPR
from storage.org import Org
from storage.org_git_claim import OrgGitClaim
from storage.org_invitation import OrgInvitation
from storage.org_member import OrgMember
from storage.proactive_convos import ProactiveConversation
@@ -66,7 +65,6 @@ __all__ = [
'MaintenanceTaskStatus',
'OpenhandsPR',
'Org',
'OrgGitClaim',
'OrgInvitation',
'OrgMember',
'ProactiveConversation',
-8
View File
@@ -1,13 +1,5 @@
"""
Unified SQLAlchemy declarative base for all models.
Re-exports the core Base to ensure enterprise and core models share the same
metadata registry. This allows foreign key relationships between enterprise
models (e.g., ConversationCallback) and core models (e.g., StoredConversationMetadata).
The core Base now uses SQLAlchemy 2.0 DeclarativeBase for proper type inference
with Mapped types, while remaining backward compatible with existing Column()
definitions.
"""
from openhands.app_server.utils.sql_utils import Base
+15 -21
View File
@@ -1,28 +1,22 @@
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import DECIMAL, DateTime, Enum, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import DECIMAL, Column, DateTime, Enum, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from storage.base import Base
if TYPE_CHECKING:
from storage.org import Org
class BillingSession(Base):
class BillingSession(Base): # type: ignore
"""
Represents a Stripe billing session for credit purchases.
Tracks the status of payment transactions and associated user information.
"""
__tablename__ = 'billing_sessions'
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(String, nullable=False)
org_id: Mapped[UUID | None] = mapped_column(ForeignKey('org.id'), nullable=True)
status: Mapped[str] = mapped_column(
id = Column(String, primary_key=True)
user_id = Column(String, nullable=False)
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
status = Column(
Enum(
'in_progress',
'completed',
@@ -32,16 +26,16 @@ class BillingSession(Base):
),
default='in_progress',
)
price: Mapped[Decimal] = mapped_column(DECIMAL(19, 4), nullable=False)
price_code: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime | None] = mapped_column(
price = Column(DECIMAL(19, 4), nullable=False)
price_code = Column(String, nullable=False)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
)
updated_at: Mapped[datetime | None] = mapped_column(
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
)
# Relationships
org: Mapped['Org | None'] = relationship('Org', back_populates='billing_sessions')
org = relationship('Org', back_populates='billing_sessions')
+10 -23
View File
@@ -3,8 +3,7 @@
from datetime import datetime, timezone
from enum import Enum
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Column, DateTime, Integer, String
from storage.base import Base
@@ -26,33 +25,21 @@ class DeviceCode(Base):
__tablename__ = 'device_codes'
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
device_code: Mapped[str] = mapped_column(
String(128), unique=True, nullable=False, index=True
)
user_code: Mapped[str] = mapped_column(
String(16), unique=True, nullable=False, index=True
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=DeviceCodeStatus.PENDING.value
)
id = Column(Integer, primary_key=True, autoincrement=True)
device_code = Column(String(128), unique=True, nullable=False, index=True)
user_code = Column(String(16), unique=True, nullable=False, index=True)
status = Column(String(32), nullable=False, default=DeviceCodeStatus.PENDING.value)
# Keycloak user ID who authorized the device (set during verification)
keycloak_user_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
keycloak_user_id = Column(String(255), nullable=True)
# Timestamps
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
authorized_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at = Column(DateTime(timezone=True), nullable=False)
authorized_at = Column(DateTime(timezone=True), nullable=True)
# Rate limiting fields for RFC 8628 section 3.5 compliance
last_poll_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
current_interval: Mapped[int] = mapped_column(nullable=False, default=5)
last_poll_time = Column(DateTime(timezone=True), nullable=True)
current_interval = Column(Integer, nullable=False, default=5)
def __repr__(self) -> str:
return f"<DeviceCode(user_code='{self.user_code}', status='{self.status}')>"
+16 -21
View File
@@ -1,34 +1,29 @@
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, Enum, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import JSON, Column, DateTime, Enum, Integer, String, Text
from sqlalchemy.sql import func
from storage.base import Base
class Feedback(Base):
class Feedback(Base): # type: ignore
__tablename__ = 'feedback'
id: Mapped[str] = mapped_column(String, primary_key=True)
version: Mapped[str] = mapped_column(String, nullable=False)
email: Mapped[str] = mapped_column(String, nullable=False)
polarity: Mapped[str] = mapped_column(
id = Column(String, primary_key=True)
version = Column(String, nullable=False)
email = Column(String, nullable=False)
polarity = Column(
Enum('positive', 'negative', name='polarity_enum'), nullable=False
)
permissions: Mapped[str] = mapped_column(
permissions = Column(
Enum('public', 'private', name='permissions_enum'), nullable=False
)
trajectory: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
trajectory = Column(JSON, nullable=True)
class ConversationFeedback(Base):
class ConversationFeedback(Base): # type: ignore
__tablename__ = 'conversation_feedback'
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
conversation_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
event_id: Mapped[int | None] = mapped_column(nullable=True)
rating: Mapped[int] = mapped_column(nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
nullable=False, server_default=func.now()
)
id = Column(Integer, primary_key=True, autoincrement=True)
conversation_id = Column(String, nullable=False, index=True)
event_id = Column(Integer, nullable=True)
rating = Column(Integer, nullable=False)
reason = Column(Text, nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
+4 -83
View File
@@ -354,10 +354,12 @@ class LiteLlmManager:
# Check if the database key exists in LiteLLM
# If not, generate a new key to prevent verification failures later
db_key = None
legacy_settings = user_settings.to_settings() if user_settings else None
if (
user_settings
and user_settings.llm_api_key
and user_settings.llm_base_url == LITE_LLM_API_URL
and legacy_settings
and legacy_settings.llm_base_url == LITE_LLM_API_URL
):
db_key = user_settings.llm_api_key
if hasattr(db_key, 'get_secret_value'):
@@ -1524,83 +1526,6 @@ class LiteLlmManager:
'LiteLlmManager:_delete_key:key_deleted',
)
@staticmethod
async def _get_team_members_financial_data(
client: httpx.AsyncClient,
team_id: str,
) -> dict:
"""
Get financial data for all members in a team.
Fetches team info from LiteLLM and extracts spending/budget data for each member.
Args:
client: HTTP client for LiteLLM API
team_id: The team/organization ID
Returns:
Dict with structure:
{
"team_max_budget": float | None, # Team's shared budget
"team_spend": float, # Team's total spend (for shared budget calc)
"members": {
user_id: {
"spend": float,
"max_budget": float | None,
"uses_shared_budget": bool # True if using team budget
},
...
}
}
Returns empty dict if team not found or LiteLLM is not configured.
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return {}
team_info = await LiteLlmManager._get_team(client, team_id)
if not team_info:
logger.warning(
'LiteLlmManager:_get_team_members_financial_data:team_not_found',
extra={'team_id': team_id},
)
return {}
members: dict[str, dict] = {}
team_memberships = team_info.get('team_memberships', [])
# Get team-level budget info (shared across all members in team orgs)
team_data = team_info.get('team_info', {})
team_max_budget = team_data.get('max_budget')
team_spend = team_data.get('spend', 0) or 0
for membership in team_memberships:
user_id = membership.get('user_id')
if not user_id:
continue
# Use individual max_budget_in_team if set, otherwise fall back to team budget
member_max_budget = membership.get('max_budget_in_team')
uses_shared_budget = member_max_budget is None
if uses_shared_budget:
member_max_budget = team_max_budget
members[user_id] = {
'spend': membership.get('spend', 0) or 0,
'max_budget': member_max_budget,
'uses_shared_budget': uses_shared_budget,
}
logger.debug(
'LiteLlmManager:_get_team_members_financial_data:success',
extra={'team_id': team_id, 'member_count': len(members)},
)
return {
'team_max_budget': team_max_budget,
'team_spend': team_spend,
'members': members,
}
@staticmethod
def with_http_client(
internal_fn: Callable[..., Awaitable[Any]],
@@ -1608,8 +1533,7 @@ class LiteLlmManager:
@functools.wraps(internal_fn)
async def wrapper(*args, **kwargs):
async with httpx.AsyncClient(
headers={'x-goog-api-key': LITE_LLM_API_KEY},
timeout=httpx.Timeout(30.0),
headers={'x-goog-api-key': LITE_LLM_API_KEY}
) as client:
return await internal_fn(client, *args, **kwargs)
@@ -1636,6 +1560,3 @@ class LiteLlmManager:
get_user_keys = staticmethod(with_http_client(_get_user_keys))
delete_key_by_alias = staticmethod(with_http_client(_delete_key_by_alias))
update_user_keys = staticmethod(with_http_client(_update_user_keys))
get_team_members_financial_data = staticmethod(
with_http_client(_get_team_members_financial_data)
)
-1
View File
@@ -64,7 +64,6 @@ class Org(Base): # type: ignore
slack_conversations = relationship('SlackConversation', back_populates='org')
slack_users = relationship('SlackUser', back_populates='org')
stripe_customers = relationship('StripeCustomer', back_populates='org')
git_claims = relationship('OrgGitClaim', back_populates='org')
def __init__(self, **kwargs):
# Handle known SQLAlchemy columns directly
-30
View File
@@ -1,30 +0,0 @@
"""
SQLAlchemy model for Git Organization Claims.
"""
from uuid import uuid4
from sqlalchemy import UUID, Column, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship
from storage.base import Base
class OrgGitClaim(Base): # type: ignore
"""Model for tracking which OpenHands org has claimed a Git organization."""
__tablename__ = 'org_git_claim'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
org_id = Column(
UUID(as_uuid=True), ForeignKey('org.id', ondelete='CASCADE'), nullable=False
)
provider = Column(String, nullable=False)
git_organization = Column(String, nullable=False)
claimed_by = Column(UUID(as_uuid=True), ForeignKey('user.id'), nullable=False)
claimed_at = Column(DateTime(timezone=True), nullable=False)
__table_args__ = (
UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
)
org = relationship('Org', back_populates='git_claims')
-141
View File
@@ -1,141 +0,0 @@
"""
Store class for managing Git organization claims.
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from sqlalchemy import and_, select
from storage.database import a_session_maker
from storage.org_git_claim import OrgGitClaim
from openhands.core.logger import openhands_logger as logger
class OrgGitClaimStore:
"""Store for managing Git organization claims."""
@staticmethod
async def create_claim(
org_id: UUID,
provider: str,
git_organization: str,
claimed_by: UUID,
) -> OrgGitClaim:
"""Create a new Git organization claim.
Args:
org_id: OpenHands organization UUID
provider: Git provider ('github', 'gitlab', 'bitbucket')
git_organization: Name of the Git organization being claimed
claimed_by: User UUID who is making the claim
Returns:
OrgGitClaim: The created claim record
"""
async with a_session_maker() as session:
claim = OrgGitClaim(
org_id=org_id,
provider=provider,
git_organization=git_organization,
claimed_by=claimed_by,
claimed_at=datetime.now(timezone.utc),
)
session.add(claim)
await session.commit()
await session.refresh(claim)
logger.info(
'Created Git organization claim',
extra={
'claim_id': str(claim.id),
'org_id': str(org_id),
'provider': provider,
'git_organization': git_organization,
'claimed_by': str(claimed_by),
},
)
return claim
@staticmethod
async def get_claim_by_provider_and_git_org(
provider: str,
git_organization: str,
) -> Optional[OrgGitClaim]:
"""Check if a Git organization is already claimed.
Args:
provider: Git provider name
git_organization: Name of the Git organization
Returns:
OrgGitClaim or None if not claimed
"""
async with a_session_maker() as session:
result = await session.execute(
select(OrgGitClaim).filter(
and_(
OrgGitClaim.provider == provider,
OrgGitClaim.git_organization == git_organization,
)
)
)
return result.scalars().first()
@staticmethod
async def get_claims_by_org_id(org_id: UUID) -> list[OrgGitClaim]:
"""Get all Git organization claims for an OpenHands organization.
Args:
org_id: OpenHands organization UUID
Returns:
List of OrgGitClaim records
"""
async with a_session_maker() as session:
result = await session.execute(
select(OrgGitClaim).filter(OrgGitClaim.org_id == org_id)
)
return list(result.scalars().all())
@staticmethod
async def delete_claim(claim_id: UUID, org_id: UUID) -> bool:
"""Delete a Git organization claim.
Args:
claim_id: Claim UUID to delete
org_id: OpenHands organization UUID (for ownership verification)
Returns:
True if deleted, False if not found
"""
async with a_session_maker() as session:
result = await session.execute(
select(OrgGitClaim).filter(
and_(
OrgGitClaim.id == claim_id,
OrgGitClaim.org_id == org_id,
)
)
)
claim = result.scalars().first()
if not claim:
return False
await session.delete(claim)
await session.commit()
logger.info(
'Deleted Git organization claim',
extra={
'claim_id': str(claim_id),
'org_id': str(org_id),
'provider': claim.provider,
'git_organization': claim.git_organization,
},
)
return True
+2 -1
View File
@@ -22,8 +22,9 @@ class OrgMember(Base): # type: ignore
llm_model = Column(String, nullable=True)
_llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
status = Column(String, nullable=True)
mcp_config = Column(JSON, nullable=True)
# Relationships
org = relationship('Org', back_populates='org_members')
+22 -5
View File
@@ -17,6 +17,14 @@ from storage.user_settings import UserSettings
from openhands.storage.data_models.settings import Settings
# Only these agent_settings keys are stored per member; org-wide settings live on Org.
_MEMBER_SCOPED_AGENT_SETTINGS_KEYS = {
'schema_version',
'llm.model',
'llm.base_url',
'max_iterations',
}
class OrgMemberStore:
"""Store for managing organization-member relationships."""
@@ -159,12 +167,21 @@ class OrgMemberStore:
@staticmethod
def get_kwargs_from_user_settings(user_settings: UserSettings):
kwargs = {
normalized: getattr(user_settings, normalized)
for c in OrgMember.__table__.columns
if (normalized := c.name.lstrip('_')) and hasattr(user_settings, normalized)
settings = user_settings.to_settings()
return {
'llm_api_key': user_settings.llm_api_key,
'llm_model': settings.llm_model,
'llm_api_key_for_byor': user_settings.llm_api_key_for_byor,
'llm_base_url': settings.llm_base_url,
'max_iterations': settings.max_iterations,
'agent_settings': {
key: value
for key, value in settings.normalized_agent_settings(
strip_secret_values=True
).items()
if key in _MEMBER_SCOPED_AGENT_SETTINGS_KEYS
},
}
return kwargs
@staticmethod
async def get_org_members_count(
+24 -20
View File
@@ -212,26 +212,30 @@ class OrgStore:
@staticmethod
def get_kwargs_from_user_settings(user_settings: UserSettings):
kwargs = {}
for c in Org.__table__.columns:
# Normalize for lookup
normalized = (
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
)
if not hasattr(user_settings, normalized):
continue
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
key = c.name
if key.startswith('_'):
key = key[1:] # remove only the very first leading underscore
kwargs[key] = getattr(user_settings, normalized)
kwargs['org_version'] = user_settings.user_version
return kwargs
settings = user_settings.to_settings()
return {
'agent': settings.agent,
'default_max_iterations': settings.max_iterations,
'security_analyzer': settings.security_analyzer,
'confirmation_mode': settings.confirmation_mode,
'default_llm_model': settings.llm_model,
'default_llm_base_url': settings.llm_base_url,
'remote_runtime_resource_factor': user_settings.remote_runtime_resource_factor,
'enable_default_condenser': settings.enable_default_condenser,
'billing_margin': user_settings.billing_margin,
'enable_proactive_conversation_starters': user_settings.enable_proactive_conversation_starters,
'sandbox_base_container_image': user_settings.sandbox_base_container_image,
'sandbox_runtime_container_image': user_settings.sandbox_runtime_container_image,
'org_version': user_settings.user_version,
'mcp_config': user_settings.mcp_config,
'search_api_key': user_settings.search_api_key,
'sandbox_api_key': user_settings.sandbox_api_key,
'max_budget_per_task': user_settings.max_budget_per_task,
'enable_solvability_analysis': user_settings.enable_solvability_analysis,
'v1_enabled': user_settings.v1_enabled,
'condenser_max_size': settings.condenser_max_size,
'sandbox_grouping_strategy': user_settings.sandbox_grouping_strategy,
}
@staticmethod
async def persist_org_with_owner(
+3 -33
View File
@@ -34,17 +34,10 @@ class SaasConversationStore(ConversationStore):
session_maker: sessionmaker
org_id: UUID | None = None # will be fetched automatically
def __init__(
self,
user_id: str,
org_id: UUID,
session_maker: sessionmaker,
resolver_org_id: UUID | None = None,
):
def __init__(self, user_id: str, org_id: UUID, session_maker: sessionmaker):
self.user_id = user_id
self.org_id = org_id
self.session_maker = session_maker
self.resolver_org_id = resolver_org_id
def _select_by_id(self, session, conversation_id: str):
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
@@ -110,13 +103,6 @@ class SaasConversationStore(ConversationStore):
stored_metadata = StoredConversationMetadata(**kwargs)
# Override with resolver org_id if set (from git org claim resolution),
# same pattern as V1's save_app_conversation_info in
# saas_app_conversation_info_injector.py
org_id = self.org_id
if self.resolver_org_id is not None:
org_id = self.resolver_org_id
def _save_metadata():
with self.session_maker() as session:
# Save the main conversation metadata
@@ -136,13 +122,13 @@ class SaasConversationStore(ConversationStore):
saas_metadata = StoredConversationMetadataSaas(
conversation_id=stored_metadata.conversation_id,
user_id=UUID(self.user_id),
org_id=org_id,
org_id=self.org_id,
)
session.add(saas_metadata)
else:
# Validate
expected_user_id = UUID(self.user_id)
expected_org_id = org_id
expected_org_id = self.org_id
if saas_metadata.user_id != expected_user_id:
raise ValueError(
@@ -254,19 +240,3 @@ class SaasConversationStore(ConversationStore):
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker)
@classmethod
async def get_resolver_instance(
cls,
config: OpenHandsConfig,
user_id: str,
resolver_org_id: UUID | None = None,
) -> 'SaasConversationStore':
"""Get a store for resolver conversations with explicit org routing.
Unlike get_instance, this accepts a resolver_org_id that overrides
the user's default org when saving conversation metadata.
"""
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)
+50 -24
View File
@@ -28,6 +28,14 @@ from openhands.server.settings import Settings
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.llm import is_openhands_model
# Only these agent_settings keys are persisted on org_member; org-wide values live on Org.
_MEMBER_SCOPED_AGENT_SETTINGS_KEYS = {
'schema_version',
'llm.model',
'llm.base_url',
'max_iterations',
}
@dataclass
class SaasSettingsStore(SettingsStore):
@@ -69,6 +77,29 @@ class SaasSettingsStore(SettingsStore):
)
return result.scalars().first()
@staticmethod
def _member_scoped_agent_settings(agent_settings: dict) -> dict:
return {
key: value
for key, value in agent_settings.items()
if key in _MEMBER_SCOPED_AGENT_SETTINGS_KEYS
}
async def _persist_agent_settings_async(
self, org_id: uuid.UUID, agent_settings: dict
) -> None:
async with a_session_maker() as session:
stmt = (
update(OrgMember)
.where(
OrgMember.org_id == org_id,
OrgMember.user_id == uuid.UUID(self.user_id),
)
.values(agent_settings=agent_settings)
)
await session.execute(stmt)
await session.commit()
async def load(self) -> Settings | None:
user = await UserStore.get_user_by_id(self.user_id)
if not user:
@@ -115,9 +146,7 @@ class SaasSettingsStore(SettingsStore):
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
if org_member.llm_base_url:
kwargs['llm_base_url'] = org_member.llm_base_url
# MCP config is user-specific (stored on org_member, not org)
if org_member.mcp_config is not None:
kwargs['mcp_config'] = org_member.mcp_config
kwargs['agent_settings'] = org_member.agent_settings or {}
if org.v1_enabled is None:
kwargs['v1_enabled'] = True
# Apply default if sandbox_grouping_strategy is None in the database
@@ -125,6 +154,11 @@ class SaasSettingsStore(SettingsStore):
kwargs.pop('sandbox_grouping_strategy', None)
settings = Settings(**kwargs)
persisted_agent_settings = self._member_scoped_agent_settings(
settings.normalized_agent_settings(strip_secret_values=True)
)
if persisted_agent_settings != (org_member.agent_settings or {}):
await self._persist_agent_settings_async(org_id, persisted_agent_settings)
return settings
async def store(self, item: Settings):
@@ -182,37 +216,29 @@ class SaasSettingsStore(SettingsStore):
return None
# Check if we need to generate an LLM key.
# Only generate/verify proxy keys when the base URL is explicitly the
# LiteLLM proxy, or when it's unset and the model is an OpenHands model
# (which always needs a proxy key). For non-OpenHands models with no
# base URL (e.g. basic view BYOR), preserve the user's own API key.
if item.llm_base_url == LITE_LLM_API_URL or (
not item.llm_base_url and is_openhands_model(item.llm_model)
):
if item.llm_base_url == LITE_LLM_API_URL:
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
)
kwargs = item.model_dump(context={'expose_secrets': True})
kwargs['agent_settings'] = self._member_scoped_agent_settings(
item.normalized_agent_settings(strip_secret_values=True)
)
for model in (user, org, org_member):
for key, value in kwargs.items():
# Skip mcp_config for org - it should only be stored on org_member (user-specific)
if key == 'mcp_config' and model is org:
continue
if hasattr(model, key):
setattr(model, key, value)
# Map Settings fields to Org fields with 'default_' prefix
# The generic loop above doesn't update these because Org uses
# 'default_llm_model' not 'llm_model', etc.
# Use exclude_unset to only update explicitly-set fields (allows clearing with null)
settings_data = item.model_dump(exclude_unset=True)
if 'llm_model' in settings_data:
org.default_llm_model = settings_data['llm_model']
if 'llm_base_url' in settings_data:
org.default_llm_base_url = settings_data['llm_base_url']
if 'max_iterations' in settings_data:
org.default_max_iterations = settings_data['max_iterations']
# Map explicitly provided SDK-managed settings onto Org defaults.
# These values now live in item.agent_settings, so inspect the
# dotted keys directly instead of relying on model_dump().
if 'llm.model' in item.agent_settings:
org.default_llm_model = item.llm_model
if 'llm.base_url' in item.agent_settings:
org.default_llm_base_url = item.llm_base_url
if 'max_iterations' in item.agent_settings:
org.default_max_iterations = item.max_iterations
# Propagate LLM settings to all org members
# This ensures all members see the same LLM configuration when an admin saves
+16 -26
View File
@@ -1,12 +1,10 @@
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import DECIMAL, DateTime, Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import DECIMAL, Column, DateTime, Enum, Integer, String
from storage.base import Base
class SubscriptionAccess(Base):
class SubscriptionAccess(Base): # type: ignore
"""
Represents a user's subscription access record.
Tracks subscription status, duration, payment information, and cancellation status.
@@ -14,8 +12,8 @@ class SubscriptionAccess(Base):
__tablename__ = 'subscription_access'
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
status: Mapped[str] = mapped_column(
id = Column(Integer, primary_key=True, autoincrement=True)
status = Column(
Enum(
'ACTIVE',
'DISABLED',
@@ -24,30 +22,22 @@ class SubscriptionAccess(Base):
nullable=False,
index=True,
)
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
start_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
end_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
amount_paid: Mapped[Decimal | None] = mapped_column(DECIMAL(19, 4), nullable=True)
stripe_invoice_payment_id: Mapped[str] = mapped_column(String, nullable=False)
cancelled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
stripe_subscription_id: Mapped[str | None] = mapped_column(
String, nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(
user_id = Column(String, nullable=False, index=True)
start_at = Column(DateTime(timezone=True), nullable=True)
end_at = Column(DateTime(timezone=True), nullable=True)
amount_paid = Column(DECIMAL(19, 4), nullable=True)
stripe_invoice_payment_id = Column(String, nullable=False)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
stripe_subscription_id = Column(String, nullable=True, index=True)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
onupdate=lambda: datetime.now(UTC), # type: ignore[attr-defined]
nullable=False,
)
-2
View File
@@ -5,7 +5,6 @@ SQLAlchemy model for User.
from uuid import uuid4
from sqlalchemy import (
JSON,
UUID,
Boolean,
Column,
@@ -35,7 +34,6 @@ class User(Base): # type: ignore
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
sandbox_grouping_strategy = Column(String, nullable=True)
disabled_skills = Column(JSON, nullable=True)
# Relationships
role = relationship('Role', back_populates='users')
+12 -9
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from server.constants import DEFAULT_BILLING_MARGIN
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Identity, Integer, String
from storage.base import Base
@@ -8,17 +10,9 @@ class UserSettings(Base): # type: ignore
id = Column(Integer, Identity(), primary_key=True)
keycloak_user_id = Column(String, nullable=True, index=True)
language = Column(String, nullable=True)
agent = Column(String, nullable=True)
max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
llm_model = Column(String, nullable=True)
llm_api_key = Column(String, nullable=True)
llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
condenser_max_size = Column(Integer, nullable=True)
user_consents_to_analytics = Column(Boolean, nullable=True)
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
@@ -31,7 +25,6 @@ class UserSettings(Base): # type: ignore
user_version = Column(Integer, nullable=False, default=0)
accepted_tos = Column(DateTime, nullable=True)
mcp_config = Column(JSON, nullable=True)
disabled_skills = Column(JSON, nullable=True)
search_api_key = Column(String, nullable=True)
sandbox_api_key = Column(String, nullable=True)
max_budget_per_task = Column(Float, nullable=True)
@@ -41,6 +34,16 @@ class UserSettings(Base): # type: ignore
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
v1_enabled = Column(Boolean, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
already_migrated = Column(
Boolean, nullable=True, default=False
) # False = not migrated, True = migrated
def to_settings(self):
from openhands.storage.data_models.settings import Settings
return Settings(
agent_settings=dict(self.agent_settings or {}),
llm_api_key=self.llm_api_key,
)
+27 -23
View File
@@ -214,15 +214,14 @@ class UserStore:
decrypted_user_settings, user_settings.user_version
)
# Migrate stripe customer (pass session to avoid FK violation)
# avoids circular reference. This migrate method is temporary until all users are migrated.
# avoids circular reference. This migrate method is temprorary until all users are migrated.
from integrations.stripe_service import migrate_customer
logger.debug(
'user_store:migrate_user:calling_stripe_migrate_customer',
extra={'user_id': user_id},
)
await migrate_customer(session, user_id, org)
await migrate_customer(user_id, org)
logger.debug(
'user_store:migrate_user:done_stripe_migrate_customer',
extra={'user_id': user_id},
@@ -236,7 +235,7 @@ class UserStore:
# if user has custom settings, set org defaults to current version
if custom_settings:
org_kwargs['default_llm_model'] = get_default_litellm_model()
org_kwargs['llm_base_url'] = LITE_LLM_API_URL
org_kwargs['default_llm_base_url'] = LITE_LLM_API_URL
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
for key, value in org_kwargs.items():
@@ -976,19 +975,31 @@ class UserStore:
'max_iterations', org_member.max_iterations
)
from openhands.storage.data_models.settings import Settings
agent_settings = Settings(
agent=org.agent,
llm_model=llm_model,
llm_api_key=org_member.llm_api_key.get_secret_value()
if org_member.llm_api_key
else None,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
confirmation_mode=org.confirmation_mode,
security_analyzer=org.security_analyzer,
enable_default_condenser=org.enable_default_condenser,
condenser_max_size=org.condenser_max_size,
agent_settings=org_member.agent_settings or {},
).normalized_agent_settings(strip_secret_values=True)
return UserSettings(
keycloak_user_id=user_id,
# OrgMember fields
llm_api_key=org_member.llm_api_key.get_secret_value()
if org_member.llm_api_key
else None,
llm_api_key_for_byor=org_member.llm_api_key_for_byor.get_secret_value()
if org_member.llm_api_key_for_byor
else None,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
# User fields
accepted_tos=user.accepted_tos,
enable_sound_notifications=user.enable_sound_notifications,
language=user.language,
@@ -997,12 +1008,7 @@ class UserStore:
email_verified=user.email_verified,
git_user_name=user.git_user_name,
git_user_email=user.git_user_email,
# Org fields
agent=org.agent,
security_analyzer=org.security_analyzer,
confirmation_mode=org.confirmation_mode,
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
enable_default_condenser=org.enable_default_condenser,
billing_margin=org.billing_margin,
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters,
sandbox_base_container_image=org.sandbox_base_container_image,
@@ -1018,7 +1024,8 @@ class UserStore:
max_budget_per_task=org.max_budget_per_task,
enable_solvability_analysis=org.enable_solvability_analysis,
v1_enabled=org.v1_enabled,
condenser_max_size=org.condenser_max_size,
sandbox_grouping_strategy=org.sandbox_grouping_strategy,
agent_settings=agent_settings,
already_migrated=False,
)
@@ -1036,15 +1043,12 @@ class UserStore:
Returns:
True if user has custom settings, False if using old defaults
"""
# Normalize values
user_model = (
user_settings.llm_model.strip() or None if user_settings.llm_model else None
)
settings = user_settings.to_settings()
user_model = settings.llm_model.strip() or None if settings.llm_model else None
user_base_url = (
user_settings.llm_base_url.strip() or None
if user_settings.llm_base_url
else None
)
settings.llm_base_url.strip() if settings.llm_base_url else None
) or None
# Custom base_url = definitely custom settings (BYOK)
if user_base_url and user_base_url != LITE_LLM_API_URL:
+2
View File
@@ -13,6 +13,7 @@ Required environment variables:
- RESEND_AUDIENCE_ID: ID of the Resend audience to add users to
Optional environment variables:
- KEYCLOAK_PROVIDER_NAME: Provider name for Keycloak
- KEYCLOAK_CLIENT_ID: Client ID for Keycloak
- KEYCLOAK_CLIENT_SECRET: Client secret for Keycloak
- RESEND_FROM_EMAIL: Email address to use as the sender (default: "OpenHands Team <no-reply@welcome.openhands.dev>")
@@ -48,6 +49,7 @@ from openhands.core.logger import openhands_logger as logger
# Get Keycloak configuration from environment variables
KEYCLOAK_SERVER_URL = os.environ.get('KEYCLOAK_SERVER_URL', '')
KEYCLOAK_REALM_NAME = os.environ.get('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.environ.get('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.environ.get('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.environ.get('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_ADMIN_PASSWORD = os.environ.get('KEYCLOAK_ADMIN_PASSWORD', '')
-1
View File
@@ -25,7 +25,6 @@ from storage.device_code import DeviceCode # noqa: F401
from storage.feedback import Feedback
from storage.github_app_installation import GithubAppInstallation
from storage.org import Org
from storage.org_git_claim import OrgGitClaim # noqa: F401
from storage.org_invitation import OrgInvitation # noqa: F401
from storage.org_member import OrgMember
from storage.role import Role
@@ -88,7 +88,6 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -145,7 +144,6 @@ class TestGithubViewV1InitialUserMessage:
]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -202,7 +200,6 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = []
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)
@@ -3,7 +3,6 @@ Tests for Jira view classes and factory.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.jira.jira_payload import (
@@ -19,9 +18,6 @@ from integrations.jira.jira_view import (
JiraNewConversationView,
)
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
class TestJiraNewConversationView:
"""Tests for JiraNewConversationView"""
@@ -90,49 +86,29 @@ class TestJiraNewConversationView:
assert 'Test Issue' in user_msg
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.create_new_conversation')
@patch('integrations.jira.jira_view.integration_store')
async def test_create_or_update_conversation_success(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
mock_store,
mock_create_conversation,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test successful conversation creation"""
new_conversation_view._issue_title = 'Test Issue'
new_conversation_view._issue_description = 'Test description'
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result is not None
assert isinstance(result, str)
assert len(result) == 32 # uuid4().hex format
mock_start_convo.assert_called_once()
mock_integration_store.create_conversation.assert_called_once()
assert result == 'conv-123'
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
@pytest.mark.asyncio
async def test_create_or_update_conversation_no_repo(
@@ -372,125 +348,6 @@ class TestJiraFactory:
)
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
class TestJiraV0ConversationRouting:
"""Test V0 conversation routing logic based on claimed git organizations."""
@pytest.fixture
def routing_view(
self,
sample_webhook_payload,
sample_jira_user,
sample_jira_workspace,
):
"""View with non-empty provider tokens for routing tests."""
user_auth = MagicMock(spec=UserAuth)
user_auth.get_provider_tokens = AsyncMock(
return_value={ProviderType.GITHUB: MagicMock()}
)
user_auth.get_secrets = AsyncMock(return_value=None)
return JiraNewConversationView(
payload=sample_webhook_payload,
saas_user_auth=user_auth,
jira_user=sample_jira_user,
jira_workspace=sample_jira_workspace,
selected_repo='test/repo1',
_issue_title='Test Issue',
_issue_description='Test description',
_decrypted_api_key='decrypted_key',
)
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.integration_store')
async def test_routes_to_claimed_org_when_user_is_member(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
routing_view,
mock_jinja_env,
):
"""When repo belongs to a claimed org and user is a member, conversation is created in that org."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
# Act
await routing_view.create_or_update_conversation(mock_jinja_env)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='test/repo1',
keycloak_user_id='test_keycloak_id',
)
call_args = mock_get_resolver_instance.call_args
assert call_args[0][1] == 'test_keycloak_id' # user_id
assert call_args[0][2] == CLAIMING_ORG_ID # resolver_org_id
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider == ProviderType.GITHUB
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.integration_store')
async def test_falls_back_to_personal_workspace_when_no_claim(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
routing_view,
mock_jinja_env,
):
"""When no org has claimed the git org, conversation goes to personal workspace."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
# Act
await routing_view.create_or_update_conversation(mock_jinja_env)
# Assert
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
class TestJiraPayloadParser:
"""Tests for JiraPayloadParser"""
@@ -73,7 +73,6 @@ def sample_user_auth():
"""Create a mock UserAuth for testing."""
user_auth = MagicMock(spec=UserAuth)
user_auth.get_provider_tokens = AsyncMock(return_value={})
user_auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
user_auth.get_access_token = AsyncMock(return_value='test_token')
user_auth.get_user_id = AsyncMock(return_value='test_user_id')
return user_auth
@@ -29,33 +29,27 @@ class TestLinearNewConversationView:
assert 'Test Issue' in user_msg
assert 'Fix this bug @openhands' in user_msg
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.create_new_conversation')
@patch('integrations.linear.linear_view.integration_store')
async def test_create_or_update_conversation_success(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_store,
mock_create_conversation,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test successful conversation creation"""
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result is not None
mock_start_convo.assert_called_once()
mock_integration_store.create_conversation.assert_called_once()
assert result == 'conv-123'
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
async def test_create_or_update_conversation_no_repo(
self, new_conversation_view, mock_jinja_env
@@ -66,23 +60,12 @@ class TestLinearNewConversationView:
with pytest.raises(StartingConvoException, match='No repository selected'):
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.create_new_conversation')
async def test_create_or_update_conversation_failure(
self,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
self, mock_create_conversation, new_conversation_view, mock_jinja_env
):
"""Test conversation creation failure"""
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.side_effect = Exception('Creation failed')
mock_create_conversation.side_effect = Exception('Creation failed')
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
@@ -317,57 +300,43 @@ class TestLinearFactory:
class TestLinearViewEdgeCases:
"""Tests for edge cases and error scenarios"""
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.create_new_conversation')
@patch('integrations.linear.linear_view.integration_store')
async def test_conversation_creation_with_no_user_secrets(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_store,
mock_create_conversation,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when user has no secrets"""
new_conversation_view.saas_user_auth.get_secrets = AsyncMock(return_value=None)
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
new_conversation_view.saas_user_auth.get_secrets.return_value = None
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result is not None
# Verify start_conversation was called with custom_secrets=None
call_kwargs = mock_start_convo.call_args[1]
assert result == 'conv-123'
# Verify create_new_conversation was called with custom_secrets=None
call_kwargs = mock_create_conversation.call_args[1]
assert call_kwargs['custom_secrets'] is None
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.create_new_conversation')
@patch('integrations.linear.linear_view.integration_store')
async def test_conversation_creation_store_failure(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_store,
mock_create_conversation,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when store creation fails"""
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock(
side_effect=Exception('Store error')
)
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock(side_effect=Exception('Store error'))
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
@@ -257,7 +257,7 @@ class TestSlackV1CallbackProcessor:
# Verify Slack posting
mock_slack_client.chat_postMessage.assert_called_once_with(
channel='C1234567890',
markdown_text='Test summary from agent',
text='Test summary from agent',
thread_ts='1234567890.123456',
unfurl_links=False,
unfurl_media=False,
@@ -509,7 +509,7 @@ class TestSlackV1CallbackProcessor:
# Verify user-friendly message was posted to Slack
mock_slack_client.chat_postMessage.assert_called_once()
call_kwargs = mock_slack_client.chat_postMessage.call_args[1]
posted_message = call_kwargs.get('markdown_text', '')
posted_message = call_kwargs.get('text', '')
assert 'OpenHands encountered an error' in posted_message
assert 'LLM budget has been exceeded' in posted_message
assert 'please re-fill' in posted_message
@@ -32,28 +32,6 @@ def resolver_context(mock_saas_user_auth):
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
# ---------------------------------------------------------------------------
# Tests for resolver_org_id - org routing for resolver conversations
# ---------------------------------------------------------------------------
def test_resolver_org_id_defaults_to_none(mock_saas_user_auth):
"""Test that resolver_org_id defaults to None when not provided."""
ctx = ResolverUserContext(saas_user_auth=mock_saas_user_auth)
assert ctx.resolver_org_id is None
def test_resolver_org_id_can_be_set_via_constructor(mock_saas_user_auth):
"""Test that resolver_org_id can be set via constructor for org routing."""
from uuid import UUID
org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
ctx = ResolverUserContext(
saas_user_auth=mock_saas_user_auth, resolver_org_id=org_id
)
assert ctx.resolver_org_id == org_id
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
"""Helper to create CustomSecret instances."""
return CustomSecret(secret=SecretStr(value), description=description)

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