Compare commits

..

1 Commits

Author SHA1 Message Date
openhands f52990ef44 Fix: Filter provider-specific tips based on user's connected providers
- Add 'providers' field to Tip interface for provider-specific filtering
- Modify getRandomTip() to filter tips based on user's connected providers
- Update RandomTip component to use useUserProviders hook
- Add TIPS$GITLAB_HOOK i18n key and translations for GitLab users
- Tag TIPS$GITHUB_HOOK with github provider
- Add comprehensive tests for provider filtering logic

This ensures users only see tips relevant to their connected Git providers.
For example, GitHub-specific tips are now only shown to users with GitHub connected.

Closes #13449

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 19:16:18 +00:00
739 changed files with 23430 additions and 47155 deletions
-202
View File
@@ -1,202 +0,0 @@
---
name: cross-repo-testing
description: This skill should be used when the user asks to "test a cross-repo feature", "deploy a feature branch to staging", "test SDK against OH Cloud", "e2e test a cloud workspace feature", "test provider tokens", "test secrets inheritance", or when changes span the SDK and OpenHands server repos and need end-to-end validation against a staging deployment.
triggers:
- cross-repo
- staging deployment
- feature branch deploy
- test against cloud
- e2e cloud
---
# Cross-Repo Testing: SDK ↔ OpenHands Cloud
How to end-to-end test features that span `OpenHands/software-agent-sdk` and `OpenHands/OpenHands` (the Cloud backend).
## Repository Map
| Repo | Role | What lives here |
|------|------|-----------------|
| [`software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Agent core | `openhands-sdk`, `openhands-workspace`, `openhands-tools` packages. `OpenHandsCloudWorkspace` lives here. |
| [`OpenHands`](https://github.com/OpenHands/OpenHands) | Cloud backend | FastAPI server (`openhands/app_server/`), sandbox management, auth, enterprise integrations. Deployed as OH Cloud. |
| [`deploy`](https://github.com/OpenHands/deploy) | Infrastructure | Helm charts + GitHub Actions that build the enterprise Docker image and deploy to staging/production. |
**Data flow:** SDK client → OH Cloud API (`/api/v1/...`) → sandbox agent-server (inside runtime container)
## When You Need This
There are **two flows** depending on which direction the dependency goes:
| Flow | When | Example |
|------|------|---------|
| **A — SDK client → new Cloud API** | The SDK calls an API that doesn't exist yet on production | `workspace.get_llm()` calling `GET /api/v1/users/me?expose_secrets=true` |
| **B — OH server → new SDK code** | The Cloud server needs unreleased SDK packages or a new agent-server image | Server consumes a new tool, agent behavior, or workspace method from the SDK |
Flow A only requires deploying the server PR. Flow B requires pinning the SDK to an unreleased commit in the server PR **and** using the SDK PR's agent-server image. Both flows may apply simultaneously.
---
## Flow A: SDK Client Tests Against New Cloud API
Use this when the SDK calls an endpoint that only exists on the server PR branch.
### A1. Write and test the server-side changes
In the `OpenHands` repo, implement the new API endpoint(s). Run unit tests:
```bash
cd OpenHands
poetry run pytest tests/unit/app_server/test_<relevant>.py -v
```
Push a PR. Wait for the **"Push Enterprise Image" (Docker) CI job** to succeed — this builds `ghcr.io/openhands/enterprise-server:sha-<COMMIT>`.
### A2. Write the SDK-side changes
In `software-agent-sdk`, implement the client code (e.g., new methods on `OpenHandsCloudWorkspace`). Run SDK unit tests:
```bash
cd software-agent-sdk
pip install -e openhands-sdk -e openhands-workspace
pytest tests/ -v
```
Push a PR. SDK CI is independent — it doesn't need the server changes to pass unit tests.
### A3. Deploy the server PR to staging
See [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) below.
### A4. Run the SDK e2e test against staging
See [Running E2E Tests Against Staging](#running-e2e-tests-against-staging) below.
---
## Flow B: OH Server Needs Unreleased SDK Code
Use this when the Cloud server depends on SDK changes that haven't been released to PyPI yet. The server's runtime containers run the `agent-server` image built from the SDK repo, so the server PR must be configured to use the SDK PR's image and packages.
### B1. Get the SDK PR merged (or identify the commit)
The SDK PR must have CI pass so its agent-server Docker image is built. The image is tagged with the **merge-commit SHA** from GitHub Actions — NOT the head-commit SHA shown in the PR.
Find the correct image tag:
- Check the SDK PR description for an `AGENT_SERVER_IMAGES` section
- Or check the "Consolidate Build Information" CI job for `"short_sha": "<tag>"`
### B2. Pin SDK packages to the commit in the OpenHands PR
In the `OpenHands` repo PR, pin all 3 SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`) to the unreleased commit and update the agent-server image tag. This involves editing 3 files and regenerating 3 lock files.
Follow the **`update-sdk` skill** → "Development: Pin SDK to an Unreleased Commit" section for the full procedure and file-by-file instructions.
### B3. Wait for the OpenHands enterprise image to build
Push the pinned changes. The OpenHands CI will build a new enterprise Docker image (`ghcr.io/openhands/enterprise-server:sha-<OH_COMMIT>`) that bundles the unreleased SDK. Wait for the "Push Enterprise Image" job to succeed.
### B4. Deploy and test
Follow [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) using the new OpenHands commit SHA.
### B5. Before merging: remove the pin
**CI guard:** `check-package-versions.yml` blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields. Before the OpenHands PR can merge, the SDK PR must be merged and released to PyPI, then the pin must be replaced with the released version number.
---
## Deploying to a Staging Feature Environment
The `deploy` repo creates preview environments from OpenHands PRs.
**Option A — GitHub Actions UI (preferred):**
Go to `OpenHands/deploy` → Actions → "Create OpenHands preview PR" → enter the OpenHands PR number. This creates a branch `ohpr-<PR>-<random>` and opens a deploy PR.
**Option B — Update an existing feature branch:**
```bash
cd deploy
git checkout ohpr-<PR>-<random>
# In .github/workflows/deploy.yaml, update BOTH:
# OPENHANDS_SHA: "<full-40-char-commit>"
# OPENHANDS_RUNTIME_IMAGE_TAG: "<same-commit>-nikolaik"
git commit -am "Update OPENHANDS_SHA to <commit>" && git push
```
**Before updating the SHA**, verify the enterprise Docker image exists:
```bash
gh api repos/OpenHands/OpenHands/actions/runs \
--jq '.workflow_runs[] | select(.head_sha=="<COMMIT>") | "\(.name): \(.conclusion)"' \
| grep Docker
# Must show: "Docker: success"
```
The deploy CI auto-triggers and creates the environment at:
```
https://ohpr-<PR>-<random>.staging.all-hands.dev
```
**Wait for it to be live:**
```bash
curl -s -o /dev/null -w "%{http_code}" https://ohpr-<PR>-<random>.staging.all-hands.dev/api/v1/health
# 401 = server is up (auth required). DNS may take 1-2 min on first deploy.
```
## Running E2E Tests Against Staging
**Critical: Feature deployments have their own Keycloak instance.** API keys from `app.all-hands.dev` or `$OPENHANDS_API_KEY` will NOT work. You need a test API key issued by the specific feature deployment's Keycloak.
**You (the agent) cannot obtain this key yourself** — the feature environment requires interactive browser login with credentials you do not have. You must **ask the user** to:
1. Log in to the feature deployment at `https://ohpr-<PR>-<random>.staging.all-hands.dev` in their browser
2. Generate a test API key from the UI
3. Provide the key to you so you can proceed with e2e testing
Do **not** attempt to log in via the browser or guess credentials. Wait for the user to supply the key before running any e2e tests.
```python
from openhands.workspace import OpenHandsCloudWorkspace
STAGING = "https://ohpr-<PR>-<random>.staging.all-hands.dev"
with OpenHandsCloudWorkspace(
cloud_api_url=STAGING,
cloud_api_key="<test-api-key-for-this-deployment>",
) as workspace:
# Test the new feature
llm = workspace.get_llm()
secrets = workspace.get_secrets()
print(f"LLM: {llm.model}, secrets: {list(secrets.keys())}")
```
Or run an example script:
```bash
OPENHANDS_CLOUD_API_KEY="<key>" \
OPENHANDS_CLOUD_API_URL="https://ohpr-<PR>-<random>.staging.all-hands.dev" \
python examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py
```
### Recording results
Both repos support a `.pr/` directory for temporary PR artifacts (design docs, test logs, scripts). These files are automatically removed when the PR is approved — see `.github/workflows/pr-artifacts.yml` and the "PR-Specific Artifacts" section in each repo's `AGENTS.md`.
Push test output to the `.pr/logs/` directory of whichever repo you're working in:
```bash
mkdir -p .pr/logs
python test_script.py 2>&1 | tee .pr/logs/<test_name>.log
git add -f .pr/logs/
git commit -m "docs: add e2e test results" && git push
```
Comment on **both PRs** with pass/fail summary and link to logs.
## Key Gotchas
| Gotcha | Details |
|--------|---------|
| **Feature env auth is isolated** | Each `ohpr-*` deployment has its own Keycloak. Production API keys don't work. Agents cannot log in — you must ask the user to provide a test API key from the feature deployment's UI. |
| **Two SHAs in deploy.yaml** | `OPENHANDS_SHA` and `OPENHANDS_RUNTIME_IMAGE_TAG` must both be updated. The runtime tag is `<sha>-nikolaik`. |
| **Enterprise image must exist** | The Docker CI job on the OpenHands PR must succeed before you can deploy. If it hasn't run, push an empty commit to trigger it. |
| **DNS propagation** | First deployment of a new branch takes 1-2 min for DNS. Subsequent deploys are instant. |
| **Merge-commit SHA ≠ head SHA** | SDK CI tags Docker images with GitHub Actions' merge-commit SHA, not the PR head SHA. Check the SDK PR description or CI logs for the correct tag. |
| **SDK pin blocks merge** | `check-package-versions.yml` prevents merging an OpenHands PR that has `rev` fields in `[tool.poetry.dependencies]`. The SDK must be released to PyPI first. |
| **Flow A: stock agent-server is fine** | When only the Cloud API changes, `OpenHandsCloudWorkspace` talks to the Cloud server, not the agent-server. No custom image needed. |
| **Flow B: agent-server image is required** | When the server needs new SDK code inside runtime containers, you must pin to the SDK PR's agent-server image. |
+8
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
@@ -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
+3 -3
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
@@ -192,7 +192,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: playwright-report
path: tests/e2e/test-results/
@@ -200,7 +200,7 @@ jobs:
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: openhands-logs
path: |
@@ -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 }}
+4 -6
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
@@ -41,7 +39,7 @@ jobs:
working-directory: ./frontend
run: npx playwright test --project=chromium
- name: Upload Playwright report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
+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
+48 -210
View File
@@ -30,51 +30,45 @@ env:
jobs:
define-matrix:
runs-on: ubuntu-latest
runs-on: blacksmith
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
architectures: ${{ steps.define-base-images.outputs.architectures }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
architectures='["amd64", "arm64"]'
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" },
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
echo "architectures=$architectures" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images (one per architecture, natively)
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image (${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
needs: define-matrix
outputs:
# All arch variants produce the same base tags, so any entry works
base_tags: ${{ steps.build.outputs.base_tags }}
permissions:
contents: read
packages: write
strategy:
matrix:
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -86,79 +80,33 @@ jobs:
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Build and push app image
id: build
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push --arch ${{ matrix.arch }}
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
# Output base tags (without arch suffix) for the merge job
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --arch ${{ matrix.arch }} --dry
BASE_TAGS=$(jq -r '.base_tags | join("\n")' docker-build-dry.json)
echo "base_tags<<EOF" >> "$GITHUB_OUTPUT"
echo "$BASE_TAGS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
# Merges per-architecture app images into a multi-arch manifest
ghcr_build_app_merge:
name: Merge App Multi-Arch Manifest
runs-on: ubuntu-22.04
needs: [define-matrix, ghcr_build_app]
if: github.event.pull_request.head.repo.fork != true
permissions:
packages: write
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Merge multi-arch manifest
run: |
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
TAGS="${{ needs.ghcr_build_app.outputs.base_tags }}"
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
sources=""
for arch in $ARCHS; do
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
echo "::error::Missing image ${tag}-${arch}"
exit 1
fi
sources+=" ${tag}-${arch}"
done
echo "Creating manifest for $tag from:$sources"
docker buildx imagetools create -t "$tag" $sources
done <<< "$TAGS"
# Builds the runtime Docker images (one per architecture x base_image, natively)
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image (${{ matrix.base_image.tag }}, ${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
needs: define-matrix
outputs:
# Keyed by base_image tag so the merge job can access per-variant tags.
# Matrix outputs from different entries with the same key overwrite each other,
# but all arch variants of the same base_image produce identical base tags.
base_tags_nikolaik: ${{ steps.params.outputs.base_tags_nikolaik }}
base_tags_ubuntu: ${{ steps.params.outputs.base_tags_ubuntu }}
strategy:
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -169,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
@@ -184,106 +132,55 @@ jobs:
run: |
echo SHORT_SHA=$(git rev-parse --short "$RELEVANT_SHA") >> $GITHUB_ENV
- name: Determine docker build params
id: params
if: github.event.pull_request.head.repo.fork != true
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --arch ${{ matrix.arch }} --dry
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
# Output base tags (without arch suffix) keyed by base_image tag for the merge job
BASE_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.base_tags | join("\n")')
echo "base_tags_${{ matrix.base_image.tag }}<<EOF" >> "$GITHUB_OUTPUT"
echo "$BASE_TAGS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
with:
push: true
tags: ${{ env.DOCKER_TAGS }}
platforms: ${{ env.DOCKER_PLATFORM }}
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}-${{ matrix.arch }}
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}-${{ matrix.arch }},mode=max
# Caching directives to boost performance
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }},mode=max
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
provenance: false
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: runtime-src-${{ matrix.base_image.tag }}-${{ matrix.arch }}
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
# Merges per-architecture runtime images into multi-arch manifests
ghcr_build_runtime_merge:
name: Merge Runtime Multi-Arch Manifest
runs-on: ubuntu-22.04
needs: [define-matrix, ghcr_build_runtime]
if: github.event.pull_request.head.repo.fork != true
permissions:
packages: write
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Merge multi-arch manifests
run: |
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
# Merge all runtime base_image variants
for variant_tags in \
"${{ needs.ghcr_build_runtime.outputs.base_tags_nikolaik }}" \
"${{ needs.ghcr_build_runtime.outputs.base_tags_ubuntu }}"; do
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
sources=""
for arch in $ARCHS; do
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
echo "::error::Missing image ${tag}-${arch}"
exit 1
fi
sources+=" ${tag}-${arch}"
done
echo "Creating manifest for $tag from:$sources"
docker buildx imagetools create -t "$tag" $sources
done <<< "$variant_tags"
done
ghcr_build_enterprise:
name: Push Enterprise Image (${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
name: Push Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
permissions:
contents: read
packages: write
needs: [define-matrix, ghcr_build_app_merge]
needs: [define-matrix, ghcr_build_app]
# Do not build enterprise in forks
if: github.event.pull_request.head.repo.fork != true
outputs:
# Tags without arch suffix, for the merge job
base_tags: ${{ steps.meta_base.outputs.tags }}
strategy:
matrix:
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -294,7 +191,7 @@ jobs:
driver-opts: network=host
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -302,28 +199,6 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/openhands/enterprise-server
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=sha,format=long
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=match,pattern=cloud-\d+\.\d+\.\d+
flavor: |
latest=auto
prefix=
suffix=-${{ matrix.arch }}
env:
DOCKER_METADATA_PR_HEAD_SHA: true
# Also compute base tags (no arch suffix) for the merge job output
- name: Extract base metadata for merge
id: meta_base
uses: docker/metadata-action@v5
with:
images: ghcr.io/openhands/enterprise-server
@@ -335,22 +210,22 @@ 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=
suffix=
env:
DOCKER_METADATA_PR_HEAD_SHA: true
- name: Determine app image tag
shell: bash
run: |
# Use the commit SHA to pin the exact app image built by ghcr_build_app,
# rather than a mutable branch tag like "main" which can serve stale cached layers.
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
# Duplicated with build.sh
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
with:
context: .
file: enterprise/Dockerfile
@@ -359,54 +234,17 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
platforms: linux/${{ matrix.arch }}
platforms: linux/amd64
# Add build provenance
provenance: true
# Add build attestations for better security
sbom: true
# Merges per-architecture enterprise images into a multi-arch manifest
ghcr_build_enterprise_merge:
name: Merge Enterprise Multi-Arch Manifest
runs-on: ubuntu-22.04
permissions:
packages: write
needs: [define-matrix, ghcr_build_enterprise]
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Merge multi-arch manifest
run: |
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
TAGS="${{ needs.ghcr_build_enterprise.outputs.base_tags }}"
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
sources=""
for arch in $ARCHS; do
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
echo "::error::Missing image ${tag}-${arch}"
exit 1
fi
sources+=" ${tag}-${arch}"
done
echo "Creating manifest for $tag from:$sources"
docker buildx imagetools create -t "$tag" $sources
done <<< "$TAGS"
# "All Runtime Tests Passed" is a required job for PRs to merge
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -414,11 +252,11 @@ jobs:
update_pr_description:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
needs: [ghcr_build_runtime_merge]
runs-on: ubuntu-22.04
needs: [ghcr_build_runtime]
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
+2 -2
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
@@ -269,7 +269,7 @@ jobs:
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
if: always() # Upload even if the previous steps fail
with:
name: resolver-output
-136
View File
@@ -1,136 +0,0 @@
---
name: PR Artifacts
on:
workflow_dispatch: # Manual trigger for testing
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
pull_request_review:
types: [submitted]
jobs:
# Auto-remove .pr/ directory when a reviewer approves
cleanup-on-approval:
concurrency:
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
cancel-in-progress: false
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check if fork PR
id: check-fork
run: |
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
echo "is_fork=true" >> $GITHUB_OUTPUT
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
else
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@v6
if: steps.check-fork.outputs.is_fork == 'false'
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
- name: Remove .pr/ directory
id: remove
if: steps.check-fork.outputs.is_fork == 'false'
run: |
if [ -d ".pr" ]; then
git config user.name "allhands-bot"
git config user.email "allhands-bot@users.noreply.github.com"
git rm -rf .pr/
git commit -m "chore: Remove PR-only artifacts [automated]"
git push || {
echo "::error::Failed to push cleanup commit. Check branch protection rules."
exit 1
}
echo "removed=true" >> $GITHUB_OUTPUT
echo "::notice::Removed .pr/ directory"
else
echo "removed=false" >> $GITHUB_OUTPUT
echo "::notice::No .pr/ directory to remove"
fi
- name: Update PR comment after cleanup
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
✅ **PR Artifacts Cleaned Up**
The \`.pr/\` directory has been automatically removed.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body,
});
}
# Warn if .pr/ directory exists (will be auto-removed on approval)
check-pr-artifacts:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- name: Check for .pr/ directory
id: check
run: |
if [ -d ".pr" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Post or update PR comment
if: steps.check.outputs.exists == 'true'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
📁 **PR Artifacts Notice**
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
> For fork PRs: Manual removal is required before merging.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (!existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}
+3 -3
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
@@ -51,7 +51,7 @@ jobs:
# Always checkout main branch for security - cannot test script changes in PRs
- name: Checkout extensions repository
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: OpenHands/extensions
path: extensions
@@ -77,7 +77,7 @@ jobs:
--trace-file trace-info/laminar_trace_info.json
- name: Upload evaluation logs
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v5
if: always() && steps.check-trace.outputs.trace_exists == 'true'
with:
name: pr-review-evaluation-${{ github.event.pull_request.number }}
+11 -13
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"
@@ -65,7 +63,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: coverage-openhands
path: |
@@ -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"
@@ -97,7 +95,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"
@@ -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"
-70
View File
@@ -36,76 +36,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.
### Usage
```
.pr/
├── design.md # Design decisions and architecture notes
├── analysis.md # Investigation or debugging notes
├── logs/ # Test output or CI logs for reviewer reference
└── notes.md # Any other PR-specific content
```
### How It Works
1. **Notification**: When `.pr/` exists, a comment is posted to the PR conversation alerting reviewers
2. **Auto-cleanup**: When the PR is approved, the `.pr/` directory is automatically removed via `.github/workflows/pr-artifacts.yml`
3. **Fork PRs**: Auto-cleanup cannot push to forks, so manual removal is required before merging
### Important Notes
- Do NOT put anything in `.pr/` that needs to be preserved after merge
- The `.pr/` check passes (green ✅) during development — it only posts a notification, not a blocking error
- For fork PRs: You must manually remove `.pr/` before the PR can be merged
### When to Use
- Complex refactoring that benefits from written design rationale
- Debugging sessions where you want to document your investigation
- E2E test results or logs that demonstrate a cross-repo feature works
- Feature implementations that need temporary planning docs
- Any analysis that helps reviewers understand the PR but isn't needed long-term
## Repository Structure
Backend:
- Located in the `openhands` directory
-11
View File
@@ -125,17 +125,6 @@ For example, a PR title could be:
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
please include a short message that we can add to our changelog
## Becoming a Maintainer
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team.
The process for this is as follows:
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
## Need Help?
- **Slack**: [Join our community](https://openhands.dev/joinslack)
+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
+3 -16
View File
@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.8-trixie-slim AS frontend-builder
FROM node:25.2-trixie-slim AS frontend-builder
WORKDIR /app
@@ -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 --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
+6 -32
View File
@@ -8,18 +8,16 @@ push=0
load=0
tag_suffix=""
dry_run=0
arch_suffix=""
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry] [--arch <arch>]"
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
echo " -i: Image name (required)"
echo " -o: Organization name"
echo " --push: Push the image"
echo " --load: Load the image"
echo " -t: Tag suffix"
echo " --dry: Don't build, only create build-args.json"
echo " --arch: Architecture suffix (e.g. amd64 or arm64). Appends -<arch> to tags and forces single-platform build"
exit 1
}
@@ -32,7 +30,6 @@ while [[ $# -gt 0 ]]; do
--load) load=1; shift ;;
-t) tag_suffix="$2"; shift 2 ;;
--dry) dry_run=1; shift ;;
--arch) arch_suffix="$2"; shift 2 ;;
*) usage ;;
esac
done
@@ -78,7 +75,7 @@ if [[ -n $tag_suffix ]]; then
done
fi
echo "Tags (before arch suffix): ${tags[@]}"
echo "Tags: ${tags[@]}"
if [[ "$image_name" == "openhands" ]]; then
dir="./containers/app"
@@ -113,21 +110,10 @@ if [[ -n "$DOCKER_IMAGE_TAG" ]]; then
tags+=("$DOCKER_IMAGE_TAG")
fi
# Apply architecture suffix for split-arch builds (after all tags are collected)
if [[ -n "$arch_suffix" ]]; then
cache_tag+="-${arch_suffix}"
for i in "${!tags[@]}"; do
tags[$i]="${tags[$i]}-${arch_suffix}"
done
# Force single-platform build for this architecture
arch_platform="linux/${arch_suffix}"
fi
DOCKER_REPOSITORY="$DOCKER_REGISTRY/$DOCKER_ORG/$DOCKER_IMAGE"
DOCKER_REPOSITORY=${DOCKER_REPOSITORY,,} # lowercase
echo "Repo: $DOCKER_REPOSITORY"
echo "Base dir: $DOCKER_BASE_DIR"
echo "Tags: ${tags[@]}"
args=""
full_tags=()
@@ -136,6 +122,7 @@ for tag in "${tags[@]}"; do
full_tags+=("$DOCKER_REPOSITORY:$tag")
done
if [[ $push -eq 1 ]]; then
args+=" --push"
args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max"
@@ -147,10 +134,8 @@ fi
echo "Args: $args"
# Determine the platform(s) to build for
if [[ -n "$arch_platform" ]]; then
platform="$arch_platform"
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
@@ -159,24 +144,13 @@ else
fi
if [[ $dry_run -eq 1 ]]; then
echo "Dry Run is enabled. Writing build config to docker-build-dry.json"
# Compute base tags (arch suffix stripped) for use by merge jobs
base_tags=()
for ftag in "${full_tags[@]}"; do
if [[ -n "$arch_suffix" ]]; then
base_tags+=("${ftag%-${arch_suffix}}")
else
base_tags+=("$ftag")
fi
done
jq -n \
--argjson tags "$(printf '%s\n' "${full_tags[@]}" | jq -R . | jq -s .)" \
--argjson base_tags "$(printf '%s\n' "${base_tags[@]}" | jq -R . | jq -s .)" \
--arg platform "$platform" \
--arg openhands_build_version "$OPENHANDS_BUILD_VERSION" \
--arg dockerfile "$dir/Dockerfile" \
'{
tags: $tags,
base_tags: $base_tags,
platform: $platform,
build_args: [
"OPENHANDS_BUILD_VERSION=" + $openhands_build_version
@@ -195,7 +169,7 @@ docker buildx build \
$args \
--build-arg OPENHANDS_BUILD_VERSION="$OPENHANDS_BUILD_VERSION" \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:${cache_tag_base}-main${arch_suffix:+-${arch_suffix}} \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag_base-main \
--platform $platform \
--provenance=false \
-f "$dir/Dockerfile" \
+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:
+1 -1
View File
@@ -10,7 +10,7 @@ LABEL com.datadoghq.tags.env="${DD_ENV}"
# Apply security updates to fix CVEs
RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
apt-get install -y jq gettext && \
# Apply security updates for packages with available fixes
-2
View File
@@ -1,7 +1,5 @@
# PolyForm Free Trial License 1.0.0
Copyright (c) 2026 All Hands AI
## Acceptance
In order to get any license under these terms, you must agree
+1 -1
View File
@@ -59,7 +59,7 @@ handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
level = DEBUG
handlers =
qualname = sqlalchemy.engine
@@ -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,
@@ -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,
)
+21 -4
View File
@@ -24,20 +24,20 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.jira.jira_view import JiraFactory
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
from integrations.manager import Manager
from integrations.models import Message
from integrations.utils import (
HOST,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
format_jira_comment_body,
get_oh_labels,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
@@ -259,6 +259,11 @@ class JiraManager(Manager[JiraViewInterface]):
async def start_job(self, view: JiraViewInterface) -> None:
"""Start a Jira job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_callback_processor import (
JiraCallbackProcessor,
)
try:
logger.info(
'[Jira] Starting job',
@@ -280,7 +285,19 @@ class JiraManager(Manager[JiraViewInterface]):
},
)
# Create success message
# Register callback processor for updates
if isinstance(view, JiraNewConversationView):
processor = JiraCallbackProcessor(
issue_key=view.payload.issue_key,
workspace_name=view.jira_workspace.name,
)
register_callback_processor(conversation_id, processor)
logger.info(
'[Jira] Callback processor registered',
extra={'conversation_id': conversation_id},
)
# Send success response
msg_info = view.get_response_msg()
except MissingSettingsError as e:
@@ -342,7 +359,7 @@ class JiraManager(Manager[JiraViewInterface]):
url = (
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
data = format_jira_comment_body(message)
data = {'body': message}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
url, auth=(svc_acc_email, svc_acc_api_key), json=data
+5 -4
View File
@@ -136,10 +136,11 @@ class JiraPayloadParser:
items = changelog.get('items', [])
# Extract labels that were added
labels = set()
for item in items:
if item.get('field') == 'labels' and item.get('toString'):
labels.update(item['toString'].split())
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
if self.oh_label not in labels:
return JiraPayloadSkipped(
@@ -1,238 +0,0 @@
import logging
from uuid import UUID
import httpx
from integrations.utils import format_jira_comment_body, get_summary_instruction
from pydantic import Field
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.utils.http_session import httpx_verify_option
_logger = logging.getLogger(__name__)
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
class JiraV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Jira V1 integrations."""
should_request_summary: bool = Field(default=True)
svc_acc_email: str
decrypted_api_key: str
issue_key: str
jira_cloud_id: str
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Jira V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
return None
_logger.info('[Jira] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info('[Jira] Should request summary: %s', self.should_request_summary)
if not self.should_request_summary:
return None
self.should_request_summary = False
try:
_logger.info(f'[Jira] Requesting summary {conversation_id}')
summary = await self._request_summary(conversation_id)
_logger.info(
f'[Jira] Posting summary {conversation_id}',
extra={'summary': summary},
)
await self._post_summary_to_jira(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception(f'[Jira] Failed to post summary: {e}', stack_info=True)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
async def _request_summary(self, conversation_id: UUID) -> str:
"""Ask the agent to produce a summary of its work and return the agent response."""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f"{agent_server_url.rstrip('/')}"
f"/api/conversations/{conversation_id}/ask_agent"
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception:
pass
_logger.exception(
'[Jira] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.exception(
'[Jira] Timeout error: %s. Request payload: %s',
error_detail,
payload,
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
async def _post_summary_to_jira(self, summary: str):
"""Post the summary back to the Jira issue."""
if not all(
[
self.svc_acc_email,
self.decrypted_api_key,
self.issue_key,
self.jira_cloud_id,
]
):
_logger.warning('[Jira] Missing required data for posting summary')
return
# Add a comment to the Jira issue with the summary
comment_url = (
f'{JIRA_CLOUD_API_URL}/{self.jira_cloud_id}'
f'/rest/api/2/issue/{self.issue_key}/comment'
)
message = f'OpenHands resolved this issue:\n\n{summary}'
comment_body = format_jira_comment_body(message)
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
comment_url,
auth=(self.svc_acc_email, self.decrypted_api_key),
json=comment_body,
)
response.raise_for_status()
_logger.info(f'[Jira] Posted summary to {self.issue_key}')
+49 -147
View File
@@ -7,7 +7,6 @@ Views are responsible for:
"""
from dataclasses import dataclass, field
from uuid import UUID, uuid4
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
@@ -16,37 +15,18 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.jira.jira_v1_callback_processor import (
JiraV1CallbackProcessor,
)
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import (
CONVERSATION_URL,
infer_repo_from_message,
)
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from jinja2 import Environment
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
)
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.integrations.provider import ProviderHandler
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.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'
@@ -66,7 +46,7 @@ class JiraNewConversationView(JiraViewInterface):
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str = ''
selected_repo: str | None = None
conversation_id: str = ''
# Lazy-loaded issue details (cached after first fetch)
@@ -76,9 +56,6 @@ class JiraNewConversationView(JiraViewInterface):
# Decrypted API key (set by factory)
_decrypted_api_key: str = field(default='', repr=False)
# Resolved org ID for V1 conversations
resolved_org_id: UUID | None = None
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch issue details from Jira API (cached after first call).
@@ -184,131 +161,56 @@ class JiraNewConversationView(JiraViewInterface):
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
issue_id=self.payload.issue_id,
issue_key=self.payload.issue_key,
jira_user_id=self.jira_user.id,
)
await integration_store.create_conversation(jira_conversation)
conversation_metadata = await self._create_v1_metadata()
await self._create_v1_conversation(jinja_env, conversation_metadata)
return self.conversation_id
async def _create_v1_metadata(self) -> ConversationMetadata:
"""Create conversation metadata for V1 conversations.
The JiraConversation mapping is saved to the integration store (above), but
V1 conversation metadata is managed by the app conversation system, not
the legacy conversation store.
"""
logger.info('[Jira]: Creating V1 metadata')
# Generate a dummy conversation for V1 (not saved to store)
self.conversation_id = uuid4().hex
self.resolved_org_id = await self._get_resolved_org_id()
return ConversationMetadata(
conversation_id=self.conversation_id,
selected_repository=self.selected_repo,
)
async def _create_v1_conversation(
self,
jinja_env: Environment,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the new V1 app conversation system."""
logger.info('[Jira]: Creating V1 conversation')
initial_user_text = await self._get_v1_initial_user_message(jinja_env)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=initial_user_text)]
)
# Create the Jira V1 callback processor
jira_callback_processor = self._create_jira_v1_callback_processor()
injector_state = InjectorState()
# Create the V1 conversation start request
start_request = AppConversationStartRequest(
conversation_id=UUID(conversation_metadata.conversation_id),
system_message_suffix=None,
initial_message=initial_message,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=ProviderType.GITHUB,
title=f'Jira Issue {self.payload.issue_key}: {self._issue_title or "Unknown"}',
trigger=ConversationTrigger.JIRA,
processors=[jira_callback_processor],
)
# Set up the Jira user context for the V1 system
jira_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, jira_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
"""Build the initial user message for V1 resolver conversations."""
issue_title, issue_description = await self.get_issue_details()
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.payload.issue_key,
issue_title=issue_title,
issue_description=issue_description,
user_message=self.payload.user_msg,
)
return user_msg
def _create_jira_v1_callback_processor(self):
"""Create a V1 callback processor for Jira integration."""
return JiraV1CallbackProcessor(
svc_acc_email=self.jira_workspace.svc_acc_email,
decrypted_api_key=self._decrypted_api_key,
issue_key=self.payload.issue_key,
jira_cloud_id=self.jira_workspace.jira_cloud_id,
)
async def _get_resolved_org_id(self) -> UUID | None:
"""Resolve the org ID for V1 conversations."""
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if not provider_tokens:
return None
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = await self._get_instructions(jinja_env)
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
resolved_org_id = await resolve_org_for_repo(
provider=repository.git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.jira_user.keycloak_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,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.JIRA,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
return resolved_org_id
self.conversation_id = agent_loop_info.conversation_id
logger.info(
'[Jira] Created conversation',
extra={
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
},
)
# Store Jira conversation mapping
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
issue_id=self.payload.issue_id,
issue_key=self.payload.issue_key,
jira_user_id=self.jira_user.id,
)
await integration_store.create_conversation(jira_conversation)
return self.conversation_id
except Exception as e:
logger.warning(
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
if isinstance(e, StartingConvoException):
raise
logger.error(
'[Jira] Failed to create conversation',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
exc_info=True,
)
return None
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira."""
@@ -20,7 +20,6 @@ from integrations.utils import (
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_session_expired_message,
markdown_to_jira_markup,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
@@ -469,8 +468,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
"""
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
# Convert standard Markdown to Jira Wiki Markup for proper rendering
data = {'body': markdown_to_jira_markup(message)}
data = {'body': message}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()
+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,78 +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 | None = None,
) -> UUID | None:
"""Determine the OpenHands org_id for a resolver conversation.
If the repo's git organization is claimed by an OpenHands org, returns the
claiming org's ID. When keycloak_user_id is provided, also verifies the user
is a member of that org.
Args:
provider: Git provider name ("github", "gitlab", "bitbucket")
full_repo_name: Full repository name (e.g., "OpenHands/foo")
keycloak_user_id: The user's Keycloak UUID string (optional). If provided,
membership is verified before returning the org_id.
Returns:
The org_id if the repo's org is claimed (and user is a member when
keycloak_user_id is provided), else None
"""
git_org = full_repo_name.split('/')[0].lower()
try:
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider, git_org
)
if not claim:
logger.debug(
f'[OrgResolver] No claim found for {provider}/{git_org}',
)
return None
# Skip membership check if no user_id provided
if keycloak_user_id is None:
logger.info(
f'[OrgResolver] Resolved org {claim.org_id} '
f'for {provider}/{git_org} (no user membership check)',
)
return claim.org_id
member = await OrgMemberStore.get_org_member(
claim.org_id, UUID(keycloak_user_id)
)
if not member:
logger.debug(
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
f'{claim.org_id} (claimed {provider}/{git_org}). '
f'Falling back to personal workspace.',
)
return None
logger.info(
f'[OrgResolver] Routing conversation to org {claim.org_id} '
f'for {provider}/{git_org} (user {keycloak_user_id})',
)
return claim.org_id
except Exception as e:
logger.error(
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
exc_info=True,
)
return None
+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()
+4 -21
View File
@@ -436,13 +436,12 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
)
# Use dict to preserve ordering
matches: dict[str, bool] = {}
matches: list[str] = []
# Git URLs first (highest priority)
for owner, repo in re.findall(git_url_pattern, normalized_msg):
repo = re.sub(r'\.git$', '', repo)
matches[f'{owner}/{repo}'] = True
matches.append(f'{owner}/{repo}')
# Direct mentions
for owner, repo in re.findall(direct_pattern, normalized_msg):
@@ -458,10 +457,9 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
continue
if full_match not in matches:
matches[full_match] = True
matches.append(full_match)
result = list(matches)
return result
return matches
def filter_potential_repos_by_user_msg(
@@ -597,18 +595,3 @@ def markdown_to_jira_markup(markdown_text: str) -> str:
# Log the error but don't raise it - return original text as fallback
print(f'Error converting markdown to Jira markup: {str(e)}')
return markdown_text or ''
def format_jira_comment_body(message: str) -> dict:
"""Format a message as a Jira API v2 comment body.
This helper ensures consistent comment formatting across all Jira integrations.
Converts markdown to Jira Wiki Markup and wraps in the expected API structure.
Args:
message: The message content to send (may contain markdown)
Returns:
dict: The comment body in Jira API v2 format {'body': ...}
"""
return {'body': markdown_to_jira_markup(message)}
+1 -17
View File
@@ -6,15 +6,9 @@ from logging.config import fileConfig
# These plugin setup messages would otherwise appear before logging is configured
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
# Prevent SQLAlchemy engine from logging SQL results at DEBUG level, which can
# leak sensitive column data (e.g. API keys, tokens) into log aggregators.
# This is set before any engine is created so it takes effect immediately.
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine, text # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata
@@ -76,12 +70,6 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Re-apply SQLAlchemy engine log suppression after fileConfig, which may override
# our earlier settings from alembic.ini. This ensures DEBUG-level SQL result logging
# is always suppressed, preventing sensitive data from leaking into log aggregators.
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
@@ -121,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()
@@ -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,41 +0,0 @@
"""Add mcp_config to org_member for user-specific MCP settings.
Revision ID: 103
Revises: 102
Create Date: 2026-03-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '103'
down_revision: Union[str, None] = '102'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
# Migrate existing org-level MCP configs to all members in each org.
# This preserves existing configurations while transitioning to user-specific settings.
# Uses server-side SQL to avoid pulling sensitive config data into the Python process.
op.execute(
sa.text(
"""
UPDATE org_member
SET mcp_config = org.mcp_config
FROM org
WHERE org_member.org_id = org.id
AND org.mcp_config IS NOT NULL
"""
)
)
def downgrade() -> None:
op.drop_column('org_member', 'mcp_config')
@@ -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')
@@ -1,31 +0,0 @@
"""Add onboarding_completed column to user table.
Tracks whether a user has completed the onboarding flow.
Used to redirect new SaaS users to /onboarding after accepting TOS.
Revision ID: 107
Revises: 106
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '107'
down_revision: Union[str, None] = '106'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'user',
sa.Column('onboarding_completed', sa.Boolean(), nullable=True, default=False),
)
def downgrade() -> None:
op.drop_column('user', 'onboarding_completed')
+2297 -2563
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 = "*"
-9
View File
@@ -46,12 +46,8 @@ from server.routes.org_invitations import ( # noqa: E402
)
from server.routes.orgs import org_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.routes.users_v1 import ( # noqa: E402
override_users_me_endpoint,
)
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
@@ -116,7 +112,6 @@ if GITLAB_APP_CLIENT_ID:
base_app.include_router(gitlab_integration_router)
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(service_router) # Add routes for internal service API
base_app.include_router(org_router) # Add routes for organization management
base_app.include_router(
verified_models_router
@@ -126,10 +121,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)
+4 -145
View File
@@ -35,13 +35,13 @@ Usage:
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, HTTPException, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_auth, get_user_id
from openhands.server.user_auth import get_user_id
class Permission(str, Enum):
@@ -84,12 +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'
# Manage Automations
MANAGE_AUTOMATIONS = 'manage_automations'
class RoleName(str, Enum):
"""Role names used in the system."""
@@ -124,10 +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,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
RoleName.ADMIN: frozenset(
@@ -149,10 +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,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
RoleName.MEMBER: frozenset(
@@ -166,8 +152,6 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Settings (View only)
Permission.VIEW_ORG_SETTINGS,
Permission.VIEW_LLM_SETTINGS,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
}
@@ -230,19 +214,6 @@ def has_permission(user_role: Role, permission: Permission) -> bool:
return permission in permissions
async def get_api_key_org_id_from_request(request: Request) -> UUID | None:
"""Get the org_id bound to the API key used for authentication.
Returns None if:
- Not authenticated via API key (cookie auth)
- API key is a legacy key without org binding
"""
user_auth = getattr(request.state, 'user_auth', None)
if user_auth and hasattr(user_auth, 'get_api_key_org_id'):
return user_auth.get_api_key_org_id()
return None
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
@@ -250,9 +221,8 @@ def require_permission(permission: Permission):
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Validates API key org binding (if using API key auth)
4. Checks if the user has the required permission in the organization
5. Returns the user_id if authorized, raises HTTPException otherwise
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
@@ -270,7 +240,6 @@ def require_permission(permission: Permission):
"""
async def permission_checker(
request: Request,
org_id: UUID | None = None,
user_id: str | None = Depends(get_user_id),
) -> str:
@@ -280,23 +249,6 @@ def require_permission(permission: Permission):
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None and org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
user_role = await get_user_org_role(user_id, org_id)
if not user_role:
@@ -327,96 +279,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 -17
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(
@@ -56,23 +57,6 @@ RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
# Automation Service
AUTOMATION_SERVICE_URL = os.getenv('AUTOMATION_SERVICE_URL', '').strip()
if AUTOMATION_SERVICE_URL and not AUTOMATION_SERVICE_URL.startswith(
('http://', 'https://')
):
raise ValueError(
f'AUTOMATION_SERVICE_URL must start with http:// or https://, '
f'got: {AUTOMATION_SERVICE_URL}'
)
AUTOMATION_EVENT_FORWARDING_ENABLED = os.getenv(
'AUTOMATION_EVENT_FORWARDING_ENABLED', 'false'
) in ('1', 'true')
# Shared secret for signing payloads sent to automation service (separate from GitHub webhook secret)
AUTOMATION_WEBHOOK_SECRET = os.getenv('AUTOMATION_WEBHOOK_SECRET', '').strip()
# Default HTTP timeout for automation service requests (seconds)
AUTOMATION_SERVICE_TIMEOUT = int(os.getenv('AUTOMATION_SERVICE_TIMEOUT', '30'))
# Account Defender labels that indicate suspicious activity
SUSPICIOUS_LABELS = {
'SUSPICIOUS_LOGIN_ACTIVITY',
+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 = {}
+4 -101
View File
@@ -1,7 +1,6 @@
import time
from dataclasses import dataclass
from types import MappingProxyType
from uuid import UUID
import jwt
from fastapi import Request
@@ -14,10 +13,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 +22,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 (
@@ -66,25 +59,6 @@ class SaasUserAuth(UserAuth):
_secrets: Secrets | None = None
accepted_tos: bool | None = None
auth_type: AuthType = AuthType.COOKIE
# API key context fields - populated when authenticated via API key
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
# Organization context fields - populated lazily via get_org_info()
_org_id: str | None = None
_org_name: str | None = None
_role: str | None = None
_permissions: list[str] | None = None
_org_info_loaded: bool = False
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
Returns:
The org_id if authenticated via API key with org binding, None otherwise
(cookie auth or legacy API keys without org binding).
"""
return self.api_key_org_id
async def get_user_id(self) -> str | None:
return self.user_id
@@ -254,72 +228,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')
@@ -375,19 +283,14 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
return None
api_key_store = ApiKeyStore.get_instance()
validation_result = await api_key_store.validate_api_key(api_key)
if not validation_result:
user_id = await api_key_store.validate_api_key(api_key)
if not user_id:
return None
offline_token = await token_manager.load_offline_token(
validation_result.user_id
)
offline_token = await token_manager.load_offline_token(user_id)
saas_user_auth = SaasUserAuth(
user_id=validation_result.user_id,
user_id=user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
api_key_org_id=validation_result.org_id,
api_key_id=validation_result.key_id,
api_key_name=validation_result.key_name,
)
await saas_user_auth.refresh()
return saas_user_auth
-2
View File
@@ -20,7 +20,6 @@ from server.auth.constants import (
GITLAB_APP_CLIENT_ID,
RECAPTCHA_SITE_KEY,
)
from server.constants import DEPLOYMENT_MODE
from openhands.core.config.utils import load_openhands_config
from openhands.integrations.service_types import ProviderType
@@ -180,7 +179,6 @@ class SaaSServerConfig(ServerConfig):
'ENABLE_JIRA': self.enable_jira,
'ENABLE_JIRA_DC': self.enable_jira_dc,
'ENABLE_LINEAR': self.enable_linear,
'DEPLOYMENT_MODE': DEPLOYMENT_MODE,
},
'PROVIDERS_CONFIGURED': providers_configured,
}
-27
View File
@@ -15,33 +15,6 @@ IS_FEATURE_ENV = (
) # Does not include the staging deployment
IS_LOCAL_ENV = bool(HOST == 'localhost')
# _is_all_hands_managed_domain() can be removed/replaced when a self-hosted specific
# env var is created (e.g is_self_hosted` or `deployment_mode`)
def _is_all_hands_managed_domain(host: str) -> bool:
"""Check if the host is an All-Hands managed domain."""
return (
host == 'app.all-hands.dev'
or host == 'app.openhands.ai'
or host.endswith('.all-hands.dev')
or host.endswith('.openhands.ai')
)
def _get_deployment_mode() -> str:
"""Determine deployment mode based on WEB_HOST.
Returns:
'cloud' for All-Hands managed infrastructure (app.all-hands.dev, etc.)
'self_hosted' for enterprise self-hosted deployments (customer domains)
"""
if _is_all_hands_managed_domain(HOST):
return 'cloud'
return 'self_hosted'
DEPLOYMENT_MODE = _get_deployment_mode()
# Role name constants
ROLE_OWNER = 'owner'
ROLE_ADMIN = 'admin'
+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)
-4
View File
@@ -182,10 +182,6 @@ class SetAuthCookieMiddleware:
if path.startswith('/api/v1/webhooks/'):
return False
# Service API uses its own authentication (X-Service-API-Key header)
if path.startswith('/api/service/'):
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp
-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
+2 -55
View File
@@ -1,9 +1,7 @@
from datetime import UTC, datetime
from typing import cast
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from server.auth.saas_user_auth import SaasUserAuth
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
from storage.lite_llm_manager import LiteLlmManager
@@ -13,8 +11,7 @@ from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_auth, get_user_id
from openhands.server.user_auth.user_auth import AuthType
from openhands.server.user_auth import get_user_id
# Helper functions for BYOR API key management
@@ -153,16 +150,6 @@ class MessageResponse(BaseModel):
message: str
class CurrentApiKeyResponse(BaseModel):
"""Response model for the current API key endpoint."""
id: int
name: str | None
org_id: str
user_id: str
auth_type: str
def api_key_to_response(key: ApiKey) -> ApiKeyResponse:
"""Convert an ApiKey model to an ApiKeyResponse."""
return ApiKeyResponse(
@@ -275,46 +262,6 @@ async def delete_api_key(
)
@api_router.get('/current', tags=['Keys'])
async def get_current_api_key(
request: Request,
user_id: str = Depends(get_user_id),
) -> CurrentApiKeyResponse:
"""Get information about the currently authenticated API key.
This endpoint returns metadata about the API key used for the current request,
including the org_id associated with the key. This is useful for API key
callers who need to know which organization context their key operates in.
Returns 400 if not authenticated via API key (e.g., using cookie auth).
"""
user_auth = await get_user_auth(request)
# Check if authenticated via API key
if user_auth.get_auth_type() != AuthType.BEARER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This endpoint requires API key authentication. Not available for cookie-based auth.',
)
# In SaaS context, bearer auth always produces SaasUserAuth
saas_user_auth = cast(SaasUserAuth, user_auth)
if saas_user_auth.api_key_org_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This API key was created before organization support. Please regenerate your API key to use this endpoint.',
)
return CurrentApiKeyResponse(
id=saas_user_auth.api_key_id,
name=saas_user_auth.api_key_name,
org_id=str(saas_user_auth.api_key_org_id),
user_id=user_id,
auth_type=saas_user_auth.auth_type.value,
)
@api_router.get('/llm/byor', tags=['Keys'])
async def get_llm_api_key_for_byor(
user_id: str = Depends(get_user_id),
+5 -156
View File
@@ -27,10 +27,7 @@ from server.auth.user.user_authorizer import (
depends_user_authorizer,
)
from server.config import sign_token
from server.constants import (
DEPLOYMENT_MODE,
IS_FEATURE_ENV,
)
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from server.services.org_invitation_service import (
EmailMismatchError,
@@ -175,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,
@@ -465,20 +445,8 @@ async def keycloak_callback(
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
response = RedirectResponse(tos_redirect_url, status_code=302)
else:
# User has accepted TOS - check if they need onboarding
# Only redirect to onboarding if user has a valid offline token,
# otherwise they need to complete the Keycloak offline token flow first
if valid_offline_token and await _should_redirect_to_onboarding(user_id, user):
redirect_url = f'{web_url}/onboarding'
logger.info(
'Redirecting returning user to onboarding',
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
)
if invitation_token:
if '?' in redirect_url:
redirect_url = f'{redirect_url}&invitation_success=true'
else:
redirect_url = f'{redirect_url}?invitation_success=true'
redirect_url = f'{redirect_url}&invitation_success=true'
response = RedirectResponse(redirect_url, status_code=302)
set_response_cookie(
@@ -486,7 +454,7 @@ async def keycloak_callback(
response=response,
keycloak_access_token=keycloak_access_token,
keycloak_refresh_token=keycloak_refresh_token,
secure=True if web_url.startswith('https') else False,
secure=True if redirect_url.startswith('https') else False,
accepted_tos=has_accepted_tos,
)
@@ -527,23 +495,8 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
user_id=user_info.sub, offline_token=keycloak_refresh_token
)
user = await UserStore.get_user_by_id(user_info.sub)
has_accepted_tos = user is not None and user.accepted_tos is not None
redirect_url, _, _ = _extract_oauth_state(state)
default_url = redirect_url if redirect_url else web_url
final_url = await _get_post_auth_redirect(user_info.sub, default_url, web_url, user)
response = RedirectResponse(final_url, status_code=302)
set_response_cookie(
request=request,
response=response,
keycloak_access_token=keycloak_access_token,
keycloak_refresh_token=keycloak_refresh_token,
secure=True if web_url.startswith('https') else False,
accepted_tos=has_accepted_tos,
)
return response
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
@oauth_router.get('/github/callback')
@@ -579,74 +532,6 @@ async def authenticate(request: Request):
return response
async def _should_redirect_to_onboarding(user_id: str, user: User) -> bool:
"""Check if user should be redirected to onboarding after TOS acceptance.
Backend always redirects applicable users to /onboarding. The frontend
checks the ENABLE_ONBOARDING feature flag (localStorage) and redirects
to / if the flag is disabled. This avoids needing helm chart changes.
Returns True if:
- User has onboarding_completed explicitly set to False (new users)
- Either:
- Deployment mode is 'cloud' (all users)
- Deployment mode is 'self_hosted' AND user is the super admin
(first owner in their current org to accept TOS)
Returns False if:
- User has onboarding_completed=True (already completed)
- User has onboarding_completed=None (existing users before this feature)
"""
# Already completed onboarding
if user.onboarding_completed is True:
return False
# Existing user before this feature (NULL in database)
if user.onboarding_completed is None:
return False
# Cloud SaaS: all users go to onboarding
if DEPLOYMENT_MODE == 'cloud':
return True
# Self-hosted SaaS: only the super admin (first owner to accept TOS in the org)
if DEPLOYMENT_MODE == 'self_hosted':
first_owner = await UserStore.get_first_owner_in_org(user.current_org_id)
if first_owner and str(first_owner.id) == user_id:
return True
return False
async def _get_post_auth_redirect(
user_id: str, default_url: str, web_url: str, user: User | None = None
) -> str:
"""Determine where to redirect user after authentication completes.
Called after offline token is stored to determine final redirect destination.
Checks for pending user flows (e.g., onboarding) before falling back to default.
Args:
user_id: The user's ID.
default_url: The default URL to redirect to if no special flow is needed.
web_url: The base web URL for constructing absolute paths.
user: Optional user object to avoid refetching.
Returns:
The URL to redirect the user to.
"""
if not user:
user = await UserStore.get_user_by_id(user_id)
if user and await _should_redirect_to_onboarding(user_id, user):
logger.info(
'Redirecting user to onboarding',
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
)
return f'{web_url}/onboarding'
return default_url
@api_router.post('/accept_tos')
async def accept_tos(request: Request):
user_auth = cast(SaasUserAuth, await get_user_auth(request))
@@ -687,12 +572,6 @@ async def accept_tos(request: Request):
logger.info(f'User {user_id} accepted TOS')
# Determine final redirect - but don't override if it's the offline token flow
# (the offline callback will handle post-auth redirect after storing the token)
is_offline_flow = 'offline' in redirect_url
if not is_offline_flow:
redirect_url = await _get_post_auth_redirect(user_id, redirect_url, web_url)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
)
@@ -702,42 +581,12 @@ async def accept_tos(request: Request):
response=response,
keycloak_access_token=access_token.get_secret_value(),
keycloak_refresh_token=refresh_token.get_secret_value(),
secure=True if web_url.startswith('https') else False,
secure=not IS_LOCAL_ENV,
accepted_tos=True,
)
return response
@api_router.post('/complete_onboarding')
async def complete_onboarding(request: Request):
"""Mark onboarding as completed for the current user."""
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_id = await user_auth.get_user_id()
if not user_id:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'User is not authenticated'},
)
user = await UserStore.mark_onboarding_completed(user_id)
if not user:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'User not found'},
)
logger.info(
'User completed onboarding',
extra={'user_id': user_id},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Onboarding completed'},
)
@api_router.post('/logout')
async def logout(request: Request):
# Always create the response object first to ensure we can return it even if errors occur
+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
+2 -21
View File
@@ -3,17 +3,13 @@ import hashlib
import hmac
import os
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from integrations.github.data_collector import GitHubDataCollector
from integrations.github.github_manager import GithubManager
from integrations.models import Message, SourceType
from server.auth.constants import (
AUTOMATION_EVENT_FORWARDING_ENABLED,
GITHUB_APP_WEBHOOK_SECRET,
)
from server.auth.constants import GITHUB_APP_WEBHOOK_SECRET
from server.auth.token_manager import TokenManager
from server.services.automation_event_service import AutomationEventService
from openhands.core.logger import openhands_logger as logger
@@ -26,7 +22,6 @@ github_integration_router = APIRouter(prefix='/integration')
token_manager = TokenManager()
data_collector = GitHubDataCollector()
github_manager = GithubManager(token_manager, data_collector)
automation_event_service = AutomationEventService(token_manager)
def verify_github_signature(payload: bytes, signature: str):
@@ -51,9 +46,7 @@ def verify_github_signature(payload: bytes, signature: str):
@github_integration_router.post('/github/events')
async def github_events(
request: Request,
background_tasks: BackgroundTasks,
x_hub_signature_256: str = Header(None),
x_github_event: str = Header(None),
):
# Check if GitHub webhooks are enabled
if not GITHUB_WEBHOOKS_ENABLED:
@@ -79,18 +72,6 @@ async def github_events(
content={'error': 'Installation ID is missing in the payload.'},
)
# Forward to automation service (fire-and-forget background task)
if AUTOMATION_EVENT_FORWARDING_ENABLED:
logger.info(
f'triggering forward_github_event with payload: {payload_data}, installation_id: {installation_id}'
)
background_tasks.add_task(
automation_event_service.forward_github_event,
payload=payload_data,
installation_id=installation_id,
)
# Existing resolver bot processing
message_payload = {'payload': payload_data, 'installation': installation_id}
message = Message(source=SourceType.GITHUB, message=message_payload)
await github_manager.receive_message(message)
+1 -6
View File
@@ -149,12 +149,7 @@ async def verify_jira_signature(body: bytes, signature: str, payload: dict):
workspace_name = jira_manager.get_workspace_name_from_payload(payload)
if workspace_name is None:
logger.warning(
'[Jira] No workspace name found in webhook payload',
extra={
'payload': payload,
},
)
logger.warning('[Jira] No workspace name found in webhook payload')
raise HTTPException(
status_code=403, detail='Workspace name not found in payload'
)
@@ -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
+2 -286
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
@@ -76,7 +68,7 @@ async def list_user_orgs(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, le=100),
Query(title='The max number of results in the page', gt=0, lte=100),
] = 100,
user_id: str = Depends(get_user_id),
) -> OrgPage:
@@ -742,7 +734,7 @@ async def get_org_members(
Query(
title='The max number of results in the page',
gt=0,
le=100,
lte=100,
),
] = 10,
email: Annotated[
@@ -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',
)
-270
View File
@@ -1,270 +0,0 @@
"""
Service API routes for internal service-to-service communication.
This module provides endpoints for trusted internal services (e.g., automations service)
to perform privileged operations like creating API keys on behalf of users.
Authentication is via a shared secret (X-Service-API-Key header) configured
through the AUTOMATIONS_SERVICE_KEY environment variable.
"""
import os
from uuid import UUID
from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel, field_validator
from storage.api_key_store import ApiKeyStore
from storage.org_member_store import OrgMemberStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
# Environment variable for the service API key
AUTOMATIONS_SERVICE_KEY = os.getenv('AUTOMATIONS_SERVICE_KEY', '').strip()
service_router = APIRouter(prefix='/api/service', tags=['Service'])
class CreateUserApiKeyRequest(BaseModel):
"""Request model for creating an API key on behalf of a user."""
name: str # Required - used to identify the key
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('name is required and cannot be empty')
return v.strip()
class CreateUserApiKeyResponse(BaseModel):
"""Response model for created API key."""
key: str
user_id: str
org_id: str
name: str
class ServiceInfoResponse(BaseModel):
"""Response model for service info endpoint."""
service: str
authenticated: bool
async def validate_service_api_key(
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> str:
"""
Validate the service API key from the request header.
Args:
x_service_api_key: The service API key from the X-Service-API-Key header
Returns:
str: Service identifier for audit logging
Raises:
HTTPException: 401 if key is missing or invalid
HTTPException: 503 if service auth is not configured
"""
if not AUTOMATIONS_SERVICE_KEY:
logger.warning(
'Service authentication not configured (AUTOMATIONS_SERVICE_KEY not set)'
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Service authentication not configured',
)
if not x_service_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='X-Service-API-Key header is required',
)
if x_service_api_key != AUTOMATIONS_SERVICE_KEY:
logger.warning('Invalid service API key attempted')
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid service API key',
)
return 'automations-service'
@service_router.get('/health')
async def service_health() -> dict:
"""Health check endpoint for the service API.
This endpoint does not require authentication and can be used
to verify the service routes are accessible.
"""
return {
'status': 'ok',
'service_auth_configured': bool(AUTOMATIONS_SERVICE_KEY),
}
@service_router.post('/users/{user_id}/orgs/{org_id}/api-keys')
async def get_or_create_api_key_for_user(
user_id: str,
org_id: UUID,
request: CreateUserApiKeyRequest,
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> CreateUserApiKeyResponse:
"""
Get or create an API key for a user on behalf of the automations service.
If a key with the given name already exists for the user/org and is not expired,
returns the existing key. Otherwise, creates a new key.
The created/returned keys are system keys and are:
- Not visible to the user in their API keys list
- Not deletable by the user
- Never expire
Args:
user_id: The user ID
org_id: The organization ID
request: Request body containing name (required)
x_service_api_key: Service API key header for authentication
Returns:
CreateUserApiKeyResponse: The API key and metadata
Raises:
HTTPException: 401 if service key is invalid
HTTPException: 404 if user not found
HTTPException: 403 if user is not a member of the specified org
"""
# Validate service API key
service_id = await validate_service_api_key(x_service_api_key)
# Verify user exists
user = await UserStore.get_user_by_id(user_id)
if not user:
logger.warning(
'Service attempted to create key for non-existent user',
extra={'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'User {user_id} not found',
)
# Verify user is a member of the specified org
org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id))
if not org_member:
logger.warning(
'Service attempted to create key for user not in org',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'User {user_id} is not a member of org {org_id}',
)
# Get or create the system API key
api_key_store = ApiKeyStore.get_instance()
try:
api_key = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name=request.name,
)
except Exception as e:
logger.exception(
'Failed to get or create system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to get or create API key',
)
logger.info(
'Service created API key for user',
extra={
'service_id': service_id,
'user_id': user_id,
'org_id': str(org_id),
'key_name': request.name,
},
)
return CreateUserApiKeyResponse(
key=api_key,
user_id=user_id,
org_id=str(org_id),
name=request.name,
)
@service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}')
async def delete_user_api_key(
user_id: str,
org_id: UUID,
key_name: str,
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> dict:
"""
Delete a system API key created by the service.
This endpoint allows the automations service to clean up API keys
it previously created for users.
Args:
user_id: The user ID
org_id: The organization ID
key_name: The name of the key to delete (without __SYSTEM__: prefix)
x_service_api_key: Service API key header for authentication
Returns:
dict: Success message
Raises:
HTTPException: 401 if service key is invalid
HTTPException: 404 if key not found
"""
# Validate service API key
service_id = await validate_service_api_key(x_service_api_key)
api_key_store = ApiKeyStore.get_instance()
# Delete the key by name (wrap with system key prefix since service creates system keys)
system_key_name = api_key_store.make_system_key_name(key_name)
success = await api_key_store.delete_api_key_by_name(
user_id=user_id,
org_id=org_id,
name=system_key_name,
allow_system=True,
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}',
)
logger.info(
'Service deleted API key for user',
extra={
'service_id': service_id,
'user_id': user_id,
'org_id': str(org_id),
'key_name': key_name,
},
)
return {'message': 'API key deleted successfully'}
+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,448 +0,0 @@
"""
Service for forwarding GitHub webhook events to the automation service.
This service is optimized for high-traffic scenarios:
1. Resolves GitHub org OpenHands org_id (via cached OrgGitClaim lookup)
2. For personal repos, resolves to personal org (via cached GitHubKeycloak mapping)
3. Forwards minimal payload to automation service (just org_id + payload)
4. Access control checks are deferred to automation execution time
The lazy access control approach means:
- Most webhooks only do cached lookups + HTTP forward
- Membership checks only happen when an automation actually matches
Security notes:
- Uses AUTOMATION_WEBHOOK_SECRET (not GitHub webhook secret) for internal service signing
- Negative results are cached to prevent DoS via repeated lookups for unclaimed orgs
"""
import asyncio
import hashlib
import hmac
import json
from dataclasses import dataclass
from typing import Any
from uuid import UUID
import aiohttp
from integrations.resolver_org_router import resolve_org_for_repo
from server.auth.constants import (
AUTOMATION_SERVICE_TIMEOUT,
AUTOMATION_SERVICE_URL,
AUTOMATION_WEBHOOK_SECRET,
)
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderType
from openhands.server.shared import sio
# Cache TTL constants
ORG_CLAIM_CACHE_TTL_SECONDS = 3600 # 1 hour for org claims (rarely change)
USER_ID_CACHE_TTL_SECONDS = 86400 # 24 hours for user ID mappings (never change)
# Cache key prefixes
ORG_CLAIM_CACHE_PREFIX = 'automation:org_claim'
USER_ID_CACHE_PREFIX = 'automation:gh_to_kc_user'
@dataclass
class OrgContext:
"""Context for the resolved organization."""
org_id: UUID
github_org: str
class AutomationEventService:
"""
Service for forwarding webhook events to the automation service.
Optimized for high traffic with:
- Redis caching for org claim lookups (1 hour TTL)
- Redis caching for GitHubKeycloak user ID mappings (24 hour TTL)
- Lazy access control (membership checks deferred to execution time)
"""
def __init__(self, token_manager: TokenManager):
from server.auth.constants import AUTOMATION_EVENT_FORWARDING_ENABLED
self.token_manager = token_manager
# Fail fast if forwarding is enabled but misconfigured
if AUTOMATION_EVENT_FORWARDING_ENABLED:
if not AUTOMATION_SERVICE_URL:
raise ValueError(
'AUTOMATION_EVENT_FORWARDING_ENABLED=true but '
'AUTOMATION_SERVICE_URL is not configured'
)
if not AUTOMATION_WEBHOOK_SECRET:
raise ValueError(
'AUTOMATION_EVENT_FORWARDING_ENABLED=true but '
'AUTOMATION_WEBHOOK_SECRET is not configured'
)
async def forward_github_event(
self,
payload: dict[str, Any],
installation_id: int,
) -> None:
"""
Forward a GitHub webhook event to the automation service.
This is designed to be called as a fire-and-forget background task.
The forward path is optimized for speed - only org resolution is done here.
Access control checks are deferred to automation execution time.
Args:
payload: The raw GitHub webhook payload
installation_id: The GitHub App installation ID
"""
org_id: UUID | None = None
try:
logger.info(f'Retrieving org context for payload {payload}')
# Resolve org context (org_id and github_org name) - uses Redis cache
org_context = await self._resolve_org_context(payload)
logger.info(f'org context for payload {payload} is {org_context}')
if not org_context:
return
org_id = org_context.org_id
# Build minimal payload and forward immediately
# Access control is NOT computed here - it's deferred to execution time
event_payload = self._build_event_payload(org_context, payload)
logger.info(f'event_payload is {event_payload}')
await self._send_to_automation_service(org_id, event_payload)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
# Network errors are expected and recoverable
logger.error(
f'[AutomationEventService] Network error forwarding event '
f'(org_id={org_id}): {e}',
exc_info=True,
extra={'installation_id': installation_id},
)
except Exception as e:
# Log unexpected errors. Note: This is a background task, so exceptions
# won't surface to the HTTP caller - they're logged for debugging only.
logger.error(
f'[AutomationEventService] Unexpected error forwarding event '
f'(org_id={org_id}): {e}',
exc_info=True,
extra={'installation_id': installation_id},
)
# Don't re-raise in background task - just log for debugging
async def _resolve_org_context(self, payload: dict[str, Any]) -> OrgContext | None:
"""
Resolve the organization context from the webhook payload.
Uses Redis caching for both org claims and user ID mappings.
Returns None if the org cannot be resolved (not claimed, no personal org).
"""
repo = payload.get('repository', {})
owner = repo.get('owner', {})
git_org_name = owner.get('login')
owner_type = owner.get('type') # 'User' or 'Organization'
if not git_org_name:
logger.warning(
'[AutomationEventService] No repository owner in payload, skipping'
)
return None
# Try to resolve via OrgGitClaim
org_id = await self._resolve_github_org(git_org_name)
# Fallback for personal repos
if not org_id and owner_type == 'User':
org_id = await self._resolve_personal_org(owner.get('id'))
if org_id:
logger.info(
f'[AutomationEventService] Resolved personal repo owner '
f'{git_org_name} to personal org {org_id}'
)
if not org_id:
logger.warning(
f'[AutomationEventService] GitHub org {git_org_name} not claimed '
f'and no personal org found, skipping'
)
return None
return OrgContext(org_id=org_id, github_org=git_org_name)
def _build_event_payload(
self,
org_context: OrgContext,
payload: dict[str, Any],
) -> dict[str, Any]:
"""
Build the minimal event payload to forward to the automation service.
Access control is NOT included here - it's deferred to execution time.
This keeps the forward path fast for high-traffic scenarios.
"""
return {
'organization': {
'github_org': org_context.github_org,
'openhands_org_id': str(org_context.org_id),
},
'payload': payload,
}
# =========================================================================
# Cached Org Resolution Methods
# =========================================================================
async def _resolve_github_org(self, git_org_name: str) -> UUID | None:
"""
Resolve a GitHub organization name to an OpenHands org_id.
Uses Redis caching with 1-hour TTL. Caches both positive and negative
results to avoid repeated DB queries for unclaimed orgs.
Note: GitHub org names are case-insensitive. We normalize to lowercase
for both cache keys and DB queries. This matches the OrgGitClaim schema
which stores git_organization as lowercase (enforced by GitOrgClaimRequest
validator in org_models.py).
"""
normalized_org = git_org_name.lower()
cache_key = f'{ORG_CLAIM_CACHE_PREFIX}:{normalized_org}'
# Check cache first
cached = await self._get_cached_value(cache_key)
if cached is not None:
if cached == 'none':
logger.debug(
f'[AutomationEventService] Cache hit (negative): org {git_org_name} not claimed'
)
return None
logger.debug(
f'[AutomationEventService] Cache hit: org {git_org_name} -> {cached}'
)
return UUID(cached)
# Cache miss - use resolve_org_for_repo without user_id (no membership check)
# Construct a minimal repo name since resolve_org_for_repo extracts the org
org_id = await resolve_org_for_repo(
provider='github',
full_repo_name=f'{normalized_org}/',
)
# Cache the result (including negative results)
if org_id:
await self._set_cached_value(
cache_key, str(org_id), ORG_CLAIM_CACHE_TTL_SECONDS
)
return org_id
else:
# Cache negative result to avoid repeated DB queries
await self._set_cached_value(cache_key, 'none', ORG_CLAIM_CACHE_TTL_SECONDS)
return None
async def _resolve_personal_org(self, github_user_id: int | None) -> UUID | None:
"""
Resolve a GitHub user to their personal OpenHands org.
For personal repos (owner type is 'User'), the OpenHands org_id
is the user's keycloak user ID. This allows users to set up
automations on their personal repos without needing an OrgGitClaim.
Uses Redis caching for the GitHubKeycloak user ID mapping (24h TTL).
"""
if not github_user_id:
return None
keycloak_id = await self._get_keycloak_user_id_cached(github_user_id)
if keycloak_id:
return UUID(keycloak_id)
return None
async def _get_keycloak_user_id_cached(self, github_user_id: int) -> str | None:
"""
Convert a GitHub user ID to a Keycloak user ID.
Uses Redis caching with 24-hour TTL since this mapping never changes.
Caches negative results to avoid repeated Keycloak queries.
"""
cache_key = f'{USER_ID_CACHE_PREFIX}:{github_user_id}'
# Check cache first
cached = await self._get_cached_value(cache_key)
if cached is not None:
if cached == 'none':
logger.debug(
f'[AutomationEventService] Cache hit (negative): GitHub user {github_user_id} not in Keycloak'
)
return None
logger.debug(
f'[AutomationEventService] Cache hit: GitHub user {github_user_id} -> Keycloak {cached}'
)
return cached
# Cache miss - query Keycloak
try:
keycloak_id = await self.token_manager.get_user_id_from_idp_user_id(
str(github_user_id), ProviderType.GITHUB
)
# Cache the result (including negative results)
if keycloak_id:
await self._set_cached_value(
cache_key, keycloak_id, USER_ID_CACHE_TTL_SECONDS
)
else:
# Cache negative result to prevent repeated Keycloak queries (DoS mitigation)
await self._set_cached_value(
cache_key, 'none', USER_ID_CACHE_TTL_SECONDS
)
return keycloak_id
except Exception as e:
# Log at warning level to surface programmer errors and API issues
logger.warning(
f'[AutomationEventService] Failed to get keycloak ID for GitHub user {github_user_id}: {e}'
)
return None
# =========================================================================
# Generic Redis Cache Helpers
# =========================================================================
async def _get_cached_value(self, cache_key: str) -> str | None:
"""
Get a cached value from Redis.
Returns the cached string value, or None if not cached or Redis unavailable.
Falls back to DB/API queries if Redis is unavailable (graceful degradation).
Warning: When Redis is unavailable, every webhook will hit the DB directly.
Monitor logs for 'Redis unavailable' warnings to detect degradation.
"""
try:
redis = getattr(sio.manager, 'redis', None)
if not redis:
# Log at warning level - this is a significant degradation that
# will cause DB load. Monitor these logs for alerting.
logger.warning(
'[AutomationEventService] Redis unavailable for cache read, '
'falling back to direct DB queries (this will increase DB load)'
)
return None
cached = await redis.get(cache_key)
if cached is None:
return None
# Redis returns bytes, decode to string
return cached.decode('utf-8') if isinstance(cached, bytes) else cached
except Exception as e:
# Log at warning level - cache errors cause DB fallback
logger.warning(
f'[AutomationEventService] Redis cache read error (falling back to DB): {e}'
)
return None
async def _set_cached_value(
self, cache_key: str, value: str, ttl_seconds: int
) -> None:
"""
Set a cached value in Redis with TTL.
Fails silently if Redis is unavailable (graceful degradation).
"""
try:
redis = getattr(sio.manager, 'redis', None)
if not redis:
# Silent failure - read path already logs the warning
return
await redis.setex(cache_key, ttl_seconds, value)
except Exception as e:
# Log at warning level for visibility
logger.warning(f'[AutomationEventService] Redis cache write error: {e}')
def _sign_payload(self, payload_bytes: bytes) -> str:
"""
Sign a payload using the dedicated automation shared secret.
Uses AUTOMATION_WEBHOOK_SECRET (not GitHub webhook secret) to maintain
separate trust boundaries between GitHub webhooks and internal services.
Returns the signature in the format 'sha256=<hex_digest>'.
"""
signature = hmac.new(
AUTOMATION_WEBHOOK_SECRET.encode('utf-8'),
msg=payload_bytes,
digestmod=hashlib.sha256,
).hexdigest()
return f'sha256={signature}'
async def _send_to_automation_service(
self,
org_id: UUID,
payload: dict[str, Any],
) -> None:
"""
Send the normalized payload to the automation service.
The payload is signed using AUTOMATION_WEBHOOK_SECRET so the
automation service can verify it came from the OpenHands server.
"""
if not AUTOMATION_SERVICE_URL:
logger.warning(
'[AutomationEventService] AUTOMATION_SERVICE_URL not configured'
)
return
# Build endpoint URL. AUTOMATION_SERVICE_URL may include path segments
# (e.g., https://example.com/api/automation), so we strip trailing slash
# and append our path.
url = f'{AUTOMATION_SERVICE_URL.rstrip("/")}/v1/events/{org_id}/github'
# Serialize payload to JSON bytes for signing
payload_bytes = json.dumps(payload, separators=(',', ':')).encode('utf-8')
signature = self._sign_payload(payload_bytes)
headers = {
'Content-Type': 'application/json',
'X-Hub-Signature-256': signature,
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
url,
data=payload_bytes,
headers=headers,
timeout=aiohttp.ClientTimeout(total=AUTOMATION_SERVICE_TIMEOUT),
) as resp:
if resp.status >= 400:
# Try JSON first (expected interface), fall back to text
# for infrastructure errors (502/503 from load balancer)
try:
body = await resp.json()
except (aiohttp.ContentTypeError, ValueError):
body = await resp.text()
logger.warning(
f'[AutomationEventService] Automation service returned '
f'{resp.status} for org {org_id}: {body}'
)
else:
data = await resp.json()
matched = data.get('matched', 0)
logger.info(
f'[AutomationEventService] Forwarded event to org {org_id}: '
f'{matched} automations matched'
)
except asyncio.TimeoutError:
logger.warning(
f'[AutomationEventService] Timeout ({AUTOMATION_SERVICE_TIMEOUT}s) '
'forwarding to automation service'
)
except aiohttp.ClientError as e:
logger.warning(
f'[AutomationEventService] HTTP error forwarding to automation service: {e}'
)
@@ -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
@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
@@ -60,7 +60,7 @@ async def search_shared_conversations(
Query(
title='The max number of results in the page',
gt=0,
le=100,
lte=100,
),
] = 100,
include_sub_conversations: Annotated[
@@ -72,6 +72,8 @@ async def search_shared_conversations(
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> SharedConversationPage:
"""Search / List shared conversations."""
assert limit > 0
assert limit <= 100
return await shared_conversation_service.search_shared_conversation_info(
title__contains=title__contains,
created_at__gte=created_at__gte,
@@ -125,11 +127,7 @@ async def batch_get_shared_conversations(
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> list[SharedConversation | None]:
"""Get a batch of shared conversations given their ids. Return None for any missing or non-shared."""
if len(ids) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 conversations at once, got {len(ids)}',
)
assert len(ids) <= 100
uuids = [UUID(id_) for id_ in ids]
shared_conversation_info = (
await shared_conversation_service.batch_get_shared_conversation_info(uuids)
@@ -1,18 +1,10 @@
"""Shared Event router for OpenHands Server.
All endpoints in this router are unauthenticated shared conversations are
public. To avoid returning internal system state that the viewer does not
need, ``ConversationStateUpdateEvent`` instances are filtered out before the
response is sent. The shared-conversation frontend only renders messages,
actions, observations, errors, and hook-execution events; state snapshots
are consumed exclusively by the authenticated WebSocket path.
"""
"""Shared Event router for OpenHands Server."""
from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
@@ -21,15 +13,9 @@ from server.sharing.shared_event_service import (
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.sdk import Event
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
from openhands.utils.environment import StorageProvider, get_storage_provider
def _is_viewable(event: Event) -> bool:
"""Return True if *event* should be included in public shared responses."""
return not isinstance(event, ConversationStateUpdateEvent)
def get_shared_event_service_injector() -> SharedEventServiceInjector:
"""Get the appropriate SharedEventServiceInjector based on configuration.
@@ -47,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 (
@@ -97,40 +77,21 @@ async def search_shared_events(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, le=100),
Query(title='The max number of results in the page', gt=0, lte=100),
] = 100,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> EventPage:
"""Search / List events for a shared conversation.
Because non-viewable events (e.g. ``ConversationStateUpdateEvent``) are
filtered out after fetching, a single backend page may yield fewer items
than *limit*. This method transparently fetches additional backend pages
until the requested *limit* is reached or there are no more results.
"""
conv_id = UUID(conversation_id)
viewable: list[Event] = []
cursor = page_id
while len(viewable) < limit:
remaining = limit - len(viewable)
page = await shared_event_service.search_shared_events(
conversation_id=conv_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=cursor,
limit=remaining,
)
viewable.extend(e for e in page.items if _is_viewable(e))
cursor = page.next_page_id
if cursor is None:
break
return EventPage(
items=viewable[:limit],
next_page_id=cursor,
"""Search / List events for a shared conversation."""
assert limit > 0
assert limit <= 100
return await shared_event_service.search_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
@@ -173,16 +134,12 @@ async def batch_get_shared_events(
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> list[Event | None]:
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
if len(id) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 events at once, got {len(id)}',
)
assert len(id) <= 100
event_ids = [UUID(id_) for id_ in id]
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids
)
return [e if e is not None and _is_viewable(e) else None for e in events]
return events
@router.get('/{conversation_id}/{event_id}')
@@ -192,9 +149,6 @@ async def get_shared_event(
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> Event | None:
"""Get a single event from a shared conversation by conversation_id and event_id."""
event = await shared_event_service.get_shared_event(
return await shared_event_service.get_shared_event(
UUID(conversation_id), UUID(event_id)
)
if event is not None and not _is_viewable(event):
return None
return event
@@ -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',
+13 -213
View File
@@ -4,7 +4,6 @@ import secrets
import string
from dataclasses import dataclass
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy import select, update
from storage.api_key import ApiKey
@@ -14,22 +13,9 @@ from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
@dataclass
class ApiKeyValidationResult:
"""Result of API key validation containing user and organization info."""
user_id: str
org_id: UUID | None # None for legacy API keys without org binding
key_id: int
key_name: str | None
@dataclass
class ApiKeyStore:
API_KEY_PREFIX = 'sk-oh-'
# Prefix for system keys created by internal services (e.g., automations)
# Keys with this prefix are hidden from users and cannot be deleted by users
SYSTEM_KEY_NAME_PREFIX = '__SYSTEM__:'
def generate_api_key(self, length: int = 32) -> str:
"""Generate a random API key with the sk-oh- prefix."""
@@ -37,19 +23,6 @@ class ApiKeyStore:
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
return f'{self.API_KEY_PREFIX}{random_part}'
@classmethod
def is_system_key_name(cls, name: str | None) -> bool:
"""Check if a key name indicates a system key."""
return name is not None and name.startswith(cls.SYSTEM_KEY_NAME_PREFIX)
@classmethod
def make_system_key_name(cls, name: str) -> str:
"""Create a system key name with the appropriate prefix.
Format: __SYSTEM__:<name>
"""
return f'{cls.SYSTEM_KEY_NAME_PREFIX}{name}'
async def create_api_key(
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
) -> str:
@@ -87,120 +60,8 @@ class ApiKeyStore:
return api_key
async def get_or_create_system_api_key(
self,
user_id: str,
org_id: UUID,
name: str,
) -> str:
"""Get or create a system API key for a user on behalf of an internal service.
If a key with the given name already exists for this user/org and is not expired,
returns the existing key. Otherwise, creates a new key (and deletes any expired one).
System keys are:
- Not visible to users in their API keys list (filtered by name prefix)
- Not deletable by users (protected by name prefix check)
- Associated with a specific org (not the user's current org)
- Never expire (no expiration date)
Args:
user_id: The ID of the user to create the key for
org_id: The organization ID to associate the key with
name: Required name for the key (will be prefixed with __SYSTEM__:)
Returns:
The API key (existing or newly created)
"""
# Create system key name with prefix
system_key_name = self.make_system_key_name(name)
async with a_session_maker() as session:
# Check if key already exists for this user/org/name
result = await session.execute(
select(ApiKey).filter(
ApiKey.user_id == user_id,
ApiKey.org_id == org_id,
ApiKey.name == system_key_name,
)
)
existing_key = result.scalars().first()
if existing_key:
# Check if expired
if existing_key.expires_at:
now = datetime.now(UTC)
expires_at = existing_key.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < now:
# Key is expired, delete it and create new one
logger.info(
'System API key expired, re-issuing',
extra={
'user_id': user_id,
'org_id': str(org_id),
'key_name': system_key_name,
},
)
await session.delete(existing_key)
await session.commit()
else:
# Key exists and is not expired, return it
logger.debug(
'Returning existing system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'key_name': system_key_name,
},
)
return existing_key.key
else:
# Key exists and has no expiration, return it
logger.debug(
'Returning existing system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'key_name': system_key_name,
},
)
return existing_key.key
# Create new key (no expiration)
api_key = self.generate_api_key()
async with a_session_maker() as session:
key_record = ApiKey(
key=api_key,
user_id=user_id,
org_id=org_id,
name=system_key_name,
expires_at=None, # System keys never expire
)
session.add(key_record)
await session.commit()
logger.info(
'Created system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'key_name': system_key_name,
},
)
return api_key
async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None:
"""Validate an API key and return the associated user_id and org_id if valid.
Returns:
ApiKeyValidationResult if the key is valid, None otherwise.
The org_id may be None for legacy API keys that weren't bound to an organization.
"""
async def validate_api_key(self, api_key: str) -> str | None:
"""Validate an API key and return the associated user_id if valid."""
now = datetime.now(UTC)
async with a_session_maker() as session:
@@ -228,12 +89,7 @@ class ApiKeyStore:
)
await session.commit()
return ApiKeyValidationResult(
user_id=key_record.user_id,
org_id=key_record.org_id,
key_id=key_record.id,
key_name=key_record.name,
)
return key_record.user_id
async def delete_api_key(self, api_key: str) -> bool:
"""Delete an API key by the key value."""
@@ -249,18 +105,8 @@ class ApiKeyStore:
return True
async def delete_api_key_by_id(
self, key_id: int, allow_system: bool = False
) -> bool:
"""Delete an API key by its ID.
Args:
key_id: The ID of the key to delete
allow_system: If False (default), system keys cannot be deleted
Returns:
True if the key was deleted, False if not found or is a protected system key
"""
async def delete_api_key_by_id(self, key_id: int) -> bool:
"""Delete an API key by its ID."""
async with a_session_maker() as session:
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
key_record = result.scalars().first()
@@ -268,26 +114,13 @@ class ApiKeyStore:
if not key_record:
return False
# Protect system keys from deletion unless explicitly allowed
if self.is_system_key_name(key_record.name) and not allow_system:
logger.warning(
'Attempted to delete system API key',
extra={'key_id': key_id, 'user_id': key_record.user_id},
)
return False
await session.delete(key_record)
await session.commit()
return True
async def list_api_keys(self, user_id: str) -> list[ApiKey]:
"""List all user-visible API keys for a user.
This excludes:
- System keys (name starts with __SYSTEM__:) - created by internal services
- MCP_API_KEY - internal MCP key
"""
"""List all API keys for a user."""
user = await UserStore.get_user_by_id(user_id)
if user is None:
raise ValueError(f'User not found: {user_id}')
@@ -296,17 +129,11 @@ class ApiKeyStore:
async with a_session_maker() as session:
result = await session.execute(
select(ApiKey).filter(
ApiKey.user_id == user_id,
ApiKey.org_id == org_id,
ApiKey.user_id == user_id, ApiKey.org_id == org_id
)
)
keys = result.scalars().all()
# Filter out system keys and MCP_API_KEY
return [
key
for key in keys
if key.name != 'MCP_API_KEY' and not self.is_system_key_name(key.name)
]
return [key for key in keys if key.name != 'MCP_API_KEY']
async def retrieve_mcp_api_key(self, user_id: str) -> str | None:
user = await UserStore.get_user_by_id(user_id)
@@ -336,44 +163,17 @@ class ApiKeyStore:
key_record = result.scalars().first()
return key_record.key if key_record else None
async def delete_api_key_by_name(
self,
user_id: str,
name: str,
org_id: UUID | None = None,
allow_system: bool = False,
) -> bool:
"""Delete an API key by name for a specific user.
Args:
user_id: The ID of the user whose key to delete
name: The name of the key to delete
org_id: Optional organization ID to filter by (required for system keys)
allow_system: If False (default), system keys cannot be deleted
Returns:
True if the key was deleted, False if not found or is a protected system key
"""
async def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
"""Delete an API key by name for a specific user."""
async with a_session_maker() as session:
# Build the query filters
filters = [ApiKey.user_id == user_id, ApiKey.name == name]
if org_id is not None:
filters.append(ApiKey.org_id == org_id)
result = await session.execute(select(ApiKey).filter(*filters))
result = await session.execute(
select(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.name == name)
)
key_record = result.scalars().first()
if not key_record:
return False
# Protect system keys from deletion unless explicitly allowed
if self.is_system_key_name(key_record.name) and not allow_system:
logger.warning(
'Attempted to delete system API key',
extra={'user_id': user_id, 'key_name': name},
)
return False
await session.delete(key_record)
await session.commit()
-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())
+39 -265
View File
@@ -29,37 +29,14 @@ KEY_VERIFICATION_TIMEOUT = 5.0
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
UNLIMITED_BUDGET_SETTING = 1000000000.0
# Check if billing is enabled (defaults to false for enterprise deployments)
ENABLE_BILLING = os.environ.get('ENABLE_BILLING', 'false').lower() == 'true'
def _get_default_initial_budget() -> float | None:
"""Get the default initial budget for new teams.
When billing is disabled (ENABLE_BILLING=false), returns None to disable
budget enforcement in LiteLLM. When billing is enabled, returns the
DEFAULT_INITIAL_BUDGET environment variable value (default 0.0).
Returns:
float | None: The default budget, or None to disable budget enforcement.
"""
if not ENABLE_BILLING:
return None
try:
budget = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
if budget < 0:
raise ValueError(
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {budget}'
)
return budget
except ValueError as e:
try:
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
if DEFAULT_INITIAL_BUDGET < 0:
raise ValueError(
f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}'
) from e
DEFAULT_INITIAL_BUDGET: float | None = _get_default_initial_budget()
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}'
)
except ValueError as e:
raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
@@ -133,15 +110,12 @@ class LiteLlmManager:
) as client:
# Check if team already exists and get its budget
# New users joining existing orgs should inherit the team's budget
# When billing is disabled, DEFAULT_INITIAL_BUDGET is None
team_budget: float | None = DEFAULT_INITIAL_BUDGET
team_budget: float = DEFAULT_INITIAL_BUDGET
try:
existing_team = await LiteLlmManager._get_team(client, org_id)
if existing_team:
team_info = existing_team.get('team_info', {})
# Preserve None from existing team (no budget enforcement)
existing_budget = team_info.get('max_budget')
team_budget = existing_budget
team_budget = team_info.get('max_budget', 0.0) or 0.0
logger.info(
'LiteLlmManager:create_entries:existing_team_budget',
extra={
@@ -164,33 +138,9 @@ class LiteLlmManager:
)
if create_user:
user_created = await LiteLlmManager._create_user(
await LiteLlmManager._create_user(
client, keycloak_user_info.get('email'), keycloak_user_id
)
if not user_created:
logger.error(
'create_entries_failed_user_creation',
extra={
'org_id': org_id,
'user_id': keycloak_user_id,
},
)
return None
# Verify user exists before proceeding with key generation
user_exists = await LiteLlmManager._user_exists(
client, keycloak_user_id
)
if not user_exists:
logger.error(
'create_entries_user_not_found_before_key_generation',
extra={
'org_id': org_id,
'user_id': keycloak_user_id,
'create_user_flag': create_user,
},
)
return None
await LiteLlmManager._add_user_to_team(
client, keycloak_user_id, org_id, team_budget
@@ -575,40 +525,25 @@ class LiteLlmManager:
client: httpx.AsyncClient,
team_alias: str,
team_id: str,
max_budget: float | None,
max_budget: float,
):
"""Create a new team in LiteLLM.
Args:
client: The HTTP client to use.
team_alias: The alias for the team.
team_id: The ID for the team.
max_budget: The maximum budget for the team. When None, budget
enforcement is disabled (unlimited usage).
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return
json_data: dict[str, Any] = {
'team_id': team_id,
'team_alias': team_alias,
'models': [],
'spend': 0,
'metadata': {
'version': ORG_SETTINGS_VERSION,
'model': get_default_litellm_model(),
},
}
if max_budget is not None:
json_data['max_budget'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/new',
json=json_data,
json={
'team_id': team_id,
'team_alias': team_alias,
'models': [],
'max_budget': max_budget,
'spend': 0,
'metadata': {
'version': ORG_SETTINGS_VERSION,
'model': get_default_litellm_model(),
},
},
)
# Team failed to create in litellm - this is an unforseen error state...
if not response.is_success:
if (
@@ -685,48 +620,15 @@ class LiteLlmManager:
)
response.raise_for_status()
@staticmethod
async def _user_exists(
client: httpx.AsyncClient,
user_id: str,
) -> bool:
"""Check if a user exists in LiteLLM.
Returns True if the user exists, False otherwise.
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
return False
try:
response = await client.get(
f'{LITE_LLM_API_URL}/user/info?user_id={user_id}',
)
if response.is_success:
user_data = response.json()
# Check that user_info exists and has the user_id
user_info = user_data.get('user_info', {})
return user_info.get('user_id') == user_id
return False
except Exception as e:
logger.warning(
'litellm_user_exists_check_failed',
extra={'user_id': user_id, 'error': str(e)},
)
return False
@staticmethod
async def _create_user(
client: httpx.AsyncClient,
email: str | None,
keycloak_user_id: str,
) -> bool:
"""Create a user in LiteLLM.
Returns True if the user was created or already exists and is verified,
False if creation failed and user does not exist.
"""
):
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return False
return
response = await client.post(
f'{LITE_LLM_API_URL}/user/new',
json={
@@ -779,33 +681,17 @@ class LiteLlmManager:
'user_id': keycloak_user_id,
},
)
# Verify the user actually exists before returning success
user_exists = await LiteLlmManager._user_exists(
client, keycloak_user_id
)
if not user_exists:
logger.error(
'litellm_user_claimed_exists_but_not_found',
extra={
'user_id': keycloak_user_id,
'status_code': response.status_code,
'text': response.text,
},
)
return False
return True
return
logger.error(
'error_creating_litellm_user',
extra={
'status_code': response.status_code,
'text': response.text,
'user_id': keycloak_user_id,
'user_id': [keycloak_user_id],
'email': None,
},
)
return False
response.raise_for_status()
return True
@staticmethod
async def _get_user(client: httpx.AsyncClient, user_id: str) -> dict | None:
@@ -1032,34 +918,19 @@ class LiteLlmManager:
client: httpx.AsyncClient,
keycloak_user_id: str,
team_id: str,
max_budget: float | None,
max_budget: float,
):
"""Add a user to a team in LiteLLM.
Args:
client: The HTTP client to use.
keycloak_user_id: The user's Keycloak ID.
team_id: The team ID.
max_budget: The maximum budget for the user in the team. When None,
budget enforcement is disabled (unlimited usage).
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return
json_data: dict[str, Any] = {
'team_id': team_id,
'member': {'user_id': keycloak_user_id, 'role': 'user'},
}
if max_budget is not None:
json_data['max_budget_in_team'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/member_add',
json=json_data,
json={
'team_id': team_id,
'member': {'user_id': keycloak_user_id, 'role': 'user'},
'max_budget_in_team': max_budget,
},
)
# Failed to add user to team - this is an unforseen error state...
if not response.is_success:
if (
@@ -1127,34 +998,19 @@ class LiteLlmManager:
client: httpx.AsyncClient,
keycloak_user_id: str,
team_id: str,
max_budget: float | None,
max_budget: float,
):
"""Update a user's budget in a team.
Args:
client: The HTTP client to use.
keycloak_user_id: The user's Keycloak ID.
team_id: The team ID.
max_budget: The maximum budget for the user in the team. When None,
budget enforcement is disabled (unlimited usage).
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return
json_data: dict[str, Any] = {
'team_id': team_id,
'user_id': keycloak_user_id,
}
if max_budget is not None:
json_data['max_budget_in_team'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/member_update',
json=json_data,
json={
'team_id': team_id,
'user_id': keycloak_user_id,
'max_budget_in_team': max_budget,
},
)
# Failed to update user in team - this is an unforseen error state...
if not response.is_success:
logger.error(
@@ -1524,83 +1380,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 +1387,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)
@@ -1619,7 +1397,6 @@ class LiteLlmManager:
create_team = staticmethod(with_http_client(_create_team))
get_team = staticmethod(with_http_client(_get_team))
update_team = staticmethod(with_http_client(_update_team))
user_exists = staticmethod(with_http_client(_user_exists))
create_user = staticmethod(with_http_client(_create_user))
get_user = staticmethod(with_http_client(_get_user))
update_user = staticmethod(with_http_client(_update_user))
@@ -1636,6 +1413,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)
)

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