mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
188 Commits
openhands/
...
fix-openha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bad8f1ebc | ||
|
|
acc0e893e3 | ||
|
|
a8098505c2 | ||
|
|
9b834bf660 | ||
|
|
5744f6602b | ||
|
|
4a82768e6d | ||
|
|
6f86e589c8 | ||
|
|
5bd8695ab8 | ||
|
|
8c73c87583 | ||
|
|
40c25cd1ce | ||
|
|
2ebde2529d | ||
|
|
cdc42130e1 | ||
|
|
903c047015 | ||
|
|
ee2ad16442 | ||
|
|
a96b47e481 | ||
|
|
5a08277184 | ||
|
|
63d5ceada6 | ||
|
|
1bae1fc4e6 | ||
|
|
15bc78f4c1 | ||
|
|
437046f5a4 | ||
|
|
714459d6eb | ||
|
|
f9b316453d | ||
|
|
96d073ee5b | ||
|
|
f7d416ac8e | ||
|
|
b7d5f903cf | ||
|
|
2734a5a52d | ||
|
|
51868ffac6 | ||
|
|
4c0f0a1e9b | ||
|
|
82e0aa7924 | ||
|
|
9043aa69d8 | ||
|
|
23d379fa41 | ||
|
|
6f9c0aa3b1 | ||
|
|
232dcf4991 | ||
|
|
ffdd95305f | ||
|
|
bfe8275963 | ||
|
|
06a97fc382 | ||
|
|
b5758b1604 | ||
|
|
3ae09680d6 | ||
|
|
0e5f4325be | ||
|
|
64d4085612 | ||
|
|
103e3ead0a | ||
|
|
d5e83d0f06 | ||
|
|
443918af3c | ||
|
|
910646d11f | ||
|
|
d9d19043f1 | ||
|
|
4dec38c7ce | ||
|
|
c3f51d9dbe | ||
|
|
ecbd3ae749 | ||
|
|
8ee1394e8c | ||
|
|
d628e1f20a | ||
|
|
1480d4acb0 | ||
|
|
58a70e8b0d | ||
|
|
49e46a5fa1 | ||
|
|
2cf6494773 | ||
|
|
d3afbfa447 | ||
|
|
8d69b4066f | ||
|
|
2261281656 | ||
|
|
d68b2cdd1a | ||
|
|
c70ecc8fe3 | ||
|
|
a3e85e2c2d | ||
|
|
3bef4e6c2d | ||
|
|
97654e6a5e | ||
|
|
30114666ad | ||
|
|
ee50f333ba | ||
|
|
09d1748a14 | ||
|
|
81519343c4 | ||
|
|
f742811e81 | ||
|
|
f8e4b5562e | ||
|
|
cb1d1f8a0d | ||
|
|
a829d10213 | ||
|
|
cb8c1fa263 | ||
|
|
c80f70392f | ||
|
|
94e6490a79 | ||
|
|
09af93a02a | ||
|
|
5407ea55aa | ||
|
|
fe1026ee8a | ||
|
|
6d14ce420e | ||
|
|
36fe23aea3 | ||
|
|
9049b95792 | ||
|
|
e2b2aa52cd | ||
|
|
dc99c7b62e | ||
|
|
8bc1a47a78 | ||
|
|
8d0e7a92b8 | ||
|
|
f6e7628bff | ||
|
|
fae83230ee | ||
|
|
a9d2f72d72 | ||
|
|
2b8f779b65 | ||
|
|
10edb28729 | ||
|
|
5553d3ca2e | ||
|
|
6605070d05 | ||
|
|
0677cebb25 | ||
|
|
fa2567b2a0 | ||
|
|
305396550a | ||
|
|
adff39507a | ||
|
|
a873af307a | ||
|
|
800e861b88 | ||
|
|
0246b1bc43 | ||
|
|
f870246c3d | ||
|
|
58340b3ef9 | ||
|
|
107d555445 | ||
|
|
ec9daf3bcc | ||
|
|
88c053b23c | ||
|
|
d063ee599b | ||
|
|
c2e4172088 | ||
|
|
d90579b398 | ||
|
|
aff9d69d41 | ||
|
|
afce58a27d | ||
|
|
43f7a6fdbd | ||
|
|
2ce6c9836e | ||
|
|
28dc3be034 | ||
|
|
2ed5c6073a | ||
|
|
9ef11bf930 | ||
|
|
f98e7fbc49 | ||
|
|
0607614372 | ||
|
|
2c83e419dc | ||
|
|
435e537693 | ||
|
|
dc14624480 | ||
|
|
281ac91540 | ||
|
|
7853b41add | ||
|
|
2a98c95557 | ||
|
|
3b7b2fd8cc | ||
|
|
49740a463f | ||
|
|
ee97542080 | ||
|
|
9753ad3a48 | ||
|
|
a12170e4c9 | ||
|
|
5c377f303f | ||
|
|
089d9c1ee5 | ||
|
|
f52d9899e2 | ||
|
|
47914c3576 | ||
|
|
67c9b6cf86 | ||
|
|
b937d344db | ||
|
|
f2def8fd7f | ||
|
|
eb9a22ef7e | ||
|
|
d57880f849 | ||
|
|
d772dd65a5 | ||
|
|
5daada17fd | ||
|
|
c6a8fc379b | ||
|
|
5a21c59a3c | ||
|
|
976d9d1ab9 | ||
|
|
6917d45d3a | ||
|
|
8a202b945b | ||
|
|
60491e30e8 | ||
|
|
62f594bc28 | ||
|
|
94086f119a | ||
|
|
f76016aa4d | ||
|
|
b16845fa06 | ||
|
|
f4dd5384d0 | ||
|
|
09e50b876d | ||
|
|
f7c3a36745 | ||
|
|
a593730b21 | ||
|
|
b308307ea2 | ||
|
|
1d1eb6dcb0 | ||
|
|
853547be82 | ||
|
|
272a37d1b8 | ||
|
|
92c91471b2 | ||
|
|
7875df4be8 | ||
|
|
0aaad16d35 | ||
|
|
df92923959 | ||
|
|
e18168020a | ||
|
|
a9c76d0ed4 | ||
|
|
3743d10766 | ||
|
|
9b57a0b14f | ||
|
|
8559efa7b2 | ||
|
|
bf06b7e3f3 | ||
|
|
959d610d86 | ||
|
|
16125f2ae9 | ||
|
|
d31950c061 | ||
|
|
db64abc580 | ||
|
|
ed7adb335c | ||
|
|
584517edec | ||
|
|
1a983d2978 | ||
|
|
d7b36c9579 | ||
|
|
72c7d9c497 | ||
|
|
7811a62491 | ||
|
|
4344f5ad4e | ||
|
|
17821f782e | ||
|
|
e1b283886f | ||
|
|
1d9cf72e39 | ||
|
|
59ca8bd9a8 | ||
|
|
3a9aa90c3a | ||
|
|
0a98f165e2 | ||
|
|
6ec477dae2 | ||
|
|
d0496fea8c | ||
|
|
8f91db8ec4 | ||
|
|
816d8acf1f | ||
|
|
97e6cb1340 | ||
|
|
cd9a3b02cf | ||
|
|
14695a8f0e |
12
.github/CODEOWNERS
vendored
12
.github/CODEOWNERS
vendored
@@ -1,12 +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 code owners
|
||||
/frontend/ @amanape
|
||||
/openhands-ui/ @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/frontend/ @amanape @hieptl
|
||||
/openhands-ui/ @amanape @hieptl
|
||||
/openhands/ @tofarr @malhotra5 @hieptl
|
||||
/enterprise/ @chuckbutkus @tofarr @malhotra5
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
# Documentation code owners
|
||||
/docs/ @mamoodi
|
||||
|
||||
2
.github/workflows/check-package-versions.yml
vendored
2
.github/workflows/check-package-versions.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
8
.github/workflows/e2e-tests.yml
vendored
8
.github/workflows/e2e-tests.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'poetry'
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
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@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: openhands-logs
|
||||
path: |
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
⚠️ This PR contains **migrations**
|
||||
|
||||
- name: Comment warning on PR
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
uses: peter-evans/create-or-update-comment@v5
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
|
||||
2
.github/workflows/enterprise-preview.yml
vendored
2
.github/workflows/enterprise-preview.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
|
||||
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
|
||||
|
||||
47
.github/workflows/fe-e2e-tests.yml
vendored
Normal file
47
.github/workflows/fe-e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# Workflow that runs frontend e2e tests with Playwright
|
||||
name: Run Frontend E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/fe-e2e-tests.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fe-e2e-test:
|
||||
name: FE E2E Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
- name: Install Playwright browsers
|
||||
working-directory: ./frontend
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test --project=chromium
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
12
.github/workflows/ghcr-build.yml
vendored
12
.github/workflows/ghcr-build.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.6.0
|
||||
uses: docker/setup-qemu-action@v3.7.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.6.0
|
||||
uses: docker/setup-qemu-action@v3.7.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
context: containers/runtime
|
||||
- name: Upload runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
|
||||
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Download runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Download runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
|
||||
6
.github/workflows/openhands-resolver.yml
vendored
6
.github/workflows/openhands-resolver.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Upgrade pip
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
contains(github.event.review.body, '@openhands-agent-exp')
|
||||
)
|
||||
)
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
|
||||
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload output.jsonl as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always() # Upload even if the previous steps fail
|
||||
with:
|
||||
name: resolver-output
|
||||
|
||||
6
.github/workflows/py-tests.yml
vendored
6
.github/workflows/py-tests.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage-openhands
|
||||
path: |
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage-enterprise
|
||||
path: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v5
|
||||
- uses: actions/download-artifact@v6
|
||||
id: download
|
||||
with:
|
||||
pattern: coverage-*
|
||||
|
||||
1
.github/workflows/stale.yml
vendored
1
.github/workflows/stale.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: github.repository == 'OpenHands/OpenHands'
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
|
||||
156
.github/workflows/vscode-extension-build.yml
vendored
156
.github/workflows/vscode-extension-build.yml
vendored
@@ -1,156 +0,0 @@
|
||||
# Workflow that validates the VSCode extension builds correctly
|
||||
name: VSCode Extension CI
|
||||
|
||||
# * Always run on "main"
|
||||
# * Run on PRs that have changes in the VSCode extension folder or this workflow
|
||||
# * Run on tags that start with "ext-v"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'ext-v*'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openhands/integrations/vscode/**'
|
||||
- 'build_vscode.py'
|
||||
- '.github/workflows/vscode-extension-build.yml'
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Validate VSCode extension builds correctly
|
||||
validate-vscode-extension:
|
||||
name: Validate VSCode Extension Build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install VSCode extension dependencies
|
||||
working-directory: ./openhands/integrations/vscode
|
||||
run: npm ci
|
||||
|
||||
- name: Build VSCode extension via build_vscode.py
|
||||
run: python build_vscode.py
|
||||
env:
|
||||
# Ensure we don't skip the build
|
||||
SKIP_VSCODE_BUILD: ""
|
||||
|
||||
- name: Validate .vsix file
|
||||
run: |
|
||||
# Verify the .vsix was created and is valid
|
||||
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
|
||||
echo "✅ VSCode extension built successfully"
|
||||
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
# Basic validation that the .vsix is a valid zip file
|
||||
echo "🔍 Validating .vsix structure..."
|
||||
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
echo "✅ VSCode extension validation passed"
|
||||
else
|
||||
echo "❌ VSCode extension build failed - .vsix not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload VSCode extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment on PR with artifact link
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Get file size for display
|
||||
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
|
||||
const stats = fs.statSync(vsixPath);
|
||||
const fileSizeKB = Math.round(stats.size / 1024);
|
||||
|
||||
const comment = `## 🔧 VSCode Extension Built Successfully!
|
||||
|
||||
The VSCode extension has been built and is ready for testing.
|
||||
|
||||
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
|
||||
|
||||
**🚀 To install**:
|
||||
1. Download the artifact from the workflow run above
|
||||
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
|
||||
3. Select the downloaded \`.vsix\` file
|
||||
|
||||
**✅ Tested with**: Node.js 22
|
||||
**🔍 Validation**: File structure and integrity verified
|
||||
|
||||
---
|
||||
*Built from commit ${{ github.sha }}*`;
|
||||
|
||||
// Check if we already commented on this PR and delete it
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('VSCode Extension Built Successfully')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: validate-vscode-extension
|
||||
if: startsWith(github.ref, 'refs/tags/ext-v')
|
||||
|
||||
steps:
|
||||
- name: Download .vsix artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: ./
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1.16.0
|
||||
with:
|
||||
artifacts: "*.vsix"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
@@ -63,7 +63,7 @@ Frontend:
|
||||
- We use TanStack Query (fka React Query) for data fetching and cache management
|
||||
- Data Access Layer: API client methods are located in `frontend/src/api` and should never be called directly from UI components - they must always be wrapped with TanStack Query
|
||||
- Custom hooks are located in `frontend/src/hooks/query/` and `frontend/src/hooks/mutation/`
|
||||
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationMicroagents`)
|
||||
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
|
||||
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
|
||||
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ STAGED_FILES=$(git diff --cached --name-only)
|
||||
# Check if any files match specific patterns
|
||||
has_frontend_changes=false
|
||||
has_backend_changes=false
|
||||
has_vscode_changes=false
|
||||
|
||||
# Check each file individually to avoid issues with grep
|
||||
for file in $STAGED_FILES; do
|
||||
@@ -21,17 +20,12 @@ for file in $STAGED_FILES; do
|
||||
has_frontend_changes=true
|
||||
elif [[ $file == openhands/* || $file == evaluation/* || $file == tests/* ]]; then
|
||||
has_backend_changes=true
|
||||
# Check for VSCode extension changes (subset of backend changes)
|
||||
if [[ $file == openhands/integrations/vscode/* ]]; then
|
||||
has_vscode_changes=true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Analyzing changes..."
|
||||
echo "- Frontend changes: $has_frontend_changes"
|
||||
echo "- Backend changes: $has_backend_changes"
|
||||
echo "- VSCode extension changes: $has_vscode_changes"
|
||||
|
||||
# Run frontend linting if needed
|
||||
if [ "$has_frontend_changes" = true ]; then
|
||||
@@ -92,51 +86,6 @@ else
|
||||
echo "Skipping backend checks (no backend changes detected)."
|
||||
fi
|
||||
|
||||
# Run VSCode extension checks if needed
|
||||
if [ "$has_vscode_changes" = true ]; then
|
||||
# Check if we're in a CI environment
|
||||
if [ -n "$CI" ]; then
|
||||
echo "Skipping VSCode extension checks (CI environment detected)."
|
||||
echo "WARNING: VSCode extension files have changed but checks are being skipped."
|
||||
echo "Please run VSCode extension checks manually before submitting your PR."
|
||||
else
|
||||
echo "Running VSCode extension checks..."
|
||||
if [ -d "openhands/integrations/vscode" ]; then
|
||||
cd openhands/integrations/vscode || exit 1
|
||||
|
||||
echo "Running npm lint:fix..."
|
||||
npm run lint:fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "VSCode extension linting failed. Please fix the issues before committing."
|
||||
EXIT_CODE=1
|
||||
else
|
||||
echo "VSCode extension linting passed!"
|
||||
fi
|
||||
|
||||
echo "Running npm typecheck..."
|
||||
npm run typecheck
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "VSCode extension type checking failed. Please fix the issues before committing."
|
||||
EXIT_CODE=1
|
||||
else
|
||||
echo "VSCode extension type checking passed!"
|
||||
fi
|
||||
|
||||
echo "Running npm compile..."
|
||||
npm run compile
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "VSCode extension compilation failed. Please fix the issues before committing."
|
||||
EXIT_CODE=1
|
||||
else
|
||||
echo "VSCode extension compilation passed!"
|
||||
fi
|
||||
|
||||
cd ../../..
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Skipping VSCode extension checks (no VSCode extension changes detected)."
|
||||
fi
|
||||
|
||||
# If no specific code changes detected, run basic checks
|
||||
if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; then
|
||||
|
||||
@@ -31,7 +31,7 @@ We're always looking to improve the look and feel of the application. If you've
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
|
||||
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
|
||||
to gather consensus from our design team first.
|
||||
|
||||
#### Improving the agent
|
||||
|
||||
@@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.1-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/OpenHands/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/badge/LICENSE-MIT-20B2AA?style=for-the-badge" alt="MIT License"></a>
|
||||
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=811504672#gid=811504672"><img src="https://img.shields.io/badge/SWEBench-72.8-00cc00?logoColor=FFE165&style=for-the-badge" alt="Benchmark Score"></a>
|
||||
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=811504672#gid=811504672"><img src="https://img.shields.io/badge/SWEBench-77.6-00cc00?logoColor=FFE165&style=for-the-badge" alt="Benchmark Score"></a>
|
||||
<br/>
|
||||
<a href="https://docs.openhands.dev/sdk"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Check out the documentation"></a>
|
||||
<a href="https://arxiv.org/abs/2511.03690"><img src="https://img.shields.io/badge/Paper-000?logoColor=FFE165&logo=arxiv&style=for-the-badge" alt="Tech Report"></a>
|
||||
|
||||
113
build_vscode.py
113
build_vscode.py
@@ -1,113 +0,0 @@
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
# This script is intended to be run by Poetry during the build process.
|
||||
|
||||
# Define the expected name of the .vsix file based on the extension's package.json
|
||||
# This should match the name and version in openhands-vscode/package.json
|
||||
EXTENSION_NAME = 'openhands-vscode'
|
||||
EXTENSION_VERSION = '0.0.1'
|
||||
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
|
||||
|
||||
# Paths
|
||||
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
|
||||
|
||||
|
||||
def check_node_version():
|
||||
"""Check if Node.js version is sufficient for building the extension."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['node', '--version'], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Extract major version number (e.g., "v12.22.9" -> 12)
|
||||
major_version = int(version_str.lstrip('v').split('.')[0])
|
||||
return major_version >= 18 # Align with frontend actual usage (18.20.1)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def build_vscode_extension():
|
||||
"""Builds the VS Code extension."""
|
||||
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
|
||||
|
||||
# Check if VSCode extension build is disabled via environment variable
|
||||
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
|
||||
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
|
||||
if vsix_path.exists():
|
||||
print(f'--- Using existing VS Code extension: {vsix_path} ---')
|
||||
else:
|
||||
print('--- No pre-built VS Code extension found ---')
|
||||
return
|
||||
|
||||
# Check Node.js version - if insufficient, use pre-built extension as fallback
|
||||
if not check_node_version():
|
||||
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
|
||||
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
|
||||
print('--- Using pre-built extension if available ---')
|
||||
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
else:
|
||||
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
|
||||
return
|
||||
|
||||
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
|
||||
|
||||
try:
|
||||
# Ensure npm dependencies are installed
|
||||
print('--- Running npm install for VS Code extension ---')
|
||||
subprocess.run(
|
||||
['npm', 'install'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Package the extension
|
||||
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
|
||||
subprocess.run(
|
||||
['npm', 'run', 'package-vsix'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Verify the generated .vsix file exists
|
||||
if not vsix_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f'VS Code extension package not found after build: {vsix_path}'
|
||||
)
|
||||
|
||||
print(f'--- VS Code extension built successfully: {vsix_path} ---')
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
|
||||
print('--- Continuing without building extension ---')
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
|
||||
|
||||
def build(setup_kwargs):
|
||||
"""This function is called by Poetry during the build process.
|
||||
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
|
||||
"""
|
||||
print('--- Running custom Poetry build script (build_vscode.py) ---')
|
||||
|
||||
# Build the VS Code extension and place the .vsix file
|
||||
build_vscode_extension()
|
||||
|
||||
# Poetry will handle including files based on pyproject.toml `include` patterns.
|
||||
# Ensure openhands/integrations/vscode/*.vsix is included there.
|
||||
|
||||
print('--- Custom Poetry build script (build_vscode.py) finished ---')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Running build_vscode.py directly for testing VS Code extension packaging...')
|
||||
build_vscode_extension()
|
||||
print('Direct execution of build_vscode.py finished.')
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG OPENHANDS_BUILD_VERSION=dev
|
||||
FROM node:24.8-trixie-slim AS frontend-builder
|
||||
FROM node:25.2-trixie-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.1-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.1-nikolaik}
|
||||
#- 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:
|
||||
|
||||
@@ -31,9 +31,8 @@ RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gsprea
|
||||
"pillow>=11.3.0"
|
||||
|
||||
WORKDIR /app
|
||||
COPY enterprise .
|
||||
COPY --chown=openhands:openhands --chmod=770 enterprise .
|
||||
|
||||
RUN chown -R openhands:openhands /app && chmod -R 770 /app
|
||||
USER openhands
|
||||
|
||||
# Command will be overridden by Kubernetes deployment template
|
||||
|
||||
@@ -721,6 +721,7 @@
|
||||
"https://$WEB_HOST/oauth/keycloak/callback",
|
||||
"https://$WEB_HOST/oauth/keycloak/offline/callback",
|
||||
"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/*"
|
||||
],
|
||||
|
||||
@@ -50,7 +50,7 @@ First run this to retrieve Github App secrets
|
||||
```
|
||||
gcloud auth application-default login
|
||||
gcloud config set project global-432717
|
||||
local/decrypt_env.sh
|
||||
enterprise_local/decrypt_env.sh /path/to/root/of/deploy/repo
|
||||
```
|
||||
|
||||
Now run this to generate a `.env` file, which will used to run SAAS locally
|
||||
|
||||
@@ -116,7 +116,7 @@ lines.append('POSTHOG_CLIENT_KEY=test')
|
||||
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
|
||||
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
|
||||
lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev')
|
||||
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514')
|
||||
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-opus-4-5-20251101')
|
||||
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
|
||||
lines.append('LOCAL_DEPLOYMENT=true')
|
||||
lines.append('DB_HOST=localhost')
|
||||
|
||||
4
enterprise/enterprise_local/decrypt_env.sh
Normal file → Executable file
4
enterprise/enterprise_local/decrypt_env.sh
Normal file → Executable file
@@ -4,12 +4,12 @@ set -euo pipefail
|
||||
# Check if DEPLOY_DIR argument was provided
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <DEPLOY_DIR>"
|
||||
echo "Example: $0 /path/to/deploy"
|
||||
echo "Example: $0 /path/to/root/of/deploy/repo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Normalize path (remove trailing slash)
|
||||
DEPLOY_DIR="${DEPLOY_DIR%/}"
|
||||
DEPLOY_DIR="${1%/}"
|
||||
|
||||
# Function to decrypt and rename
|
||||
decrypt_and_move() {
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from github import Github, GithubIntegration
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from integrations.github.github_view import (
|
||||
GithubIssue,
|
||||
)
|
||||
@@ -84,7 +84,7 @@ class GitHubDataCollector:
|
||||
# self.full_saved_pr_path = 'github_data/prs/{}-{}/data.json'
|
||||
self.full_saved_pr_path = 'prs/github/{}-{}/data.json'
|
||||
self.github_integration = GithubIntegration(
|
||||
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
|
||||
)
|
||||
self.conversation_id = None
|
||||
|
||||
@@ -143,7 +143,7 @@ class GitHubDataCollector:
|
||||
try:
|
||||
installation_token = self._get_installation_access_token(installation_id)
|
||||
|
||||
with Github(installation_token) as github_client:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(repo_name)
|
||||
issue = repo.get_issue(issue_number)
|
||||
comments = []
|
||||
@@ -237,7 +237,7 @@ class GitHubDataCollector:
|
||||
def _get_pr_commits(self, installation_id: str, repo_name: str, pr_number: int):
|
||||
commits = []
|
||||
installation_token = self._get_installation_access_token(installation_id)
|
||||
with Github(installation_token) as github_client:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(repo_name)
|
||||
pr = repo.get_pull(pr_number)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from types import MappingProxyType
|
||||
|
||||
from github import Github, GithubIntegration
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from integrations.github.data_collector import GitHubDataCollector
|
||||
from integrations.github.github_solvability import summarize_issue_solvability
|
||||
from integrations.github.github_view import (
|
||||
@@ -21,7 +21,9 @@ from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
@@ -30,7 +32,11 @@ from server.utils.conversation_callback_utils import register_callback_processor
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
@@ -42,7 +48,7 @@ class GithubManager(Manager):
|
||||
self.token_manager = token_manager
|
||||
self.data_collector = data_collector
|
||||
self.github_integration = GithubIntegration(
|
||||
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
|
||||
)
|
||||
|
||||
self.jinja_env = Environment(
|
||||
@@ -76,7 +82,7 @@ class GithubManager(Manager):
|
||||
reaction: The reaction to add (e.g. "eyes", "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket")
|
||||
installation_token: GitHub installation access token for API access
|
||||
"""
|
||||
with Github(installation_token) as github_client:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
# Add reaction based on view type
|
||||
if isinstance(github_view, GithubInlinePRComment):
|
||||
@@ -164,8 +170,13 @@ class GithubManager(Manager):
|
||||
)
|
||||
|
||||
if await self.is_job_requested(message):
|
||||
payload = message.message.get('payload', {})
|
||||
user_id = payload['sender']['id']
|
||||
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
|
||||
user_id, ProviderType.GITHUB
|
||||
)
|
||||
github_view = await GithubFactory.create_github_view_from_payload(
|
||||
message, self.token_manager
|
||||
message, keycloak_user_id
|
||||
)
|
||||
logger.info(
|
||||
f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}'
|
||||
@@ -193,7 +204,7 @@ class GithubManager(Manager):
|
||||
outgoing_message = message.message
|
||||
|
||||
if isinstance(github_view, GithubInlinePRComment):
|
||||
with Github(installation_token) as github_client:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
pr = repo.get_pull(github_view.issue_number)
|
||||
pr.create_review_comment_reply(
|
||||
@@ -205,7 +216,7 @@ class GithubManager(Manager):
|
||||
or isinstance(github_view, GithubIssueComment)
|
||||
or isinstance(github_view, GithubIssue)
|
||||
):
|
||||
with Github(installation_token) as github_client:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
issue = repo.get_issue(number=github_view.issue_number)
|
||||
issue.create_comment(outgoing_message)
|
||||
@@ -282,8 +293,15 @@ class GithubManager(Manager):
|
||||
f'[Github]: Error summarizing issue solvability: {str(e)}'
|
||||
)
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
github_view.user_info.keycloak_user_id, self.token_manager
|
||||
)
|
||||
|
||||
await github_view.create_new_conversation(
|
||||
self.jinja_env, secret_store.provider_tokens, convo_metadata
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
convo_metadata,
|
||||
saas_user_auth,
|
||||
)
|
||||
|
||||
conversation_id = github_view.conversation_id
|
||||
@@ -292,14 +310,7 @@ class GithubManager(Manager):
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, github_view.user_info.keycloak_user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
if metadata.conversation_version != 'v1':
|
||||
if not github_view.v1:
|
||||
# Create a GithubCallbackProcessor
|
||||
processor = GithubCallbackProcessor(
|
||||
github_view=github_view,
|
||||
@@ -336,6 +347,13 @@ class GithubManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[GitHub] Session expired for user {user_info.username}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.username)
|
||||
|
||||
msg = self.create_outgoing_message(msg_info)
|
||||
await self.send_message(msg, github_view)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from github import Github
|
||||
from github import Auth, Github
|
||||
from integrations.github.github_view import (
|
||||
GithubInlinePRComment,
|
||||
GithubIssueComment,
|
||||
@@ -47,7 +47,7 @@ def fetch_github_issue_context(
|
||||
context_parts.append(f'Title: {github_view.title}')
|
||||
context_parts.append(f'Description:\n{github_view.description}')
|
||||
|
||||
with Github(user_token) as github_client:
|
||||
with Github(auth=Auth.Token(user_token)) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
issue = repo.get_issue(github_view.issue_number)
|
||||
if issue.labels:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from github import Github, GithubIntegration
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from github.Issue import Issue
|
||||
from integrations.github.github_types import (
|
||||
WorkflowRun,
|
||||
@@ -8,16 +9,17 @@ from integrations.github.github_types import (
|
||||
WorkflowRunStatus,
|
||||
)
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
ENABLE_V1_GITHUB_RESOLVER,
|
||||
HOST,
|
||||
HOST_URL,
|
||||
get_oh_labels,
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from pydantic.dataclasses import dataclass
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
@@ -34,18 +36,16 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
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.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
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.sdk.conversation.secret_source import SecretSource
|
||||
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,
|
||||
@@ -55,52 +55,6 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
class GithubUserContext(UserContext):
|
||||
"""User context for GitHub integration that provides user info without web request."""
|
||||
|
||||
def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE):
|
||||
self.keycloak_user_id = keycloak_user_id
|
||||
self.git_provider_tokens = git_provider_tokens
|
||||
self.settings_store = SaasSettingsStore(
|
||||
user_id=self.keycloak_user_id,
|
||||
session_maker=session_maker,
|
||||
config=get_config(),
|
||||
)
|
||||
|
||||
self.secrets_store = SaasSecretsStore(
|
||||
self.keycloak_user_id, session_maker, get_config()
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.keycloak_user_id
|
||||
|
||||
async def get_user_info(self) -> UserInfo:
|
||||
user_settings = await self.settings_store.load()
|
||||
return UserInfo(
|
||||
id=self.keycloak_user_id,
|
||||
**user_settings.model_dump(context={'expose_secrets': True}),
|
||||
)
|
||||
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
# This would need to be implemented based on the git provider tokens
|
||||
# For now, return a basic HTTPS URL
|
||||
return f'https://github.com/{repository}.git'
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
# Return the appropriate token from git_provider_tokens
|
||||
if provider_type == ProviderType.GITHUB and self.git_provider_tokens:
|
||||
return self.git_provider_tokens.get(ProviderType.GITHUB)
|
||||
return None
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
# Return empty dict for now - GitHub integration handles secrets separately
|
||||
user_secrets = await self.secrets_store.load()
|
||||
return dict(user_secrets.custom_secrets) if user_secrets else {}
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's proactive conversation setting.
|
||||
|
||||
@@ -134,7 +88,7 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
return settings.enable_proactive_conversation_starters
|
||||
|
||||
|
||||
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
async def get_user_v1_enabled_setting(user_id: str) -> bool:
|
||||
"""Get the user's V1 conversation API setting.
|
||||
|
||||
Args:
|
||||
@@ -142,10 +96,13 @@ async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
|
||||
Returns:
|
||||
True if V1 conversations are enabled for this user, False otherwise
|
||||
"""
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
Note:
|
||||
This function checks both the global environment variable kill switch AND
|
||||
the user's individual setting. Both must be true for the function to return true.
|
||||
"""
|
||||
# Check the global environment variable first
|
||||
if not ENABLE_V1_GITHUB_RESOLVER:
|
||||
return False
|
||||
|
||||
config = get_config()
|
||||
@@ -183,6 +140,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
title: str
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
v1: bool
|
||||
|
||||
async def _load_resolver_context(self):
|
||||
github_service = GithubServiceImpl(
|
||||
@@ -229,6 +187,19 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
return ConversationMetadata(
|
||||
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
@@ -245,14 +216,17 @@ class GithubIssue(ResolverViewInterface):
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
try:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
return
|
||||
|
||||
@@ -271,6 +245,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the legacy V0 system."""
|
||||
logger.info('[GitHub]: Creating V0 conversation')
|
||||
custom_secrets = await self._get_user_secrets()
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
@@ -292,10 +267,12 @@ class GithubIssue(ResolverViewInterface):
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
saas_user_auth: UserAuth,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitHub V1]: Creating V1 conversation')
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
@@ -326,10 +303,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitHub user context for the V1 system
|
||||
github_user_context = GithubUserContext(
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
)
|
||||
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(
|
||||
@@ -344,6 +318,8 @@ class GithubIssue(ResolverViewInterface):
|
||||
f'Failed to start V1 conversation: {task.detail}'
|
||||
)
|
||||
|
||||
self.v1 = True
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from openhands.app_server.event_callback.github_v1_callback_processor import (
|
||||
@@ -415,7 +391,18 @@ class GithubPRComment(GithubIssueComment):
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
return ConversationMetadata(
|
||||
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
@@ -742,13 +729,13 @@ class GithubFactory:
|
||||
|
||||
def _interact_with_github() -> Issue | None:
|
||||
with GithubIntegration(
|
||||
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
|
||||
) as integration:
|
||||
access_token = integration.get_access_token(
|
||||
payload['installation']['id']
|
||||
).token
|
||||
|
||||
with Github(access_token) as gh:
|
||||
with Github(auth=Auth.Token(access_token)) as gh:
|
||||
repo = gh.get_repo(selected_repo)
|
||||
login = (
|
||||
payload['organization']['login']
|
||||
@@ -806,7 +793,7 @@ class GithubFactory:
|
||||
|
||||
@staticmethod
|
||||
async def create_github_view_from_payload(
|
||||
message: Message, token_manager: TokenManager
|
||||
message: Message, keycloak_user_id: str
|
||||
) -> ResolverViewInterface:
|
||||
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
|
||||
Also return metadata about the event (e.g., action type).
|
||||
@@ -816,17 +803,10 @@ class GithubFactory:
|
||||
user_id = payload['sender']['id']
|
||||
username = payload['sender']['login']
|
||||
|
||||
keyloak_user_id = await token_manager.get_user_id_from_idp_user_id(
|
||||
user_id, ProviderType.GITHUB
|
||||
)
|
||||
|
||||
if keyloak_user_id is None:
|
||||
logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ')
|
||||
|
||||
selected_repo = GithubFactory.get_full_repo_name(repo_obj)
|
||||
is_public_repo = not repo_obj.get('private', True)
|
||||
user_info = UserData(
|
||||
user_id=user_id, username=username, keycloak_user_id=keyloak_user_id
|
||||
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
|
||||
)
|
||||
|
||||
installation_id = message.message['installation']
|
||||
@@ -850,6 +830,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_issue_comment(message):
|
||||
@@ -875,6 +856,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_pr_comment(message):
|
||||
@@ -885,12 +867,12 @@ class GithubFactory:
|
||||
|
||||
access_token = ''
|
||||
with GithubIntegration(
|
||||
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
|
||||
) as integration:
|
||||
access_token = integration.get_access_token(installation_id).token
|
||||
|
||||
head_ref = None
|
||||
with Github(access_token) as gh:
|
||||
with Github(auth=Auth.Token(access_token)) as gh:
|
||||
repo = gh.get_repo(selected_repo)
|
||||
pull_request = repo.get_pull(issue_number)
|
||||
head_ref = pull_request.head.ref
|
||||
@@ -916,6 +898,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_inline_pr_comment(message):
|
||||
@@ -949,6 +932,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -15,6 +15,7 @@ from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
@@ -24,7 +25,11 @@ from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
@@ -249,6 +254,13 @@ class GitlabManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[GitLab] Session expired for user {user_info.username}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.username)
|
||||
|
||||
# Send the acknowledgment message
|
||||
msg = self.create_outgoing_message(msg_info)
|
||||
await self.send_message(msg, gitlab_view)
|
||||
|
||||
@@ -80,22 +80,52 @@ class SaaSGitLabService(GitLabService):
|
||||
logger.warning('external_auth_token and user_id not set!')
|
||||
return gitlab_token
|
||||
|
||||
async def get_owned_groups(self) -> list[dict]:
|
||||
async def get_owned_groups(self, min_access_level: int = 40) -> list[dict]:
|
||||
"""
|
||||
Get all groups for which the current user is the owner.
|
||||
Get all top-level groups where the current user has admin access.
|
||||
|
||||
This method supports pagination and fetches all groups where the user has
|
||||
at least the specified access level.
|
||||
|
||||
Args:
|
||||
min_access_level: Minimum access level required (default: 40 for Maintainer or Owner)
|
||||
- 40: Maintainer or Owner
|
||||
- 50: Owner only
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of groups owned by the current user.
|
||||
list[dict]: A list of groups where user has the specified access level or higher.
|
||||
"""
|
||||
url = f'{self.BASE_URL}/groups'
|
||||
params = {'owned': 'true', 'per_page': 100, 'top_level_only': 'true'}
|
||||
groups_with_admin_access = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
try:
|
||||
response, headers = await self._make_request(url, params)
|
||||
return response
|
||||
except Exception:
|
||||
logger.warning('Error fetching owned groups', exc_info=True)
|
||||
return []
|
||||
while True:
|
||||
try:
|
||||
url = f'{self.BASE_URL}/groups'
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'min_access_level': min_access_level,
|
||||
'top_level_only': 'true',
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response:
|
||||
break
|
||||
|
||||
groups_with_admin_access.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
logger.warning(f'Error fetching groups on page {page}', exc_info=True)
|
||||
break
|
||||
|
||||
return groups_with_admin_access
|
||||
|
||||
async def add_owned_projects_and_groups_to_db(self, owned_personal_projects):
|
||||
"""
|
||||
@@ -527,3 +557,55 @@ class SaaSGitLabService(GitLabService):
|
||||
await self._make_request(url=url, params=params, method=RequestMethod.POST)
|
||||
except Exception as e:
|
||||
logger.exception(f'[GitLab]: Reply to MR failed {e}')
|
||||
|
||||
async def get_user_resources_with_admin_access(
|
||||
self,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Get all projects and groups where the current user has admin access (maintainer or owner).
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], list[dict]]: A tuple containing:
|
||||
- list of projects where user has admin access
|
||||
- list of groups where user has admin access
|
||||
"""
|
||||
projects_with_admin_access = []
|
||||
groups_with_admin_access = []
|
||||
|
||||
# Fetch all projects the user is a member of
|
||||
page = 1
|
||||
per_page = 100
|
||||
while True:
|
||||
try:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'membership': 1,
|
||||
'min_access_level': 40, # Maintainer or Owner
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response:
|
||||
break
|
||||
|
||||
projects_with_admin_access.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
logger.warning(f'Error fetching projects on page {page}', exc_info=True)
|
||||
break
|
||||
|
||||
# Fetch all groups where user is owner or maintainer
|
||||
groups_with_admin_access = await self.get_owned_groups(min_access_level=40)
|
||||
|
||||
logger.info(
|
||||
f'Found {len(projects_with_admin_access)} projects and {len(groups_with_admin_access)} groups with admin access'
|
||||
)
|
||||
|
||||
return projects_with_admin_access, groups_with_admin_access
|
||||
|
||||
199
enterprise/integrations/gitlab/webhook_installation.py
Normal file
199
enterprise/integrations/gitlab/webhook_installation.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Shared utilities for GitLab webhook installation.
|
||||
|
||||
This module contains reusable functions and classes for installing GitLab webhooks
|
||||
that can be used by both the cron job and API routes.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import GitService
|
||||
|
||||
# Webhook configuration constants
|
||||
WEBHOOK_NAME = 'OpenHands Resolver'
|
||||
SCOPES: list[str] = [
|
||||
'note_events',
|
||||
'merge_requests_events',
|
||||
'confidential_issues_events',
|
||||
'issues_events',
|
||||
'confidential_note_events',
|
||||
'job_events',
|
||||
'pipeline_events',
|
||||
]
|
||||
|
||||
|
||||
class BreakLoopException(Exception):
|
||||
"""Exception raised when webhook installation conditions are not met or rate limited."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def verify_webhook_conditions(
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
) -> None:
|
||||
"""
|
||||
Verify all conditions are met for webhook installation.
|
||||
Raises BreakLoopException if any condition fails or rate limited.
|
||||
|
||||
Args:
|
||||
gitlab_service: GitLab service instance
|
||||
resource_type: Type of resource (PROJECT or GROUP)
|
||||
resource_id: ID of the resource
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to verify
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
# Check if resource exists
|
||||
does_resource_exist, status = await gitlab_service.check_resource_exists(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does resource exists',
|
||||
extra={
|
||||
'does_resource_exist': does_resource_exist,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
# Check if user has admin access
|
||||
(
|
||||
is_user_admin_of_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Is user admin',
|
||||
extra={
|
||||
'is_user_admin': is_user_admin_of_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if not is_user_admin_of_resource:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
# Check if webhook already exists
|
||||
(
|
||||
does_webhook_exist_on_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_webhook_exists_on_resource(
|
||||
resource_type, resource_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does webhook already exist',
|
||||
extra={
|
||||
'does_webhook_exist_on_resource': does_webhook_exist_on_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if does_webhook_exist_on_resource != webhook.webhook_exists:
|
||||
await webhook_store.update_webhook(
|
||||
webhook, {'webhook_exists': does_webhook_exist_on_resource}
|
||||
)
|
||||
|
||||
if does_webhook_exist_on_resource:
|
||||
raise BreakLoopException()
|
||||
|
||||
|
||||
async def install_webhook_on_resource(
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
) -> tuple[str | None, WebhookStatus | None]:
|
||||
"""
|
||||
Install webhook on a GitLab resource.
|
||||
|
||||
Args:
|
||||
gitlab_service: GitLab service instance
|
||||
resource_type: Type of resource (PROJECT or GROUP)
|
||||
resource_id: ID of the resource
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to install
|
||||
|
||||
Returns:
|
||||
Tuple of (webhook_id, status)
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
|
||||
webhook_uuid = f'{str(uuid4())}'
|
||||
|
||||
webhook_id, status = await gitlab_service.install_webhook(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_name=WEBHOOK_NAME,
|
||||
webhook_url=GITLAB_WEBHOOK_URL,
|
||||
webhook_secret=webhook_secret,
|
||||
webhook_uuid=webhook_uuid,
|
||||
scopes=SCOPES,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Creating new webhook',
|
||||
extra={
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
|
||||
if webhook_id:
|
||||
await webhook_store.update_webhook(
|
||||
webhook=webhook,
|
||||
update_fields={
|
||||
'webhook_secret': webhook_secret,
|
||||
'webhook_exists': True, # webhook was created
|
||||
'webhook_url': GITLAB_WEBHOOK_URL,
|
||||
'scopes': SCOPES,
|
||||
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
|
||||
)
|
||||
|
||||
return webhook_id, status
|
||||
@@ -17,6 +17,7 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -30,7 +31,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -380,6 +385,10 @@ class JiraManager(Manager):
|
||||
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Jira] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
@@ -19,6 +19,7 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -32,7 +33,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -397,6 +402,10 @@ class JiraDcManager(Manager):
|
||||
logger.warning(f'[Jira DC] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Jira DC] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira DC] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
@@ -16,6 +16,7 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -29,7 +30,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -387,6 +392,10 @@ class LinearManager(Manager):
|
||||
logger.warning(f'[Linear] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Linear] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Linear] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
63
enterprise/integrations/resolver_context.py
Normal file
63
enterprise/integrations/resolver_context.py
Normal file
@@ -0,0 +1,63 @@
|
||||
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
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class ResolverUserContext(UserContext):
|
||||
"""User context for resolver operations that inherits from UserContext."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
self.saas_user_auth = saas_user_auth
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return await self.saas_user_auth.get_user_id()
|
||||
|
||||
async def get_user_info(self) -> UserInfo:
|
||||
user_settings = await self.saas_user_auth.get_user_settings()
|
||||
user_id = await self.saas_user_auth.get_user_id()
|
||||
if user_settings:
|
||||
return UserInfo(
|
||||
id=user_id,
|
||||
**user_settings.model_dump(context={'expose_secrets': True}),
|
||||
)
|
||||
|
||||
return UserInfo(id=user_id)
|
||||
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
# This would need to be implemented based on the git provider tokens
|
||||
# For now, return a basic HTTPS URL
|
||||
return f'https://github.com/{repository}.git'
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
# Return the appropriate token from git_provider_tokens
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if provider_tokens:
|
||||
return provider_tokens.get(provider_type)
|
||||
return None
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
return await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
"""Get secrets for the user, including custom secrets."""
|
||||
secrets = await self.saas_user_auth.get_secrets()
|
||||
if secrets:
|
||||
# Convert custom secrets to StaticSecret objects for SDK compatibility
|
||||
# secrets.custom_secrets is of type Mapping[str, CustomSecret]
|
||||
converted_secrets = {}
|
||||
for key, custom_secret in secrets.custom_secrets.items():
|
||||
# Extract the secret value from CustomSecret and convert to StaticSecret
|
||||
secret_value = custom_secret.secret.get_secret_value()
|
||||
converted_secrets[key] = StaticSecret(value=secret_value)
|
||||
return converted_secrets
|
||||
return {}
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
return await self.saas_user_auth.get_mcp_api_key()
|
||||
@@ -14,6 +14,7 @@ from integrations.slack.slack_view import (
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
@@ -29,7 +30,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import config, server_config
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
authorize_url_generator = AuthorizeUrlGenerator(
|
||||
@@ -352,6 +357,13 @@ class SlackManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.slack_display_name} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[Slack] Session expired for user {user_info.slack_display_name}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.slack_display_name)
|
||||
|
||||
except StartingConvoException as e:
|
||||
msg_info = str(e)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class PRStatus(Enum):
|
||||
class UserData(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
keycloak_user_id: str | None
|
||||
keycloak_user_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -20,6 +20,7 @@ from openhands.events.action import (
|
||||
AgentFinishAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.integrations.service_types import Repository
|
||||
@@ -46,11 +47,37 @@ ENABLE_PROACTIVE_CONVERSATION_STARTERS = (
|
||||
os.getenv('ENABLE_PROACTIVE_CONVERSATION_STARTERS', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
def get_session_expired_message(username: str | None = None) -> str:
|
||||
"""Get a user-friendly session expired message.
|
||||
|
||||
Used by integrations to notify users when their Keycloak offline session
|
||||
has expired.
|
||||
|
||||
Args:
|
||||
username: Optional username to mention in the message. If provided,
|
||||
the message will include @username prefix (used by Git providers
|
||||
like GitHub, GitLab, Slack). If None, returns a generic message
|
||||
(used by Jira, Jira DC, Linear).
|
||||
|
||||
Returns:
|
||||
A formatted session expired message
|
||||
"""
|
||||
if username:
|
||||
return f'@{username} your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
return f'Your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
|
||||
|
||||
# Toggle for solvability report feature
|
||||
ENABLE_SOLVABILITY_ANALYSIS = (
|
||||
os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
# Toggle for V1 GitHub resolver feature
|
||||
ENABLE_V1_GITHUB_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/'
|
||||
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
|
||||
@@ -198,18 +225,35 @@ def get_summary_for_agent_state(
|
||||
def get_final_agent_observation(
|
||||
event_store: EventStoreABC,
|
||||
) -> list[AgentStateChangedObservation]:
|
||||
return event_store.get_matching_events(
|
||||
source=EventSource.ENVIRONMENT,
|
||||
event_types=(AgentStateChangedObservation,),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.ENVIRONMENT,
|
||||
include_types=(AgentStateChangedObservation,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
result = [e for e in events if isinstance(e, AgentStateChangedObservation)]
|
||||
assert len(result) == len(events)
|
||||
return result
|
||||
|
||||
|
||||
def get_last_user_msg(event_store: EventStoreABC) -> list[MessageAction]:
|
||||
return event_store.get_matching_events(
|
||||
source=EventSource.USER, event_types=(MessageAction,), limit=1, reverse='true'
|
||||
events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.USER,
|
||||
include_types=(MessageAction,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
result = [e for e in events if isinstance(e, MessageAction)]
|
||||
assert len(result) == len(events)
|
||||
return result
|
||||
|
||||
|
||||
def extract_summary_from_event_store(
|
||||
@@ -221,18 +265,22 @@ def extract_summary_from_event_store(
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
summary_instruction = get_summary_instruction()
|
||||
|
||||
instruction_event: list[MessageAction] = event_store.get_matching_events(
|
||||
query=json.dumps(summary_instruction),
|
||||
source=EventSource.USER,
|
||||
event_types=(MessageAction,),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
instruction_events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
query=json.dumps(summary_instruction),
|
||||
source=EventSource.USER,
|
||||
include_types=(MessageAction,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
|
||||
final_agent_observation = get_final_agent_observation(event_store)
|
||||
|
||||
# Find summary instruction event ID
|
||||
if len(instruction_event) == 0:
|
||||
if not instruction_events:
|
||||
logger.warning(
|
||||
'no_instruction_event_found', extra={'conversation_id': conversation_id}
|
||||
)
|
||||
@@ -240,19 +288,19 @@ def extract_summary_from_event_store(
|
||||
final_agent_observation, conversation_link
|
||||
) # Agent did not receive summary instruction
|
||||
|
||||
event_id: int = instruction_event[0].id
|
||||
|
||||
agent_messages: list[MessageAction | AgentFinishAction] = (
|
||||
event_store.get_matching_events(
|
||||
start_id=event_id,
|
||||
source=EventSource.AGENT,
|
||||
event_types=(MessageAction, AgentFinishAction),
|
||||
reverse=True,
|
||||
summary_events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.AGENT,
|
||||
include_types=(MessageAction, AgentFinishAction),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
start_id=instruction_events[0].id,
|
||||
)
|
||||
)
|
||||
|
||||
if len(agent_messages) == 0:
|
||||
if not summary_events:
|
||||
logger.warning(
|
||||
'no_agent_messages_found', extra={'conversation_id': conversation_id}
|
||||
)
|
||||
@@ -260,10 +308,11 @@ def extract_summary_from_event_store(
|
||||
final_agent_observation, conversation_link
|
||||
) # Agent failed to generate summary
|
||||
|
||||
summary_event: MessageAction | AgentFinishAction = agent_messages[0]
|
||||
summary_event = summary_events[0]
|
||||
if isinstance(summary_event, MessageAction):
|
||||
return summary_event.content
|
||||
|
||||
assert isinstance(summary_event, AgentFinishAction)
|
||||
return summary_event.final_thought
|
||||
|
||||
|
||||
@@ -316,7 +365,7 @@ def append_conversation_footer(message: str, conversation_id: str) -> str:
|
||||
The message with the conversation footer appended
|
||||
"""
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
footer = f'\n\n<sub>[View full conversation]({conversation_link})</sub>'
|
||||
footer = f'\n\n[View full conversation]({conversation_link})'
|
||||
return message + footer
|
||||
|
||||
|
||||
|
||||
20
enterprise/integrations/v1_utils.py
Normal file
20
enterprise/integrations/v1_utils.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
async def get_saas_user_auth(
|
||||
keycloak_user_id: str, token_manager: TokenManager
|
||||
) -> UserAuth:
|
||||
offline_token = await token_manager.load_offline_token(keycloak_user_id)
|
||||
if offline_token is None:
|
||||
logger.info('no_offline_token_found')
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
user_id=keycloak_user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
)
|
||||
return user_auth
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Create device_codes table for OAuth 2.0 Device Flow
|
||||
|
||||
Revision ID: 084
|
||||
Revises: 083
|
||||
Create Date: 2024-12-10 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '084'
|
||||
down_revision = '083'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create device_codes table for OAuth 2.0 Device Flow."""
|
||||
op.create_table(
|
||||
'device_codes',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('device_code', sa.String(length=128), nullable=False),
|
||||
sa.Column('user_code', sa.String(length=16), nullable=False),
|
||||
sa.Column('status', sa.String(length=32), nullable=False),
|
||||
sa.Column('keycloak_user_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('authorized_at', sa.DateTime(timezone=True), nullable=True),
|
||||
# Rate limiting fields for RFC 8628 section 3.5 compliance
|
||||
sa.Column('last_poll_time', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('current_interval', sa.Integer(), nullable=False, default=5),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# Create indexes for efficient lookups
|
||||
op.create_index(
|
||||
'ix_device_codes_device_code', 'device_codes', ['device_code'], unique=True
|
||||
)
|
||||
op.create_index(
|
||||
'ix_device_codes_user_code', 'device_codes', ['user_code'], unique=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Drop device_codes table."""
|
||||
op.drop_index('ix_device_codes_user_code', table_name='device_codes')
|
||||
op.drop_index('ix_device_codes_device_code', table_name='device_codes')
|
||||
op.drop_table('device_codes')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add public column to conversation_metadata
|
||||
|
||||
Revision ID: 085
|
||||
Revises: 084
|
||||
Create Date: 2025-01-27 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '085'
|
||||
down_revision: Union[str, None] = '084'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('public', sa.Boolean(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_conversation_metadata_public'),
|
||||
'conversation_metadata',
|
||||
['public'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(
|
||||
op.f('ix_conversation_metadata_public'),
|
||||
table_name='conversation_metadata',
|
||||
)
|
||||
op.drop_column('conversation_metadata', 'public')
|
||||
548
enterprise/poetry.lock
generated
548
enterprise/poetry.lock
generated
@@ -201,14 +201,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.72.0"
|
||||
version = "0.75.0"
|
||||
description = "The official Python library for the anthropic API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
|
||||
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
|
||||
{file = "anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b"},
|
||||
{file = "anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -682,37 +682,37 @@ crt = ["awscrt (==0.27.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "browser-use"
|
||||
version = "0.9.5"
|
||||
version = "0.10.1"
|
||||
description = "Make websites accessible for AI agents"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "browser_use-0.9.5-py3-none-any.whl", hash = "sha256:4a2e92847204d1ded269026a99cb0cc0e60e38bd2751fa3f58aedd78f00b4e67"},
|
||||
{file = "browser_use-0.9.5.tar.gz", hash = "sha256:f8285fe253b149d01769a7084883b4cf4db351e2f38e26302c157bcbf14a703f"},
|
||||
{file = "browser_use-0.10.1-py3-none-any.whl", hash = "sha256:96e603bfc71098175342cdcb0592519e6f244412e740f0254e4389fdd82a977f"},
|
||||
{file = "browser_use-0.10.1.tar.gz", hash = "sha256:5f211ecfdf1f9fd186160f10df70dedd661821231e30f1bce40939787abab223"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = "3.12.15"
|
||||
anthropic = ">=0.68.1,<1.0.0"
|
||||
anthropic = ">=0.72.1,<1.0.0"
|
||||
anyio = ">=4.9.0"
|
||||
authlib = ">=1.6.0"
|
||||
bubus = ">=1.5.6"
|
||||
cdp-use = ">=1.4.0"
|
||||
cdp-use = ">=1.4.4"
|
||||
click = ">=8.1.8"
|
||||
cloudpickle = ">=3.1.1"
|
||||
google-api-core = ">=2.25.0"
|
||||
google-api-python-client = ">=2.174.0"
|
||||
google-auth = ">=2.40.3"
|
||||
google-auth-oauthlib = ">=1.2.2"
|
||||
google-genai = ">=1.29.0,<2.0.0"
|
||||
google-genai = ">=1.50.0,<2.0.0"
|
||||
groq = ">=0.30.0"
|
||||
httpx = ">=0.28.1"
|
||||
inquirerpy = ">=0.3.4"
|
||||
markdownify = ">=1.2.0"
|
||||
mcp = ">=1.10.1"
|
||||
ollama = ">=0.5.1"
|
||||
openai = ">=1.99.2,<2.0.0"
|
||||
openai = ">=2.7.2,<3.0.0"
|
||||
pillow = ">=11.2.1"
|
||||
portalocker = ">=2.7.0,<3.0.0"
|
||||
posthog = ">=3.7.0"
|
||||
@@ -721,6 +721,7 @@ pydantic = ">=2.11.5"
|
||||
pyobjc = {version = ">=11.0", markers = "platform_system == \"darwin\""}
|
||||
pyotp = ">=2.9.0"
|
||||
pypdf = ">=5.7.0"
|
||||
python-docx = ">=1.2.0"
|
||||
python-dotenv = ">=1.0.1"
|
||||
reportlab = ">=4.0.0"
|
||||
requests = ">=2.32.3"
|
||||
@@ -850,14 +851,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "cdp-use"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
description = "Type safe generator/client library for CDP"
|
||||
optional = false
|
||||
python-versions = ">=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cdp_use-1.4.3-py3-none-any.whl", hash = "sha256:c48664604470c2579aa1e677c3e3e7e24c4f300c54804c093d935abb50479ecd"},
|
||||
{file = "cdp_use-1.4.3.tar.gz", hash = "sha256:9029c04bdc49fbd3939d2bf1988ad8d88e260729c7d5e35c2f6c87591f5a10e9"},
|
||||
{file = "cdp_use-1.4.4-py3-none-any.whl", hash = "sha256:e37e80e067db2653d6fdf953d4ff9e5d80d75daa27b7c6d48c0261cccbef73e1"},
|
||||
{file = "cdp_use-1.4.4.tar.gz", hash = "sha256:330a848b517006eb9ad1dc468aa6434d913cf0c6918610760c36c3fdfdba0fab"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2978,28 +2979,29 @@ testing = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "google-genai"
|
||||
version = "1.32.0"
|
||||
version = "1.53.0"
|
||||
description = "GenAI Python SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_genai-1.32.0-py3-none-any.whl", hash = "sha256:c0c4b1d45adf3aa99501050dd73da2f0dea09374002231052d81a6765d15e7f6"},
|
||||
{file = "google_genai-1.32.0.tar.gz", hash = "sha256:349da3f5ff0e981066bd508585fcdd308d28fc4646f318c8f6d1aa6041f4c7e3"},
|
||||
{file = "google_genai-1.53.0-py3-none-any.whl", hash = "sha256:65a3f99e5c03c372d872cda7419f5940e723374bb12a2f3ffd5e3e56e8eb2094"},
|
||||
{file = "google_genai-1.53.0.tar.gz", hash = "sha256:938a26d22f3fd32c6eeeb4276ef204ef82884e63af9842ce3eac05ceb39cbd8d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=4.8.0,<5.0.0"
|
||||
google-auth = ">=2.14.1,<3.0.0"
|
||||
google-auth = {version = ">=2.14.1,<3.0.0", extras = ["requests"]}
|
||||
httpx = ">=0.28.1,<1.0.0"
|
||||
pydantic = ">=2.0.0,<3.0.0"
|
||||
pydantic = ">=2.9.0,<3.0.0"
|
||||
requests = ">=2.28.1,<3.0.0"
|
||||
tenacity = ">=8.2.3,<9.2.0"
|
||||
typing-extensions = ">=4.11.0,<5.0.0"
|
||||
websockets = ">=13.0.0,<15.1.0"
|
||||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp (<4.0.0)"]
|
||||
aiohttp = ["aiohttp (<3.13.3)"]
|
||||
local-tokenizer = ["protobuf", "sentencepiece (>=0.2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "google-resumable-media"
|
||||
@@ -3055,6 +3057,8 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
|
||||
@@ -3064,6 +3068,8 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
|
||||
@@ -3073,6 +3079,8 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
|
||||
@@ -3082,6 +3090,8 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
|
||||
@@ -3089,6 +3099,8 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
|
||||
@@ -3098,6 +3110,8 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
|
||||
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
|
||||
@@ -3166,83 +3180,87 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.74.0"
|
||||
version = "1.67.1"
|
||||
description = "HTTP/2-based RPC framework"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "grpcio-1.74.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:85bd5cdf4ed7b2d6438871adf6afff9af7096486fcf51818a81b77ef4dd30907"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:68c8ebcca945efff9d86d8d6d7bfb0841cf0071024417e2d7f45c5e46b5b08eb"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:e154d230dc1bbbd78ad2fdc3039fa50ad7ffcf438e4eb2fa30bce223a70c7486"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8978003816c7b9eabe217f88c78bc26adc8f9304bf6a594b02e5a49b2ef9c11"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3d7bd6e3929fd2ea7fbc3f562e4987229ead70c9ae5f01501a46701e08f1ad9"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:136b53c91ac1d02c8c24201bfdeb56f8b3ac3278668cbb8e0ba49c88069e1bdc"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fe0f540750a13fd8e5da4b3eaba91a785eea8dca5ccd2bc2ffe978caa403090e"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4e4181bfc24413d1e3a37a0b7889bea68d973d4b45dd2bc68bb766c140718f82"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-win32.whl", hash = "sha256:1733969040989f7acc3d94c22f55b4a9501a30f6aaacdbccfaba0a3ffb255ab7"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e912d3c993a29df6c627459af58975b2e5c897d93287939b9d5065f000249b5"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:4bc5fca10aaf74779081e16c2bcc3d5ec643ffd528d9e7b1c9039000ead73bae"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:6bab67d15ad617aff094c382c882e0177637da73cbc5532d52c07b4ee887a87b"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:655726919b75ab3c34cdad39da5c530ac6fa32696fb23119e36b64adcfca174a"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a2b06afe2e50ebfd46247ac3ba60cac523f54ec7792ae9ba6073c12daf26f0a"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f251c355167b2360537cf17bea2cf0197995e551ab9da6a0a59b3da5e8704f9"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8f7b5882fb50632ab1e48cb3122d6df55b9afabc265582808036b6e51b9fd6b7"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:834988b6c34515545b3edd13e902c1acdd9f2465d386ea5143fb558f153a7176"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22b834cef33429ca6cc28303c9c327ba9a3fafecbf62fae17e9a7b7163cc43ac"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-win32.whl", hash = "sha256:7d95d71ff35291bab3f1c52f52f474c632db26ea12700c2ff0ea0532cb0b5854"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-win_amd64.whl", hash = "sha256:ecde9ab49f58433abe02f9ed076c7b5be839cf0153883a6d23995937a82392fa"},
|
||||
{file = "grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
|
||||
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
protobuf = ["grpcio-tools (>=1.74.0)"]
|
||||
protobuf = ["grpcio-tools (>=1.67.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio-status"
|
||||
version = "1.71.2"
|
||||
version = "1.67.1"
|
||||
description = "Status proto mapping for gRPC"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3"},
|
||||
{file = "grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50"},
|
||||
{file = "grpcio_status-1.67.1-py3-none-any.whl", hash = "sha256:16e6c085950bdacac97c779e6a502ea671232385e6e37f258884d6883392c2bd"},
|
||||
{file = "grpcio_status-1.67.1.tar.gz", hash = "sha256:2bf38395e028ceeecfd8866b081f61628114b384da7d51ae064ddc8d766a5d11"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.5.5"
|
||||
grpcio = ">=1.71.2"
|
||||
grpcio = ">=1.67.1"
|
||||
protobuf = ">=5.26.1,<6.0dev"
|
||||
|
||||
[[package]]
|
||||
@@ -4499,14 +4517,14 @@ dev = ["Sphinx (>=5.1.1)", "black (==24.8.0)", "build (>=0.10.0)", "coverage[tom
|
||||
|
||||
[[package]]
|
||||
name = "libtmux"
|
||||
version = "0.46.2"
|
||||
version = "0.53.0"
|
||||
description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
python-versions = "<4.0,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "libtmux-0.46.2-py3-none-any.whl", hash = "sha256:6c32dbf22bde8e5e33b2714a4295f6e838dc640f337cd4c085a044f6828c7793"},
|
||||
{file = "libtmux-0.46.2.tar.gz", hash = "sha256:9a398fec5d714129c8344555d466e1a903dfc0f741ba07aabe75a8ceb25c5dda"},
|
||||
{file = "libtmux-0.53.0-py3-none-any.whl", hash = "sha256:024b7ae6a12aae55358e8feb914c8632b3ab9bd61c0987c53559643c6a58ee4f"},
|
||||
{file = "libtmux-0.53.0.tar.gz", hash = "sha256:1d19af4cea0c19543954d7e7317c7025c0739b029cccbe3b843212fae238f1bd"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4540,42 +4558,39 @@ valkey = ["valkey (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.77.7"
|
||||
version = "1.80.11"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = ">=3.8.1,<4.0, !=3.9.7"
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "litellm-1.80.11-py3-none-any.whl", hash = "sha256:406283d66ead77dc7ff0e0b2559c80e9e497d8e7c2257efb1cb9210a20d09d54"},
|
||||
{file = "litellm-1.80.11.tar.gz", hash = "sha256:c9fc63e7acb6360363238fe291bcff1488c59ff66020416d8376c0ee56414a19"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.10"
|
||||
click = "*"
|
||||
fastuuid = ">=0.13.0"
|
||||
grpcio = {version = ">=1.62.3,<1.68.0", markers = "python_version < \"3.14\""}
|
||||
httpx = ">=0.23.0"
|
||||
importlib-metadata = ">=6.8.0"
|
||||
jinja2 = "^3.1.2"
|
||||
jsonschema = "^4.22.0"
|
||||
openai = ">=1.99.5"
|
||||
pydantic = "^2.5.0"
|
||||
jinja2 = ">=3.1.2,<4.0.0"
|
||||
jsonschema = ">=4.23.0,<5.0.0"
|
||||
openai = ">=2.8.0"
|
||||
pydantic = ">=2.5.0,<3.0.0"
|
||||
python-dotenv = ">=0.2.0"
|
||||
tiktoken = ">=0.7.0"
|
||||
tokenizers = "*"
|
||||
|
||||
[package.extras]
|
||||
caching = ["diskcache (>=5.6.1,<6.0.0)"]
|
||||
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
|
||||
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
|
||||
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
|
||||
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.20)", "litellm-proxy-extras (==0.2.25)", "mcp (>=1.10.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=13.1.0,<14.0.0)"]
|
||||
semantic-router = ["semantic-router ; python_version >= \"3.9\""]
|
||||
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.27)", "litellm-proxy-extras (==0.4.16)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
|
||||
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
|
||||
utils = ["numpydoc"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/BerriAI/litellm.git"
|
||||
reference = "v1.77.7.dev9"
|
||||
resolved_reference = "763d2f8ccdd8412dbe6d4ac0e136d9ac34dcd4c0"
|
||||
|
||||
[[package]]
|
||||
name = "llvmlite"
|
||||
version = "0.44.0"
|
||||
@@ -4609,14 +4624,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "lmnr"
|
||||
version = "0.7.20"
|
||||
version = "0.7.24"
|
||||
description = "Python SDK for Laminar"
|
||||
optional = false
|
||||
python-versions = "<4,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
|
||||
{file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
|
||||
{file = "lmnr-0.7.24-py3-none-any.whl", hash = "sha256:ad780d4a62ece897048811f3368639c240a9329ab31027da8c96545137a3a08a"},
|
||||
{file = "lmnr-0.7.24.tar.gz", hash = "sha256:aa6973f46fc4ba95c9061c1feceb58afc02eb43c9376c21e32545371ff6123d7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4639,14 +4654,15 @@ tqdm = ">=4.0"
|
||||
|
||||
[package.extras]
|
||||
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
|
||||
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
|
||||
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
|
||||
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
|
||||
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
|
||||
claude-agent-sdk = ["lmnr-claude-code-proxy (>=0.1.0a5)"]
|
||||
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
|
||||
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
|
||||
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
|
||||
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
|
||||
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
|
||||
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)"]
|
||||
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
|
||||
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
|
||||
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
|
||||
@@ -5644,28 +5660,28 @@ pydantic = ">=2.9"
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "1.99.9"
|
||||
version = "2.8.0"
|
||||
description = "The official Python library for the openai API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a"},
|
||||
{file = "openai-1.99.9.tar.gz", hash = "sha256:f2082d155b1ad22e83247c3de3958eb4255b20ccf4a1de2e6681b6957b554e92"},
|
||||
{file = "openai-2.8.0-py3-none-any.whl", hash = "sha256:ba975e347f6add2fe13529ccb94d54a578280e960765e5224c34b08d7e029ddf"},
|
||||
{file = "openai-2.8.0.tar.gz", hash = "sha256:4851908f6d6fcacbd47ba659c5ac084f7725b752b6bfa1e948b6fbfc111a6bad"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.5.0,<5"
|
||||
distro = ">=1.7.0,<2"
|
||||
httpx = ">=0.23.0,<1"
|
||||
jiter = ">=0.4.0,<1"
|
||||
jiter = ">=0.10.0,<1"
|
||||
pydantic = ">=1.9.0,<3"
|
||||
sniffio = "*"
|
||||
tqdm = ">4"
|
||||
typing-extensions = ">=4.11,<5"
|
||||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
|
||||
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
|
||||
realtime = ["websockets (>=13,<16)"]
|
||||
voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
|
||||
@@ -5820,14 +5836,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.3.0"
|
||||
version = "1.7.4"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.3.0-py3-none-any.whl", hash = "sha256:2f87f790c740dc3fb81821c5f9fa375af875fbb937ebca3baa6dc5c035035b3c"},
|
||||
{file = "openhands_agent_server-1.3.0.tar.gz", hash = "sha256:0a83ae77373f5c41d0ba0e22d8f0f6144d54d55784183a50b7c098c96cd5135c"},
|
||||
{file = "openhands_agent_server-1.7.4-py3-none-any.whl", hash = "sha256:997b3dc5243a1ba105f5bd9b0b5bc0cd590c5aa79cd609f23f841218e5f77393"},
|
||||
{file = "openhands_agent_server-1.7.4.tar.gz", hash = "sha256:0491cf2a5d596610364cbbe9360412bc10a66ae71c0466ab64fd264826e6f1d8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5835,6 +5851,7 @@ aiosqlite = ">=0.19"
|
||||
alembic = ">=1.13"
|
||||
docker = ">=7.1,<8"
|
||||
fastapi = ">=0.104"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2"
|
||||
sqlalchemy = ">=2"
|
||||
uvicorn = ">=0.31.1"
|
||||
@@ -5843,7 +5860,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.62.0"
|
||||
version = "0.0.0-post.5803+a8098505c"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -5879,15 +5896,15 @@ json-repair = "*"
|
||||
jupyter_kernel_gateway = "*"
|
||||
kubernetes = "^33.1.0"
|
||||
libtmux = ">=0.46.2"
|
||||
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
|
||||
litellm = ">=1.74.3, !=1.64.4, !=1.67.*"
|
||||
lmnr = "^0.7.20"
|
||||
memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "1.99.9"
|
||||
openai = "2.8.0"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = "1.3.0"
|
||||
openhands-sdk = "1.3.0"
|
||||
openhands-tools = "1.3.0"
|
||||
openhands-agent-server = "1.7.4"
|
||||
openhands-sdk = "1.7.4"
|
||||
openhands-tools = "1.7.4"
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5904,7 +5921,6 @@ pygithub = "^2.5.0"
|
||||
pyjwt = "^2.9.0"
|
||||
pylatexenc = "*"
|
||||
pypdf = "^6.0.0"
|
||||
PyPDF2 = "*"
|
||||
python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = "^1.1.0"
|
||||
@@ -5943,23 +5959,23 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.3.0"
|
||||
version = "1.7.4"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.3.0-py3-none-any.whl", hash = "sha256:feee838346f8e60ea3e4d3391de7cb854314eb8b3c9e3dbbb56f98a784aadc56"},
|
||||
{file = "openhands_sdk-1.3.0.tar.gz", hash = "sha256:2d060803a78de462121b56dea717a66356922deb02276f37b29fae8af66343fb"},
|
||||
{file = "openhands_sdk-1.7.4-py3-none-any.whl", hash = "sha256:b57511a0467bd3fa64e8cccb7e8026f95e10ee7c5b148335eaa762a32aad8369"},
|
||||
{file = "openhands_sdk-1.7.4.tar.gz", hash = "sha256:f8e63f996a13d2ea41447384b77a4ffebeb9e85aa54fafcf584f97f7cdc2cd9b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
deprecation = ">=2.1.0"
|
||||
fastmcp = ">=2.11.3"
|
||||
httpx = ">=0.27.0"
|
||||
litellm = ">=1.77.7.dev9"
|
||||
lmnr = ">=0.7.20"
|
||||
pydantic = ">=2.11.7"
|
||||
litellm = ">=1.80.10"
|
||||
lmnr = ">=0.7.24"
|
||||
pydantic = ">=2.12.5"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
python-json-logger = ">=3.3.0"
|
||||
tenacity = ">=9.1.2"
|
||||
@@ -5970,14 +5986,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.3.0"
|
||||
version = "1.7.4"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.3.0-py3-none-any.whl", hash = "sha256:f31056d87c3058ac92709f9161c7c602daeee3ed0cb4439097b43cda105ed03e"},
|
||||
{file = "openhands_tools-1.3.0.tar.gz", hash = "sha256:3da46f09e28593677d3e17252ce18584fcc13caab1a73213e66bd7edca2cebe0"},
|
||||
{file = "openhands_tools-1.7.4-py3-none-any.whl", hash = "sha256:b6a9b04bc59610087d6df789054c966df176c16371fc9c0b0f333ba09f5710d1"},
|
||||
{file = "openhands_tools-1.7.4.tar.gz", hash = "sha256:776b570da0e86ae48c7815e9adb3839e953e2f4cab7295184ce15849348c52e7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5986,9 +6002,10 @@ binaryornot = ">=0.4.4"
|
||||
browser-use = ">=0.8.0"
|
||||
cachetools = "*"
|
||||
func-timeout = ">=4.3.5"
|
||||
libtmux = ">=0.46.2"
|
||||
libtmux = ">=0.53.0"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2.11.7"
|
||||
tom-swe = ">=1.0.3"
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
@@ -7237,22 +7254,22 @@ markers = {test = "platform_python_implementation == \"CPython\" and sys_platfor
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.7"
|
||||
version = "2.12.5"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
|
||||
{file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
|
||||
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
|
||||
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
|
||||
pydantic-core = "2.33.2"
|
||||
typing-extensions = ">=4.12.2"
|
||||
typing-inspection = ">=0.4.0"
|
||||
pydantic-core = "2.41.5"
|
||||
typing-extensions = ">=4.14.1"
|
||||
typing-inspection = ">=0.4.2"
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
@@ -7260,115 +7277,137 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
version = "2.41.5"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
|
||||
{file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
|
||||
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
typing-extensions = ">=4.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
@@ -13305,6 +13344,31 @@ dev = ["tokenizers[testing]"]
|
||||
docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"]
|
||||
testing = ["black (==22.3)", "datasets", "numpy", "pytest", "pytest-asyncio", "requests", "ruff"]
|
||||
|
||||
[[package]]
|
||||
name = "tom-swe"
|
||||
version = "1.0.3"
|
||||
description = "Theory of Mind modeling for Software Engineering assistants"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "tom_swe-1.0.3-py3-none-any.whl", hash = "sha256:7b1172b29eb5c8fb7f1975016e7b6a238511b9ac2a7a980bd400dcb4e29773f2"},
|
||||
{file = "tom_swe-1.0.3.tar.gz", hash = "sha256:57c97d0104e563f15bd39edaf2aa6ac4c3e9444afd437fb92458700d22c6c0f5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
jinja2 = ">=3.0.0"
|
||||
json-repair = ">=0.1.0"
|
||||
litellm = ">=1.0.0"
|
||||
pydantic = ">=2.0.0"
|
||||
python-dotenv = ">=1.0.0"
|
||||
tiktoken = ">=0.8.0"
|
||||
tqdm = ">=4.65.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["aiofiles (>=23.0.0)", "black (>=22.0.0)", "datasets (>=2.0.0)", "fastapi (>=0.104.0)", "httpx (>=0.25.0)", "huggingface-hub (>=0.0.0)", "isort (>=5.0.0)", "mypy (>=1.0.0)", "numpy (>=1.24.0)", "pandas (>=2.0.0)", "pre-commit (>=3.6.0)", "pytest (>=7.0.0)", "pytest-cov (>=6.2.1)", "rich (>=13.0.0)", "ruff (>=0.3.0)", "typing-extensions (>=4.0.0)", "uvicorn (>=0.24.0)"]
|
||||
search = ["bm25s (>=0.2.0)", "pystemmer (>=2.2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.10.2"
|
||||
@@ -13582,14 +13646,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
description = "Runtime typing introspection tools"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
|
||||
{file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
|
||||
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
|
||||
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
||||
@@ -34,8 +34,15 @@ from server.routes.integration.jira_dc import jira_dc_integration_router # noqa
|
||||
from server.routes.integration.linear import linear_integration_router # noqa: E402
|
||||
from server.routes.integration.slack import slack_router # noqa: E402
|
||||
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
|
||||
from server.routes.oauth_device import oauth_device_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.sharing.shared_conversation_router import ( # noqa: E402
|
||||
router as shared_conversation_router,
|
||||
)
|
||||
from server.sharing.shared_event_router import ( # noqa: E402
|
||||
router as shared_event_router,
|
||||
)
|
||||
|
||||
from openhands.server.app import app as base_app # noqa: E402
|
||||
from openhands.server.listen_socket import sio # noqa: E402
|
||||
@@ -60,10 +67,13 @@ base_app.mount('/internal/metrics', metrics_app())
|
||||
base_app.include_router(readiness_router) # Add routes for readiness checks
|
||||
base_app.include_router(api_router) # Add additional route for github auth
|
||||
base_app.include_router(oauth_router) # Add additional route for oauth callback
|
||||
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
|
||||
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
|
||||
base_app.include_router(
|
||||
billing_router
|
||||
) # Add routes for credit management and Stripe payment integration
|
||||
base_app.include_router(shared_conversation_router)
|
||||
base_app.include_router(shared_event_router)
|
||||
|
||||
# Add GitHub integration router only if GITHUB_APP_CLIENT_ID is set
|
||||
if GITHUB_APP_CLIENT_ID:
|
||||
@@ -97,6 +107,7 @@ base_app.include_router(
|
||||
event_webhook_router
|
||||
) # Add routes for Events in nested runtimes
|
||||
|
||||
|
||||
base_app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=PERMITTED_CORS_ORIGINS,
|
||||
|
||||
@@ -38,3 +38,8 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
BLOCKED_EMAIL_DOMAINS = [
|
||||
domain.strip().lower()
|
||||
for domain in os.getenv('BLOCKED_EMAIL_DOMAINS', '').split(',')
|
||||
if domain.strip()
|
||||
]
|
||||
|
||||
75
enterprise/server/auth/domain_blocker.py
Normal file
75
enterprise/server/auth/domain_blocker.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from server.auth.constants import BLOCKED_EMAIL_DOMAINS
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class DomainBlocker:
|
||||
def __init__(self) -> None:
|
||||
logger.debug('Initializing DomainBlocker')
|
||||
self.blocked_domains: list[str] = BLOCKED_EMAIL_DOMAINS
|
||||
if self.blocked_domains:
|
||||
logger.info(
|
||||
f'Successfully loaded {len(self.blocked_domains)} blocked email domains: {self.blocked_domains}'
|
||||
)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Check if domain blocking is enabled"""
|
||||
return bool(self.blocked_domains)
|
||||
|
||||
def _extract_domain(self, email: str) -> str | None:
|
||||
"""Extract and normalize email domain from email address"""
|
||||
if not email:
|
||||
return None
|
||||
try:
|
||||
# Extract domain part after @
|
||||
if '@' not in email:
|
||||
return None
|
||||
domain = email.split('@')[1].strip().lower()
|
||||
return domain if domain else None
|
||||
except Exception:
|
||||
logger.debug(f'Error extracting domain from email: {email}', exc_info=True)
|
||||
return None
|
||||
|
||||
def is_domain_blocked(self, email: str) -> bool:
|
||||
"""Check if email domain is blocked
|
||||
|
||||
Supports blocking:
|
||||
- Exact domains: 'example.com' blocks 'user@example.com'
|
||||
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
|
||||
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
|
||||
"""
|
||||
if not self.is_active():
|
||||
return False
|
||||
|
||||
if not email:
|
||||
logger.debug('No email provided for domain check')
|
||||
return False
|
||||
|
||||
domain = self._extract_domain(email)
|
||||
if not domain:
|
||||
logger.debug(f'Could not extract domain from email: {email}')
|
||||
return False
|
||||
|
||||
# Check if domain matches any blocked pattern
|
||||
for blocked_pattern in self.blocked_domains:
|
||||
if blocked_pattern.startswith('.'):
|
||||
# TLD pattern (e.g., '.us') - check if domain ends with it
|
||||
if domain.endswith(blocked_pattern):
|
||||
logger.warning(
|
||||
f'Email domain {domain} is blocked by TLD pattern {blocked_pattern} for email: {email}'
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# Full domain pattern (e.g., 'example.com')
|
||||
# Block exact match or subdomains
|
||||
if domain == blocked_pattern or domain.endswith(f'.{blocked_pattern}'):
|
||||
logger.warning(
|
||||
f'Email domain {domain} is blocked by domain pattern {blocked_pattern} for email: {email}'
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug(f'Email domain {domain} is not blocked')
|
||||
return False
|
||||
|
||||
|
||||
domain_blocker = DomainBlocker()
|
||||
109
enterprise/server/auth/email_validation.py
Normal file
109
enterprise/server/auth/email_validation.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Email validation utilities for preventing duplicate signups with + modifier."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def extract_base_email(email: str) -> str | None:
|
||||
"""Extract base email from an email address.
|
||||
|
||||
For emails with + modifier, extracts the base email (local part before + and @, plus domain).
|
||||
For emails without + modifier, returns the email as-is.
|
||||
|
||||
Examples:
|
||||
extract_base_email("joe+test@example.com") -> "joe@example.com"
|
||||
extract_base_email("joe@example.com") -> "joe@example.com"
|
||||
extract_base_email("joe+openhands+test@example.com") -> "joe@example.com"
|
||||
|
||||
Args:
|
||||
email: The email address to process
|
||||
|
||||
Returns:
|
||||
The base email address, or None if email format is invalid
|
||||
"""
|
||||
if not email or '@' not in email:
|
||||
return None
|
||||
|
||||
try:
|
||||
local_part, domain = email.rsplit('@', 1)
|
||||
# Extract the part before + if it exists
|
||||
base_local = local_part.split('+', 1)[0]
|
||||
return f'{base_local}@{domain}'
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def has_plus_modifier(email: str) -> bool:
|
||||
"""Check if an email address contains a + modifier.
|
||||
|
||||
Args:
|
||||
email: The email address to check
|
||||
|
||||
Returns:
|
||||
True if email contains + before @, False otherwise
|
||||
"""
|
||||
if not email or '@' not in email:
|
||||
return False
|
||||
|
||||
try:
|
||||
local_part, _ = email.rsplit('@', 1)
|
||||
return '+' in local_part
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def matches_base_email(email: str, base_email: str) -> bool:
|
||||
"""Check if an email matches a base email pattern.
|
||||
|
||||
An email matches if:
|
||||
- It is exactly the base email (e.g., joe@example.com)
|
||||
- It has the same base local part and domain, with or without + modifier
|
||||
(e.g., joe+test@example.com matches base joe@example.com)
|
||||
|
||||
Args:
|
||||
email: The email address to check
|
||||
base_email: The base email to match against
|
||||
|
||||
Returns:
|
||||
True if email matches the base pattern, False otherwise
|
||||
"""
|
||||
if not email or not base_email:
|
||||
return False
|
||||
|
||||
# Extract base from both emails for comparison
|
||||
email_base = extract_base_email(email)
|
||||
base_email_normalized = extract_base_email(base_email)
|
||||
|
||||
if not email_base or not base_email_normalized:
|
||||
return False
|
||||
|
||||
# Emails match if they have the same base
|
||||
return email_base.lower() == base_email_normalized.lower()
|
||||
|
||||
|
||||
def get_base_email_regex_pattern(base_email: str) -> re.Pattern | None:
|
||||
"""Generate a regex pattern to match emails with the same base.
|
||||
|
||||
For base_email "joe@example.com", the pattern will match:
|
||||
- joe@example.com
|
||||
- joe+anything@example.com
|
||||
|
||||
Args:
|
||||
base_email: The base email address
|
||||
|
||||
Returns:
|
||||
A compiled regex pattern, or None if base_email is invalid
|
||||
"""
|
||||
base = extract_base_email(base_email)
|
||||
if not base:
|
||||
return None
|
||||
|
||||
try:
|
||||
local_part, domain = base.rsplit('@', 1)
|
||||
# Escape special regex characters in local part and domain
|
||||
escaped_local = re.escape(local_part)
|
||||
escaped_domain = re.escape(domain)
|
||||
# Pattern: joe@example.com OR joe+anything@example.com
|
||||
pattern = rf'^{escaped_local}(\+[^@\s]+)?@{escaped_domain}$'
|
||||
return re.compile(pattern, re.IGNORECASE)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
@@ -13,6 +13,7 @@ from server.auth.auth_error import (
|
||||
ExpiredError,
|
||||
NoCredentialsError,
|
||||
)
|
||||
from server.auth.domain_blocker import domain_blocker
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from server.logger import logger
|
||||
@@ -153,8 +154,10 @@ class SaasUserAuth(UserAuth):
|
||||
try:
|
||||
# TODO: I think we can do this in a single request if we refactor
|
||||
with session_maker() as session:
|
||||
tokens = session.query(AuthTokens).where(
|
||||
AuthTokens.keycloak_user_id == self.user_id
|
||||
tokens = (
|
||||
session.query(AuthTokens)
|
||||
.where(AuthTokens.keycloak_user_id == self.user_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
for token in tokens:
|
||||
@@ -312,6 +315,16 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
|
||||
user_id = access_token_payload['sub']
|
||||
email = access_token_payload['email']
|
||||
email_verified = access_token_payload['email_verified']
|
||||
|
||||
# Check if email domain is blocked
|
||||
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
|
||||
logger.warning(
|
||||
f'Blocked authentication attempt for existing user with email: {email}'
|
||||
)
|
||||
raise AuthError(
|
||||
'Access denied: Your email domain is not allowed to access this service'
|
||||
)
|
||||
|
||||
logger.debug('saas_user_auth_from_signed_token:return')
|
||||
|
||||
return SaasUserAuth(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
@@ -13,6 +14,7 @@ from keycloak.exceptions import (
|
||||
KeycloakAuthenticationError,
|
||||
KeycloakConnectionError,
|
||||
KeycloakError,
|
||||
KeycloakPostError,
|
||||
)
|
||||
from server.auth.constants import (
|
||||
BITBUCKET_APP_CLIENT_ID,
|
||||
@@ -25,6 +27,11 @@ from server.auth.constants import (
|
||||
KEYCLOAK_SERVER_URL,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
)
|
||||
from server.auth.email_validation import (
|
||||
extract_base_email,
|
||||
get_base_email_regex_pattern,
|
||||
matches_base_email,
|
||||
)
|
||||
from server.auth.keycloak_manager import get_keycloak_admin, get_keycloak_openid
|
||||
from server.config import get_config
|
||||
from server.logger import logger
|
||||
@@ -37,6 +44,7 @@ from storage.offline_token_store import OfflineTokenStore
|
||||
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.types import SessionExpiredError
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
@@ -459,6 +467,14 @@ class TokenManager:
|
||||
except KeycloakConnectionError:
|
||||
logger.exception('KeycloakConnectionError when refreshing token')
|
||||
raise
|
||||
except KeycloakPostError as e:
|
||||
error_message = str(e)
|
||||
if 'invalid_grant' in error_message or 'session not found' in error_message:
|
||||
logger.warning(f'User session expired or invalid: {error_message}')
|
||||
raise SessionExpiredError(
|
||||
'Your session has expired. Please login again.'
|
||||
) from e
|
||||
raise
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(2),
|
||||
@@ -509,6 +525,183 @@ class TokenManager:
|
||||
logger.info(f'Got user ID {keycloak_user_id} from email: {email}')
|
||||
return keycloak_user_id
|
||||
|
||||
async def _query_users_by_wildcard_pattern(
|
||||
self, local_part: str, domain: str
|
||||
) -> dict[str, dict]:
|
||||
"""Query Keycloak for users matching a wildcard email pattern.
|
||||
|
||||
Tries multiple query methods to find users with emails matching
|
||||
the pattern {local_part}*@{domain}. This catches the base email
|
||||
and all + modifier variants.
|
||||
|
||||
Args:
|
||||
local_part: The local part of the email (before @)
|
||||
domain: The domain part of the email (after @)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping user IDs to user objects
|
||||
"""
|
||||
keycloak_admin = get_keycloak_admin(self.external)
|
||||
all_users = {}
|
||||
|
||||
# Query for users with emails matching the base pattern using wildcard
|
||||
# Pattern: {local_part}*@{domain} - catches base email and all + variants
|
||||
# This may also catch unintended matches (e.g., joesmith@example.com), but
|
||||
# they will be filtered out by the regex pattern check later
|
||||
# Use 'search' parameter for Keycloak 26+ (better wildcard support)
|
||||
wildcard_queries = [
|
||||
{'search': f'{local_part}*@{domain}'}, # Try 'search' parameter first
|
||||
{'q': f'email:{local_part}*@{domain}'}, # Fallback to 'q' parameter
|
||||
]
|
||||
|
||||
for query_params in wildcard_queries:
|
||||
try:
|
||||
users = await keycloak_admin.a_get_users(query_params)
|
||||
for user in users:
|
||||
all_users[user.get('id')] = user
|
||||
break # Success, no need to try fallback
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f'Wildcard query failed with {list(query_params.keys())[0]}: {e}'
|
||||
)
|
||||
continue # Try next query method
|
||||
|
||||
return all_users
|
||||
|
||||
def _find_duplicate_in_users(
|
||||
self, users: dict[str, dict], base_email: str, current_user_id: str
|
||||
) -> bool:
|
||||
"""Check if any user in the provided list matches the base email pattern.
|
||||
|
||||
Filters users to find duplicates that match the base email pattern,
|
||||
excluding the current user.
|
||||
|
||||
Args:
|
||||
users: Dictionary mapping user IDs to user objects
|
||||
base_email: The base email to match against
|
||||
current_user_id: The user ID to exclude from the check
|
||||
|
||||
Returns:
|
||||
True if a duplicate is found, False otherwise
|
||||
"""
|
||||
regex_pattern = get_base_email_regex_pattern(base_email)
|
||||
if not regex_pattern:
|
||||
logger.warning(
|
||||
f'Could not generate regex pattern for base email: {base_email}'
|
||||
)
|
||||
# Fallback to simple matching
|
||||
for user in users.values():
|
||||
user_email = user.get('email', '').lower()
|
||||
if (
|
||||
user_email
|
||||
and user.get('id') != current_user_id
|
||||
and matches_base_email(user_email, base_email)
|
||||
):
|
||||
logger.info(
|
||||
f'Found duplicate email: {user_email} matches base {base_email}'
|
||||
)
|
||||
return True
|
||||
else:
|
||||
for user in users.values():
|
||||
user_email = user.get('email', '')
|
||||
if (
|
||||
user_email
|
||||
and user.get('id') != current_user_id
|
||||
and regex_pattern.match(user_email)
|
||||
):
|
||||
logger.info(
|
||||
f'Found duplicate email: {user_email} matches base {base_email}'
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(2),
|
||||
retry=retry_if_exception_type(KeycloakConnectionError),
|
||||
before_sleep=_before_sleep_callback,
|
||||
)
|
||||
async def check_duplicate_base_email(
|
||||
self, email: str, current_user_id: str
|
||||
) -> bool:
|
||||
"""Check if a user with the same base email already exists.
|
||||
|
||||
This method checks for duplicate signups using email + modifier.
|
||||
It checks if any user exists with the same base email, regardless of whether
|
||||
the provided email has a + modifier or not.
|
||||
|
||||
Examples:
|
||||
- If email is "joe+test@example.com", it checks for existing users with
|
||||
base email "joe@example.com" (e.g., "joe@example.com", "joe+1@example.com")
|
||||
- If email is "joe@example.com", it checks for existing users with
|
||||
base email "joe@example.com" (e.g., "joe+1@example.com", "joe+test@example.com")
|
||||
|
||||
Args:
|
||||
email: The email address to check (may or may not contain + modifier)
|
||||
current_user_id: The user ID of the current user (to exclude from check)
|
||||
|
||||
Returns:
|
||||
True if a duplicate is found (excluding current user), False otherwise
|
||||
"""
|
||||
if not email:
|
||||
return False
|
||||
|
||||
base_email = extract_base_email(email)
|
||||
if not base_email:
|
||||
logger.warning(f'Could not extract base email from: {email}')
|
||||
return False
|
||||
|
||||
try:
|
||||
local_part, domain = base_email.rsplit('@', 1)
|
||||
users = await self._query_users_by_wildcard_pattern(local_part, domain)
|
||||
return self._find_duplicate_in_users(users, base_email, current_user_id)
|
||||
|
||||
except KeycloakConnectionError:
|
||||
logger.exception('KeycloakConnectionError when checking duplicate email')
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Unexpected error checking duplicate email: {e}')
|
||||
# On any error, allow signup to proceed (fail open)
|
||||
return False
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(2),
|
||||
retry=retry_if_exception_type(KeycloakConnectionError),
|
||||
before_sleep=_before_sleep_callback,
|
||||
)
|
||||
async def delete_keycloak_user(self, user_id: str) -> bool:
|
||||
"""Delete a user from Keycloak.
|
||||
|
||||
This method is used to clean up user accounts that were created
|
||||
but should not exist (e.g., duplicate email signups).
|
||||
|
||||
Args:
|
||||
user_id: The Keycloak user ID to delete
|
||||
|
||||
Returns:
|
||||
True if deletion was successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
keycloak_admin = get_keycloak_admin(self.external)
|
||||
# Use the sync method (python-keycloak doesn't have async delete_user)
|
||||
# Run it in a thread executor to avoid blocking the event loop
|
||||
await asyncio.to_thread(keycloak_admin.delete_user, user_id)
|
||||
logger.info(f'Successfully deleted Keycloak user {user_id}')
|
||||
return True
|
||||
except KeycloakConnectionError:
|
||||
logger.exception(f'KeycloakConnectionError when deleting user {user_id}')
|
||||
raise
|
||||
except KeycloakError as e:
|
||||
# User might not exist or already deleted
|
||||
logger.warning(
|
||||
f'KeycloakError when deleting user {user_id}: {e}',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f'Unexpected error deleting Keycloak user {user_id}: {e}')
|
||||
return False
|
||||
|
||||
async def get_user_info_from_user_id(self, user_id: str) -> dict | None:
|
||||
keycloak_admin = get_keycloak_admin(self.external)
|
||||
user = await keycloak_admin.a_get_user(user_id)
|
||||
@@ -527,6 +720,49 @@ class TokenManager:
|
||||
github_id = github_ids[0]
|
||||
return github_id
|
||||
|
||||
async def disable_keycloak_user(
|
||||
self, user_id: str, email: str | None = None
|
||||
) -> None:
|
||||
"""Disable a Keycloak user account.
|
||||
|
||||
Args:
|
||||
user_id: The Keycloak user ID to disable
|
||||
email: Optional email address for logging purposes
|
||||
|
||||
This method attempts to disable the user account but will not raise exceptions.
|
||||
Errors are logged but do not prevent the operation from completing.
|
||||
"""
|
||||
try:
|
||||
keycloak_admin = get_keycloak_admin(self.external)
|
||||
# Get current user to preserve other fields
|
||||
user = await keycloak_admin.a_get_user(user_id)
|
||||
if user:
|
||||
# Update user with enabled=False to disable the account
|
||||
await keycloak_admin.a_update_user(
|
||||
user_id=user_id,
|
||||
payload={
|
||||
'enabled': False,
|
||||
'username': user.get('username', ''),
|
||||
'email': user.get('email', ''),
|
||||
'emailVerified': user.get('emailVerified', False),
|
||||
},
|
||||
)
|
||||
email_str = f', email: {email}' if email else ''
|
||||
logger.info(
|
||||
f'Disabled Keycloak account for user_id: {user_id}{email_str}'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'User not found in Keycloak when attempting to disable: {user_id}'
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't raise - the caller should handle the blocking regardless
|
||||
email_str = f', email: {email}' if email else ''
|
||||
logger.error(
|
||||
f'Failed to disable Keycloak account for user_id: {user_id}{email_str}: {str(e)}',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def store_org_token(self, installation_id: int, installation_token: str):
|
||||
"""Store a GitHub App installation token.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ USER_SETTINGS_VERSION_TO_MODEL = {
|
||||
2: 'claude-3-7-sonnet-20250219',
|
||||
3: 'claude-sonnet-4-20250514',
|
||||
4: 'claude-sonnet-4-20250514',
|
||||
5: 'claude-opus-4-5-20251101',
|
||||
}
|
||||
|
||||
LITELLM_DEFAULT_MODEL = os.getenv('LITELLM_DEFAULT_MODEL')
|
||||
@@ -37,6 +38,8 @@ LITE_LLM_API_URL = os.environ.get(
|
||||
)
|
||||
LITE_LLM_TEAM_ID = os.environ.get('LITE_LLM_TEAM_ID', None)
|
||||
LITE_LLM_API_KEY = os.environ.get('LITE_LLM_API_KEY', None)
|
||||
# Timeout in seconds for BYOR key verification requests to LiteLLM
|
||||
BYOR_KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
SUBSCRIPTION_PRICE_DATA = {
|
||||
'MONTHLY_SUBSCRIPTION': {
|
||||
'unit_amount': 2000,
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import socketio
|
||||
from server.clustered_conversation_manager import ClusteredConversationManager
|
||||
from server.saas_nested_conversation_manager import SaasNestedConversationManager
|
||||
|
||||
from openhands.core.config import LLMConfig, OpenHandsConfig
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
)
|
||||
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.utils.async_utils import wait_all
|
||||
|
||||
_LEGACY_ENTRY_TIMEOUT_SECONDS = 3600
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegacyCacheEntry:
|
||||
"""Cache entry for legacy mode status."""
|
||||
|
||||
is_legacy: bool
|
||||
timestamp: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegacyConversationManager(ConversationManager):
|
||||
"""
|
||||
Conversation manager for use while migrating - since existing conversations are not nested!
|
||||
Separate class from SaasNestedConversationManager so it can be easliy removed in a few weeks.
|
||||
(As of 2025-07-23)
|
||||
"""
|
||||
|
||||
sio: socketio.AsyncServer
|
||||
config: OpenHandsConfig
|
||||
server_config: ServerConfig
|
||||
file_store: FileStore
|
||||
conversation_manager: SaasNestedConversationManager
|
||||
legacy_conversation_manager: ClusteredConversationManager
|
||||
_legacy_cache: dict[str, LegacyCacheEntry] = field(default_factory=dict)
|
||||
|
||||
async def __aenter__(self):
|
||||
await wait_all(
|
||||
[
|
||||
self.conversation_manager.__aenter__(),
|
||||
self.legacy_conversation_manager.__aenter__(),
|
||||
]
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await wait_all(
|
||||
[
|
||||
self.conversation_manager.__aexit__(exc_type, exc_value, traceback),
|
||||
self.legacy_conversation_manager.__aexit__(
|
||||
exc_type, exc_value, traceback
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def request_llm_completion(
|
||||
self,
|
||||
sid: str,
|
||||
service_id: str,
|
||||
llm_config: LLMConfig,
|
||||
messages: list[dict[str, str]],
|
||||
) -> str:
|
||||
session = self.get_agent_session(sid)
|
||||
llm_registry = session.llm_registry
|
||||
return llm_registry.request_extraneous_completion(
|
||||
service_id, llm_config, messages
|
||||
)
|
||||
|
||||
async def attach_to_conversation(
|
||||
self, sid: str, user_id: str | None = None
|
||||
) -> ServerConversation | None:
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.attach_to_conversation(
|
||||
sid, user_id
|
||||
)
|
||||
return await self.conversation_manager.attach_to_conversation(sid, user_id)
|
||||
|
||||
async def detach_from_conversation(self, conversation: ServerConversation):
|
||||
if await self.should_start_in_legacy_mode(conversation.sid):
|
||||
return await self.legacy_conversation_manager.detach_from_conversation(
|
||||
conversation
|
||||
)
|
||||
return await self.conversation_manager.detach_from_conversation(conversation)
|
||||
|
||||
async def join_conversation(
|
||||
self,
|
||||
sid: str,
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
) -> AgentLoopInfo:
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.join_conversation(
|
||||
sid, connection_id, settings, user_id
|
||||
)
|
||||
return await self.conversation_manager.join_conversation(
|
||||
sid, connection_id, settings, user_id
|
||||
)
|
||||
|
||||
def get_agent_session(self, sid: str):
|
||||
session = self.legacy_conversation_manager.get_agent_session(sid)
|
||||
if session is None:
|
||||
session = self.conversation_manager.get_agent_session(sid)
|
||||
return session
|
||||
|
||||
async def get_running_agent_loops(
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
) -> set[str]:
|
||||
if filter_to_sids and len(filter_to_sids) == 1:
|
||||
sid = next(iter(filter_to_sids))
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.get_running_agent_loops(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
return await self.conversation_manager.get_running_agent_loops(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
|
||||
# Get all running agent loops from both managers
|
||||
agent_loops, legacy_agent_loops = await wait_all(
|
||||
[
|
||||
self.conversation_manager.get_running_agent_loops(
|
||||
user_id, filter_to_sids
|
||||
),
|
||||
self.legacy_conversation_manager.get_running_agent_loops(
|
||||
user_id, filter_to_sids
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Combine the results
|
||||
result = set()
|
||||
for sid in legacy_agent_loops:
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
result.add(sid)
|
||||
|
||||
for sid in agent_loops:
|
||||
if not await self.should_start_in_legacy_mode(sid):
|
||||
result.add(sid)
|
||||
|
||||
return result
|
||||
|
||||
async def is_agent_loop_running(self, sid: str) -> bool:
|
||||
return bool(await self.get_running_agent_loops(filter_to_sids={sid}))
|
||||
|
||||
async def get_connections(
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
) -> dict[str, str]:
|
||||
if filter_to_sids and len(filter_to_sids) == 1:
|
||||
sid = next(iter(filter_to_sids))
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.get_connections(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
return await self.conversation_manager.get_connections(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
agent_loops, legacy_agent_loops = await wait_all(
|
||||
[
|
||||
self.conversation_manager.get_connections(user_id, filter_to_sids),
|
||||
self.legacy_conversation_manager.get_connections(
|
||||
user_id, filter_to_sids
|
||||
),
|
||||
]
|
||||
)
|
||||
legacy_agent_loops.update(agent_loops)
|
||||
return legacy_agent_loops
|
||||
|
||||
async def maybe_start_agent_loop(
|
||||
self,
|
||||
sid: str,
|
||||
settings: Settings,
|
||||
user_id: str, # type: ignore[override]
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.maybe_start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
return await self.conversation_manager.maybe_start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
|
||||
async def send_to_event_stream(self, connection_id: str, data: dict):
|
||||
return await self.legacy_conversation_manager.send_to_event_stream(
|
||||
connection_id, data
|
||||
)
|
||||
|
||||
async def send_event_to_conversation(self, sid: str, data: dict):
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
await self.legacy_conversation_manager.send_event_to_conversation(sid, data)
|
||||
await self.conversation_manager.send_event_to_conversation(sid, data)
|
||||
|
||||
async def disconnect_from_session(self, connection_id: str):
|
||||
return await self.legacy_conversation_manager.disconnect_from_session(
|
||||
connection_id
|
||||
)
|
||||
|
||||
async def close_session(self, sid: str):
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
await self.legacy_conversation_manager.close_session(sid)
|
||||
await self.conversation_manager.close_session(sid)
|
||||
|
||||
async def get_agent_loop_info(
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
) -> list[AgentLoopInfo]:
|
||||
if filter_to_sids and len(filter_to_sids) == 1:
|
||||
sid = next(iter(filter_to_sids))
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.get_agent_loop_info(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
return await self.conversation_manager.get_agent_loop_info(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
agent_loops, legacy_agent_loops = await wait_all(
|
||||
[
|
||||
self.conversation_manager.get_agent_loop_info(user_id, filter_to_sids),
|
||||
self.legacy_conversation_manager.get_agent_loop_info(
|
||||
user_id, filter_to_sids
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Combine results
|
||||
result = []
|
||||
legacy_sids = set()
|
||||
|
||||
# Add legacy agent loops
|
||||
for agent_loop in legacy_agent_loops:
|
||||
if await self.should_start_in_legacy_mode(agent_loop.conversation_id):
|
||||
result.append(agent_loop)
|
||||
legacy_sids.add(agent_loop.conversation_id)
|
||||
|
||||
# Add non-legacy agent loops
|
||||
for agent_loop in agent_loops:
|
||||
if (
|
||||
agent_loop.conversation_id not in legacy_sids
|
||||
and not await self.should_start_in_legacy_mode(
|
||||
agent_loop.conversation_id
|
||||
)
|
||||
):
|
||||
result.append(agent_loop)
|
||||
|
||||
return result
|
||||
|
||||
def _cleanup_expired_cache_entries(self):
|
||||
"""Remove expired entries from the local cache."""
|
||||
current_time = time.time()
|
||||
expired_keys = [
|
||||
key
|
||||
for key, entry in self._legacy_cache.items()
|
||||
if current_time - entry.timestamp > _LEGACY_ENTRY_TIMEOUT_SECONDS
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self._legacy_cache[key]
|
||||
|
||||
async def should_start_in_legacy_mode(self, conversation_id: str) -> bool:
|
||||
"""
|
||||
Check if a conversation should run in legacy mode by directly checking the runtime.
|
||||
The /list method does not include stopped conversations even though the PVC for these
|
||||
may not yet have been deleted, so we need to check /sessions/{session_id} directly.
|
||||
"""
|
||||
# Clean up expired entries periodically
|
||||
self._cleanup_expired_cache_entries()
|
||||
|
||||
# First check the local cache
|
||||
if conversation_id in self._legacy_cache:
|
||||
cached_entry = self._legacy_cache[conversation_id]
|
||||
# Check if the cached value is still valid
|
||||
if time.time() - cached_entry.timestamp <= _LEGACY_ENTRY_TIMEOUT_SECONDS:
|
||||
return cached_entry.is_legacy
|
||||
|
||||
# If not in cache or expired, check the runtime directly
|
||||
runtime = await self.conversation_manager._get_runtime(conversation_id)
|
||||
is_legacy = self.is_legacy_runtime(runtime)
|
||||
|
||||
# Cache the result with current timestamp
|
||||
self._legacy_cache[conversation_id] = LegacyCacheEntry(is_legacy, time.time())
|
||||
|
||||
return is_legacy
|
||||
|
||||
def is_legacy_runtime(self, runtime: dict | None) -> bool:
|
||||
"""
|
||||
Determine if a runtime is a legacy runtime based on its command.
|
||||
|
||||
Args:
|
||||
runtime: The runtime dictionary or None if not found
|
||||
|
||||
Returns:
|
||||
bool: True if this is a legacy runtime, False otherwise
|
||||
"""
|
||||
if runtime is None:
|
||||
return False
|
||||
return 'openhands.server' not in runtime['command']
|
||||
|
||||
@classmethod
|
||||
def get_instance(
|
||||
cls,
|
||||
sio: socketio.AsyncServer,
|
||||
config: OpenHandsConfig,
|
||||
file_store: FileStore,
|
||||
server_config: ServerConfig,
|
||||
monitoring_listener: MonitoringListener,
|
||||
) -> ConversationManager:
|
||||
return LegacyConversationManager(
|
||||
sio=sio,
|
||||
config=config,
|
||||
server_config=server_config,
|
||||
file_store=file_store,
|
||||
conversation_manager=SaasNestedConversationManager.get_instance(
|
||||
sio, config, file_store, server_config, monitoring_listener
|
||||
),
|
||||
legacy_conversation_manager=ClusteredConversationManager.get_instance(
|
||||
sio, config, file_store, server_config, monitoring_listener
|
||||
),
|
||||
)
|
||||
@@ -152,17 +152,23 @@ class SetAuthCookieMiddleware:
|
||||
return False
|
||||
path = request.url.path
|
||||
|
||||
is_api_that_should_attach = path.startswith('/api') and path not in (
|
||||
ignore_paths = (
|
||||
'/api/options/config',
|
||||
'/api/keycloak/callback',
|
||||
'/api/billing/success',
|
||||
'/api/billing/cancel',
|
||||
'/api/billing/customer-setup-success',
|
||||
'/api/billing/stripe-webhook',
|
||||
'/api/email/resend',
|
||||
'/oauth/device/authorize',
|
||||
'/oauth/device/token',
|
||||
)
|
||||
if path in ignore_paths:
|
||||
return False
|
||||
|
||||
is_mcp = path.startswith('/mcp')
|
||||
return is_api_that_should_attach or is_mcp
|
||||
is_api_route = path.startswith('/api')
|
||||
return is_api_route or is_mcp
|
||||
|
||||
async def _logout(self, request: Request):
|
||||
# Log out of keycloak - this prevents issues where you did not log in with the idp you believe you used
|
||||
|
||||
@@ -4,7 +4,11 @@ import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from server.config import get_config
|
||||
from server.constants import LITE_LLM_API_KEY, LITE_LLM_API_URL
|
||||
from server.constants import (
|
||||
BYOR_KEY_VERIFICATION_TIMEOUT,
|
||||
LITE_LLM_API_KEY,
|
||||
LITE_LLM_API_URL,
|
||||
)
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
@@ -112,6 +116,70 @@ async def generate_byor_key(user_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def verify_byor_key_in_litellm(byor_key: str, user_id: str) -> bool:
|
||||
"""Verify that a BYOR key is valid in LiteLLM by making a lightweight API call.
|
||||
|
||||
Args:
|
||||
byor_key: The BYOR key to verify
|
||||
user_id: The user ID for logging purposes
|
||||
|
||||
Returns:
|
||||
True if the key is verified as valid, False if verification fails or key is invalid.
|
||||
Returns False on network errors/timeouts to ensure we don't return potentially invalid keys.
|
||||
"""
|
||||
if not (LITE_LLM_API_URL and byor_key):
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(),
|
||||
timeout=BYOR_KEY_VERIFICATION_TIMEOUT,
|
||||
) as client:
|
||||
# Make a lightweight request to verify the key
|
||||
# Using /v1/models endpoint as it's lightweight and requires authentication
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/v1/models',
|
||||
headers={
|
||||
'Authorization': f'Bearer {byor_key}',
|
||||
},
|
||||
)
|
||||
|
||||
# Only 200 status code indicates valid key
|
||||
if response.status_code == 200:
|
||||
logger.debug(
|
||||
'BYOR key verification successful',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return True
|
||||
|
||||
# All other status codes (401, 403, 500, etc.) are treated as invalid
|
||||
# This includes authentication errors and server errors
|
||||
logger.warning(
|
||||
'BYOR key verification failed - treating as invalid',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'status_code': response.status_code,
|
||||
'key_prefix': byor_key[:10] + '...'
|
||||
if len(byor_key) > 10
|
||||
else byor_key,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
except (httpx.TimeoutException, Exception) as e:
|
||||
# Any exception (timeout, network error, etc.) means we can't verify
|
||||
# Return False to trigger regeneration rather than returning potentially invalid key
|
||||
logger.warning(
|
||||
'BYOR key verification error - treating as invalid to ensure key validity',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
'error_type': type(e).__name__,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool:
|
||||
"""Delete the BYOR key from LiteLLM using the key directly."""
|
||||
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
|
||||
@@ -278,18 +346,44 @@ async def delete_api_key(key_id: int, user_id: str = Depends(get_user_id)):
|
||||
|
||||
@api_router.get('/llm/byor', response_model=LlmApiKeyResponse)
|
||||
async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
"""Get the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user."""
|
||||
"""Get the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user.
|
||||
|
||||
This endpoint validates that the key exists in LiteLLM before returning it.
|
||||
If validation fails, it automatically generates a new key to ensure users
|
||||
always receive a working key.
|
||||
"""
|
||||
try:
|
||||
# Check if the BYOR key exists in the database
|
||||
byor_key = await get_byor_key_from_db(user_id)
|
||||
if byor_key:
|
||||
return {'key': byor_key}
|
||||
# Validate that the key is actually registered in LiteLLM
|
||||
is_valid = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
if is_valid:
|
||||
return {'key': byor_key}
|
||||
else:
|
||||
# Key exists in DB but is invalid in LiteLLM - regenerate it
|
||||
logger.warning(
|
||||
'BYOR key found in database but invalid in LiteLLM - regenerating',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'key_prefix': byor_key[:10] + '...'
|
||||
if len(byor_key) > 10
|
||||
else byor_key,
|
||||
},
|
||||
)
|
||||
# Delete the invalid key from LiteLLM (best effort, don't fail if it doesn't exist)
|
||||
await delete_byor_key_from_litellm(user_id, byor_key)
|
||||
# Fall through to generate a new key
|
||||
|
||||
# If not, generate a new key for BYOR
|
||||
# Generate a new key for BYOR (either no key exists or validation failed)
|
||||
key = await generate_byor_key(user_id)
|
||||
if key:
|
||||
# Store the key in the database
|
||||
await store_byor_key_in_db(user_id, key)
|
||||
logger.info(
|
||||
'Successfully generated and stored new BYOR key',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return {'key': key}
|
||||
else:
|
||||
logger.error(
|
||||
@@ -301,6 +395,9 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
detail='Failed to generate new BYOR LLM API key',
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception('Error retrieving BYOR LLM API key', extra={'error': str(e)})
|
||||
raise HTTPException(
|
||||
|
||||
@@ -14,6 +14,7 @@ from server.auth.constants import (
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
ROLE_CHECK_ENABLED,
|
||||
)
|
||||
from server.auth.domain_blocker import domain_blocker
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
@@ -145,7 +146,76 @@ async def keycloak_callback(
|
||||
content={'error': 'Missing user ID or username in response'},
|
||||
)
|
||||
|
||||
email = user_info.get('email')
|
||||
user_id = user_info['sub']
|
||||
|
||||
# Check if email domain is blocked
|
||||
email = user_info.get('email')
|
||||
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
|
||||
logger.warning(
|
||||
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'
|
||||
)
|
||||
|
||||
# Disable the Keycloak account
|
||||
await token_manager.disable_keycloak_user(user_id, email)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={
|
||||
'error': 'Access denied: Your email domain is not allowed to access this service'
|
||||
},
|
||||
)
|
||||
|
||||
# Check for duplicate email with + modifier
|
||||
if email:
|
||||
try:
|
||||
has_duplicate = await token_manager.check_duplicate_base_email(
|
||||
email, user_id
|
||||
)
|
||||
if has_duplicate:
|
||||
logger.warning(
|
||||
f'Blocked signup attempt for email {email} - duplicate base email found',
|
||||
extra={'user_id': user_id, 'email': email},
|
||||
)
|
||||
|
||||
# Delete the Keycloak user that was automatically created during OAuth
|
||||
# This prevents orphaned accounts in Keycloak
|
||||
# The delete_keycloak_user method already handles all errors internally
|
||||
deletion_success = await token_manager.delete_keycloak_user(user_id)
|
||||
if deletion_success:
|
||||
logger.info(
|
||||
f'Deleted Keycloak user {user_id} after detecting duplicate email {email}'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'Failed to delete Keycloak user {user_id} after detecting duplicate email {email}. '
|
||||
f'User may need to be manually cleaned up.'
|
||||
)
|
||||
|
||||
# Redirect to home page with query parameter indicating the issue
|
||||
home_url = f'{request.base_url}?duplicated_email=true'
|
||||
return RedirectResponse(home_url, status_code=302)
|
||||
except Exception as e:
|
||||
# Log error but allow signup to proceed (fail open)
|
||||
logger.error(
|
||||
f'Error checking duplicate email for {email}: {e}',
|
||||
extra={'user_id': user_id, 'email': email},
|
||||
)
|
||||
|
||||
# Check email verification status
|
||||
email_verified = user_info.get('email_verified', False)
|
||||
if not email_verified:
|
||||
# Send verification email
|
||||
# Import locally to avoid circular import with email.py
|
||||
from server.routes.email import verify_email
|
||||
|
||||
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
|
||||
redirect_url = (
|
||||
f'{request.base_url}?email_verification_required=true&user_id={user_id}'
|
||||
)
|
||||
response = RedirectResponse(redirect_url, status_code=302)
|
||||
return response
|
||||
|
||||
# default to github IDP for now.
|
||||
# TODO: remove default once Keycloak is updated universally with the new attribute.
|
||||
idp: str = user_info.get('identity_provider', ProviderType.GITHUB.value)
|
||||
|
||||
@@ -111,10 +111,24 @@ def calculate_credits(user_info: LiteLlmUserInfo) -> float:
|
||||
async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse:
|
||||
if not stripe_service.STRIPE_API_KEY:
|
||||
return GetCreditsResponse()
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
user_json = await _get_litellm_user(client, user_id)
|
||||
credits = calculate_credits(user_json['user_info'])
|
||||
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
user_json = await _get_litellm_user(client, user_id)
|
||||
credits = calculate_credits(user_json['user_info'])
|
||||
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f'litellm_get_user_failed: {type(e).__name__}: {e}',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'status_code': e.response.status_code,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve credit balance from billing service',
|
||||
)
|
||||
|
||||
|
||||
# Endpoint to retrieve user's current subscription access
|
||||
|
||||
@@ -7,6 +7,7 @@ from server.auth.constants import KEYCLOAK_CLIENT_ID
|
||||
from server.auth.keycloak_manager import get_keycloak_admin
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.auth import set_response_cookie
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
@@ -28,6 +29,11 @@ class EmailUpdate(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class ResendEmailVerificationRequest(BaseModel):
|
||||
user_id: str | None = None
|
||||
is_auth_flow: bool = False
|
||||
|
||||
|
||||
@api_router.post('')
|
||||
async def update_email(
|
||||
email_data: EmailUpdate, request: Request, user_id: str = Depends(get_user_id)
|
||||
@@ -74,7 +80,7 @@ async def update_email(
|
||||
accepted_tos=user_auth.accepted_tos,
|
||||
)
|
||||
|
||||
await _verify_email(request=request, user_id=user_id)
|
||||
await verify_email(request=request, user_id=user_id)
|
||||
|
||||
logger.info(f'Updating email address for {user_id} to {email}')
|
||||
return response
|
||||
@@ -90,9 +96,41 @@ async def update_email(
|
||||
)
|
||||
|
||||
|
||||
@api_router.put('/verify')
|
||||
async def verify_email(request: Request, user_id: str = Depends(get_user_id)):
|
||||
await _verify_email(request=request, user_id=user_id)
|
||||
@api_router.put('/resend')
|
||||
async def resend_email_verification(
|
||||
request: Request,
|
||||
body: ResendEmailVerificationRequest | None = None,
|
||||
):
|
||||
# Get user_id from body if provided, otherwise from auth
|
||||
user_id: str | None = None
|
||||
if body and body.user_id:
|
||||
user_id = body.user_id
|
||||
else:
|
||||
try:
|
||||
user_id = await get_user_id(request)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='user_id is required in request body or user must be authenticated',
|
||||
)
|
||||
|
||||
# Check rate limit (uses user_id if available, otherwise falls back to IP)
|
||||
# Use 30 seconds for user-based rate limiting to match frontend cooldown
|
||||
await check_rate_limit_by_user_id(
|
||||
request=request,
|
||||
key_prefix='email_resend',
|
||||
user_id=user_id,
|
||||
user_rate_limit_seconds=30,
|
||||
ip_rate_limit_seconds=60, # 1 minute for IP-based limiting (more lenient)
|
||||
)
|
||||
|
||||
# Get is_auth_flow from body if provided, default to False
|
||||
is_auth_flow = body.is_auth_flow if body else False
|
||||
|
||||
await verify_email(request=request, user_id=user_id, is_auth_flow=is_auth_flow)
|
||||
|
||||
logger.info(f'Resending verification email for {user_id}')
|
||||
return JSONResponse(
|
||||
@@ -124,10 +162,13 @@ async def verified_email(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
async def _verify_email(request: Request, user_id: str):
|
||||
async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False):
|
||||
keycloak_admin = get_keycloak_admin()
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
|
||||
if is_auth_flow:
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}?email_verified=true'
|
||||
else:
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
|
||||
logger.info(f'Redirect URI: {redirect_uri}')
|
||||
await keycloak_admin.a_send_verify_email(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -134,12 +134,12 @@ async def _process_batch_operations_background(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'error_processing_batch_operation',
|
||||
f'error_processing_batch_operation: {type(e).__name__}: {e}',
|
||||
extra={
|
||||
'path': batch_op.path,
|
||||
'method': str(batch_op.method),
|
||||
'error': str(e),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
@@ -58,7 +59,8 @@ async def github_events(
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await request.body()
|
||||
# Add timeout to prevent hanging on slow/stalled clients
|
||||
payload = await asyncio.wait_for(request.body(), timeout=15.0)
|
||||
verify_github_signature(payload, x_hub_signature_256)
|
||||
|
||||
payload_data = await request.json()
|
||||
@@ -78,6 +80,12 @@ async def github_events(
|
||||
status_code=200,
|
||||
content={'message': 'GitHub events endpoint reached successfully.'},
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning('GitHub webhook request timed out waiting for request body')
|
||||
return JSONResponse(
|
||||
status_code=408,
|
||||
content={'error': 'Request timeout - client took too long to send data.'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f'Error processing GitHub event: {e}')
|
||||
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from pydantic import BaseModel
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.server.shared import sio
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
gitlab_integration_router = APIRouter(prefix='/integration')
|
||||
webhook_store = GitlabWebhookStore()
|
||||
@@ -18,6 +31,37 @@ token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
|
||||
# Request/Response models
|
||||
class ResourceIdentifier(BaseModel):
|
||||
type: GitLabResourceType
|
||||
id: str
|
||||
|
||||
|
||||
class ReinstallWebhookRequest(BaseModel):
|
||||
resource: ResourceIdentifier
|
||||
|
||||
|
||||
class ResourceWithWebhookStatus(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
full_path: str
|
||||
type: str
|
||||
webhook_installed: bool
|
||||
webhook_uuid: str | None
|
||||
last_synced: str | None
|
||||
|
||||
|
||||
class GitLabResourcesResponse(BaseModel):
|
||||
resources: list[ResourceWithWebhookStatus]
|
||||
|
||||
|
||||
class ResourceInstallationResult(BaseModel):
|
||||
resource_id: str
|
||||
resource_type: str
|
||||
success: bool
|
||||
error: str | None
|
||||
|
||||
|
||||
async def verify_gitlab_signature(
|
||||
header_webhook_secret: str, webhook_uuid: str, user_id: str
|
||||
):
|
||||
@@ -83,3 +127,260 @@ async def gitlab_events(
|
||||
except Exception as e:
|
||||
logger.exception(f'Error processing GitLab event: {e}')
|
||||
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
|
||||
|
||||
|
||||
@gitlab_integration_router.get('/gitlab/resources')
|
||||
async def get_gitlab_resources(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> GitLabResourcesResponse:
|
||||
"""Get all GitLab projects and groups where the user has admin access.
|
||||
|
||||
Returns a list of resources with their webhook installation status.
|
||||
"""
|
||||
try:
|
||||
# Get GitLab service for the user
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only SaaS GitLab service is supported',
|
||||
)
|
||||
|
||||
# Fetch projects and groups with admin access
|
||||
projects, groups = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Filter out projects that belong to a group (nested projects)
|
||||
# We only want top-level personal projects since group webhooks cover nested projects
|
||||
filtered_projects = [
|
||||
project
|
||||
for project in projects
|
||||
if project.get('namespace', {}).get('kind') != 'group'
|
||||
]
|
||||
|
||||
# Extract IDs for bulk fetching
|
||||
project_ids = [str(project['id']) for project in filtered_projects]
|
||||
group_ids = [str(group['id']) for group in groups]
|
||||
|
||||
# Bulk fetch webhook records from database (organization-wide)
|
||||
(
|
||||
project_webhook_map,
|
||||
group_webhook_map,
|
||||
) = await webhook_store.get_webhooks_by_resources(project_ids, group_ids)
|
||||
|
||||
# Parallelize GitLab API calls to check webhook status for all resources
|
||||
async def check_project_webhook(project):
|
||||
project_id = str(project['id'])
|
||||
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
|
||||
GitLabResourceType.PROJECT, project_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
return project_id, webhook_exists
|
||||
|
||||
async def check_group_webhook(group):
|
||||
group_id = str(group['id'])
|
||||
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
|
||||
GitLabResourceType.GROUP, group_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
return group_id, webhook_exists
|
||||
|
||||
# Gather all API calls in parallel
|
||||
project_checks = [
|
||||
check_project_webhook(project) for project in filtered_projects
|
||||
]
|
||||
group_checks = [check_group_webhook(group) for group in groups]
|
||||
|
||||
# Execute all checks concurrently
|
||||
all_results = await asyncio.gather(*(project_checks + group_checks))
|
||||
|
||||
# Split results back into projects and groups
|
||||
num_projects = len(filtered_projects)
|
||||
project_results = all_results[:num_projects]
|
||||
group_results = all_results[num_projects:]
|
||||
|
||||
# Build response
|
||||
resources = []
|
||||
|
||||
# Add projects with their webhook status
|
||||
for project, (project_id, webhook_exists) in zip(
|
||||
filtered_projects, project_results
|
||||
):
|
||||
webhook = project_webhook_map.get(project_id)
|
||||
|
||||
resources.append(
|
||||
ResourceWithWebhookStatus(
|
||||
id=project_id,
|
||||
name=project.get('name', ''),
|
||||
full_path=project.get('path_with_namespace', ''),
|
||||
type='project',
|
||||
webhook_installed=webhook_exists,
|
||||
webhook_uuid=webhook.webhook_uuid if webhook else None,
|
||||
last_synced=(
|
||||
webhook.last_synced.isoformat()
|
||||
if webhook and webhook.last_synced
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Add groups with their webhook status
|
||||
for group, (group_id, webhook_exists) in zip(groups, group_results):
|
||||
webhook = group_webhook_map.get(group_id)
|
||||
|
||||
resources.append(
|
||||
ResourceWithWebhookStatus(
|
||||
id=group_id,
|
||||
name=group.get('name', ''),
|
||||
full_path=group.get('full_path', ''),
|
||||
type='group',
|
||||
webhook_installed=webhook_exists,
|
||||
webhook_uuid=webhook.webhook_uuid if webhook else None,
|
||||
last_synced=(
|
||||
webhook.last_synced.isoformat()
|
||||
if webhook and webhook.last_synced
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Retrieved GitLab resources',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'project_count': len(projects),
|
||||
'group_count': len(groups),
|
||||
},
|
||||
)
|
||||
|
||||
return GitLabResourcesResponse(resources=resources)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error retrieving GitLab resources: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve GitLab resources',
|
||||
)
|
||||
|
||||
|
||||
@gitlab_integration_router.post('/gitlab/reinstall-webhook')
|
||||
async def reinstall_gitlab_webhook(
|
||||
body: ReinstallWebhookRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> ResourceInstallationResult:
|
||||
"""Reinstall GitLab webhook for a specific resource immediately.
|
||||
|
||||
This endpoint validates permissions, resets webhook status in the database,
|
||||
and immediately installs the webhook on the specified resource.
|
||||
"""
|
||||
try:
|
||||
# Get GitLab service for the user
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only SaaS GitLab service is supported',
|
||||
)
|
||||
|
||||
resource_id = body.resource.id
|
||||
resource_type = body.resource.type
|
||||
|
||||
# Check if user has admin access to this resource
|
||||
(
|
||||
has_admin_access,
|
||||
check_status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not has_admin_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User does not have admin access to this resource',
|
||||
)
|
||||
|
||||
# Reset webhook in database (organization-wide, not user-specific)
|
||||
# This allows any admin user to reinstall webhooks
|
||||
await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
|
||||
# Get or create webhook record (without user_id filter)
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not webhook:
|
||||
# Create new webhook record
|
||||
webhook = GitlabWebhook(
|
||||
user_id=user_id, # Track who created it
|
||||
project_id=resource_id
|
||||
if resource_type == GitLabResourceType.PROJECT
|
||||
else None,
|
||||
group_id=resource_id
|
||||
if resource_type == GitLabResourceType.GROUP
|
||||
else None,
|
||||
webhook_exists=False,
|
||||
)
|
||||
await webhook_store.store_webhooks([webhook])
|
||||
# Fetch it again to get the ID (without user_id filter)
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Verify conditions and install webhook
|
||||
try:
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
# Install the webhook
|
||||
webhook_id, install_status = await install_webhook_on_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
if webhook_id:
|
||||
logger.info(
|
||||
'GitLab webhook reinstalled successfully',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'resource_type': resource_type.value,
|
||||
'resource_id': resource_id,
|
||||
},
|
||||
)
|
||||
return ResourceInstallationResult(
|
||||
resource_id=resource_id,
|
||||
resource_type=resource_type.value,
|
||||
success=True,
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to install webhook',
|
||||
)
|
||||
|
||||
except BreakLoopException:
|
||||
# Conditions not met or webhook already exists
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Webhook installation conditions not met or webhook already exists',
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error reinstalling GitLab webhook: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reinstall webhook',
|
||||
)
|
||||
|
||||
324
enterprise/server/routes/oauth_device.py
Normal file
324
enterprise/server/routes/oauth_device.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""OAuth 2.0 Device Flow endpoints for CLI authentication."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.database import session_maker
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEVICE_CODE_EXPIRES_IN = 600 # 10 minutes
|
||||
DEVICE_TOKEN_POLL_INTERVAL = 5 # seconds
|
||||
|
||||
API_KEY_NAME = 'Device Link Access Key'
|
||||
KEY_EXPIRATION_TIME = timedelta(days=1) # Key expires in 24 hours
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceAuthorizationResponse(BaseModel):
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
class DeviceTokenResponse(BaseModel):
|
||||
access_token: str # This will be the user's API key
|
||||
token_type: str = 'Bearer'
|
||||
expires_in: Optional[int] = None # API keys may not have expiration
|
||||
|
||||
|
||||
class DeviceTokenErrorResponse(BaseModel):
|
||||
error: str
|
||||
error_description: Optional[str] = None
|
||||
interval: Optional[int] = None # Required for slow_down error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router + stores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
oauth_device_router = APIRouter(prefix='/oauth/device')
|
||||
device_code_store = DeviceCodeStore(session_maker)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _oauth_error(
|
||||
status_code: int,
|
||||
error: str,
|
||||
description: str,
|
||||
interval: Optional[int] = None,
|
||||
) -> JSONResponse:
|
||||
"""Return a JSON OAuth-style error response."""
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=DeviceTokenErrorResponse(
|
||||
error=error,
|
||||
error_description=description,
|
||||
interval=interval,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@oauth_device_router.post('/authorize', response_model=DeviceAuthorizationResponse)
|
||||
async def device_authorization(
|
||||
http_request: Request,
|
||||
) -> DeviceAuthorizationResponse:
|
||||
"""Start device flow by generating device and user codes."""
|
||||
try:
|
||||
device_code_entry = device_code_store.create_device_code(
|
||||
expires_in=DEVICE_CODE_EXPIRES_IN,
|
||||
)
|
||||
|
||||
base_url = str(http_request.base_url).rstrip('/')
|
||||
verification_uri = f'{base_url}/oauth/device/verify'
|
||||
verification_uri_complete = (
|
||||
f'{verification_uri}?user_code={device_code_entry.user_code}'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Device authorization initiated',
|
||||
extra={'user_code': device_code_entry.user_code},
|
||||
)
|
||||
|
||||
return DeviceAuthorizationResponse(
|
||||
device_code=device_code_entry.device_code,
|
||||
user_code=device_code_entry.user_code,
|
||||
verification_uri=verification_uri,
|
||||
verification_uri_complete=verification_uri_complete,
|
||||
expires_in=DEVICE_CODE_EXPIRES_IN,
|
||||
interval=device_code_entry.current_interval,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Error in device authorization: %s', str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Internal server error',
|
||||
) from e
|
||||
|
||||
|
||||
@oauth_device_router.post('/token')
|
||||
async def device_token(device_code: str = Form(...)):
|
||||
"""Poll for a token until the user authorizes or the code expires."""
|
||||
try:
|
||||
device_code_entry = device_code_store.get_by_device_code(device_code)
|
||||
|
||||
if not device_code_entry:
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'invalid_grant',
|
||||
'Invalid device code',
|
||||
)
|
||||
|
||||
# Check rate limiting (RFC 8628 section 3.5)
|
||||
is_too_fast, current_interval = device_code_entry.check_rate_limit()
|
||||
if is_too_fast:
|
||||
# Update poll time and increase interval
|
||||
device_code_store.update_poll_time(device_code, increase_interval=True)
|
||||
logger.warning(
|
||||
'Client polling too fast, returning slow_down error',
|
||||
extra={
|
||||
'device_code': device_code[:8] + '...', # Log partial for privacy
|
||||
'new_interval': current_interval,
|
||||
},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'slow_down',
|
||||
f'Polling too frequently. Wait at least {current_interval} seconds between requests.',
|
||||
interval=current_interval,
|
||||
)
|
||||
|
||||
# Update poll time for successful rate limit check
|
||||
device_code_store.update_poll_time(device_code, increase_interval=False)
|
||||
|
||||
if device_code_entry.is_expired():
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'expired_token',
|
||||
'Device code has expired',
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'denied':
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'access_denied',
|
||||
'User denied the authorization request',
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'pending':
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'authorization_pending',
|
||||
'User has not yet completed authorization',
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'authorized':
|
||||
# Retrieve the specific API key for this device using the user_code
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})'
|
||||
device_api_key = api_key_store.retrieve_api_key_by_name(
|
||||
device_code_entry.keycloak_user_id, device_key_name
|
||||
)
|
||||
|
||||
if not device_api_key:
|
||||
logger.error(
|
||||
'No device API key found for authorized device',
|
||||
extra={
|
||||
'user_id': device_code_entry.keycloak_user_id,
|
||||
'user_code': device_code_entry.user_code,
|
||||
},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'API key not found',
|
||||
)
|
||||
|
||||
# Return the API key as access_token
|
||||
return DeviceTokenResponse(
|
||||
access_token=device_api_key,
|
||||
)
|
||||
|
||||
# Fallback for unexpected status values
|
||||
logger.error(
|
||||
'Unknown device code status',
|
||||
extra={'status': device_code_entry.status},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'Unknown device code status',
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Error in device token: %s', str(e))
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'Internal server error',
|
||||
)
|
||||
|
||||
|
||||
@oauth_device_router.post('/verify-authenticated')
|
||||
async def device_verification_authenticated(
|
||||
user_code: str = Form(...),
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
"""Process device verification for authenticated users (called by frontend)."""
|
||||
try:
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Authentication required',
|
||||
)
|
||||
|
||||
# Validate device code
|
||||
device_code_entry = device_code_store.get_by_user_code(user_code)
|
||||
if not device_code_entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='The device code is invalid or has expired.',
|
||||
)
|
||||
|
||||
if not device_code_entry.is_pending():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This device code has already been processed.',
|
||||
)
|
||||
|
||||
# First, authorize the device code
|
||||
success = device_code_store.authorize_device_code(
|
||||
user_code=user_code,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(
|
||||
'Failed to authorize device code',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to authorize the device. Please try again.',
|
||||
)
|
||||
|
||||
# Only create API key AFTER successful authorization
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
try:
|
||||
# Create a unique API key for this device using user_code in the name
|
||||
device_key_name = f'{API_KEY_NAME} ({user_code})'
|
||||
api_key_store.create_api_key(
|
||||
user_id,
|
||||
name=device_key_name,
|
||||
expires_at=datetime.now(UTC) + KEY_EXPIRATION_TIME,
|
||||
)
|
||||
logger.info(
|
||||
'Created new device API key for user after successful authorization',
|
||||
extra={'user_id': user_id, 'user_code': user_code},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Failed to create device API key after authorization: %s', str(e)
|
||||
)
|
||||
|
||||
# Clean up: revert the device authorization since API key creation failed
|
||||
# This prevents the device from being in an authorized state without an API key
|
||||
try:
|
||||
device_code_store.deny_device_code(user_code)
|
||||
logger.info(
|
||||
'Reverted device authorization due to API key creation failure',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
)
|
||||
except Exception as cleanup_error:
|
||||
logger.exception(
|
||||
'Failed to revert device authorization during cleanup: %s',
|
||||
str(cleanup_error),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create API key for device access.',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Device code authorized with API key successfully',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Device authorized successfully!'},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception('Error in device verification: %s', str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred. Please try again.',
|
||||
)
|
||||
@@ -12,6 +12,8 @@ from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import socketio
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import PERMITTED_CORS_ORIGINS, WEB_HOST
|
||||
from server.utils.conversation_callback_utils import (
|
||||
process_event,
|
||||
@@ -29,8 +31,13 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
ProviderToken,
|
||||
)
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.runtime.plugins.vscode import VSCodeRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.constants import ROOM_KEY
|
||||
@@ -70,6 +77,14 @@ RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + (
|
||||
else '/api/conversations/{conversation_id}'
|
||||
)
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'false')
|
||||
truthy = {'1', 'true', 't', 'yes', 'y', 'on'}
|
||||
SU_TO_USER = str(SU_TO_USER.lower() in truthy).lower()
|
||||
|
||||
DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true'
|
||||
|
||||
# Time in seconds before a Redis entry is considered expired if not refreshed
|
||||
_REDIS_ENTRY_TIMEOUT_SECONDS = 300
|
||||
|
||||
@@ -219,6 +234,102 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def _refresh_provider_tokens_after_runtime_init(
|
||||
self, settings: Settings, sid: str, user_id: str | None = None
|
||||
) -> Settings:
|
||||
"""Refresh provider tokens after runtime initialization.
|
||||
|
||||
During runtime initialization, tokens may be refreshed by Runtime.__init__().
|
||||
This method retrieves the fresh tokens from the database and creates a new
|
||||
settings object with updated tokens to avoid sending stale tokens to the
|
||||
nested runtime.
|
||||
|
||||
The method handles two scenarios:
|
||||
1. ProviderToken has user_id (IDP user ID, e.g., GitLab user ID)
|
||||
→ Uses get_idp_token_from_idp_user_id()
|
||||
2. ProviderToken has no user_id but Keycloak user_id is available
|
||||
→ Uses load_offline_token() + get_idp_token_from_offline_token()
|
||||
|
||||
Args:
|
||||
settings: The conversation settings that may contain provider tokens
|
||||
sid: The session ID for logging purposes
|
||||
user_id: The Keycloak user ID (optional, used as fallback when
|
||||
ProviderToken.user_id is not available)
|
||||
|
||||
Returns:
|
||||
Updated settings with fresh provider tokens, or original settings
|
||||
if no update is needed
|
||||
"""
|
||||
if not isinstance(settings, ConversationInitData):
|
||||
return settings
|
||||
|
||||
if not settings.git_provider_tokens:
|
||||
return settings
|
||||
|
||||
token_manager = TokenManager()
|
||||
updated_tokens = {}
|
||||
tokens_refreshed = 0
|
||||
tokens_failed = 0
|
||||
|
||||
for provider_type, provider_token in settings.git_provider_tokens.items():
|
||||
fresh_token = None
|
||||
|
||||
try:
|
||||
if provider_token.user_id:
|
||||
# Case 1: We have IDP user ID (e.g., GitLab user ID '32546706')
|
||||
# Get the token that was just refreshed during runtime initialization
|
||||
fresh_token = await token_manager.get_idp_token_from_idp_user_id(
|
||||
provider_token.user_id, provider_type
|
||||
)
|
||||
elif user_id:
|
||||
# Case 2: We have Keycloak user ID but no IDP user ID
|
||||
# This happens in web UI flow where ProviderToken.user_id is None
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
if offline_token:
|
||||
fresh_token = (
|
||||
await token_manager.get_idp_token_from_offline_token(
|
||||
offline_token, provider_type
|
||||
)
|
||||
)
|
||||
|
||||
if fresh_token:
|
||||
updated_tokens[provider_type] = ProviderToken(
|
||||
token=SecretStr(fresh_token),
|
||||
user_id=provider_token.user_id,
|
||||
host=provider_token.host,
|
||||
)
|
||||
tokens_refreshed += 1
|
||||
else:
|
||||
# Keep original token if we couldn't get a fresh one
|
||||
updated_tokens[provider_type] = provider_token
|
||||
|
||||
except Exception as e:
|
||||
# If refresh fails, use original token to prevent conversation startup failure
|
||||
logger.warning(
|
||||
f'Failed to refresh {provider_type.value} token: {e}',
|
||||
extra={'session_id': sid, 'provider': provider_type.value},
|
||||
exc_info=True,
|
||||
)
|
||||
updated_tokens[provider_type] = provider_token
|
||||
tokens_failed += 1
|
||||
|
||||
# Create new ConversationInitData with updated tokens
|
||||
# We cannot modify the frozen field directly, so we create a new object
|
||||
updated_settings = settings.model_copy(
|
||||
update={'git_provider_tokens': MappingProxyType(updated_tokens)}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Updated provider tokens after runtime creation',
|
||||
extra={
|
||||
'session_id': sid,
|
||||
'providers': [p.value for p in updated_tokens.keys()],
|
||||
'refreshed': tokens_refreshed,
|
||||
'failed': tokens_failed,
|
||||
},
|
||||
)
|
||||
return updated_settings
|
||||
|
||||
async def _start_agent_loop(
|
||||
self, sid, settings, user_id, initial_user_msg=None, replay_json=None
|
||||
):
|
||||
@@ -240,6 +351,11 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
|
||||
session_api_key = runtime.session.headers['X-Session-API-Key']
|
||||
|
||||
# Update provider tokens with fresh ones after runtime creation
|
||||
settings = await self._refresh_provider_tokens_after_runtime_init(
|
||||
settings, sid, user_id
|
||||
)
|
||||
|
||||
await self._start_conversation(
|
||||
sid,
|
||||
user_id,
|
||||
@@ -324,7 +440,12 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
async def _setup_provider_tokens(
|
||||
self, client: httpx.AsyncClient, api_url: str, settings: Settings
|
||||
):
|
||||
"""Setup provider tokens for the nested conversation."""
|
||||
"""Setup provider tokens for the nested conversation.
|
||||
|
||||
Note: Token validation happens in the nested runtime. If tokens are revoked,
|
||||
the nested runtime will return 401. The caller should handle token refresh
|
||||
and retry if needed.
|
||||
"""
|
||||
provider_handler = self._get_provider_handler(settings)
|
||||
provider_tokens = provider_handler.provider_tokens
|
||||
if provider_tokens:
|
||||
@@ -772,7 +893,11 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
env_vars['SERVE_FRONTEND'] = '0'
|
||||
env_vars['RUNTIME'] = 'local'
|
||||
# TODO: In the long term we may come up with a more secure strategy for user management within the nested runtime.
|
||||
env_vars['USER'] = 'openhands' if config.run_as_openhands else 'root'
|
||||
env_vars['USER'] = (
|
||||
RUNTIME_USERNAME
|
||||
if RUNTIME_USERNAME
|
||||
else ('openhands' if config.run_as_openhands else 'root')
|
||||
)
|
||||
env_vars['PERMITTED_CORS_ORIGINS'] = ','.join(PERMITTED_CORS_ORIGINS)
|
||||
env_vars['port'] = '60000'
|
||||
# TODO: These values are static in the runtime-api project, but do not get copied into the runtime ENV
|
||||
@@ -789,6 +914,10 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
|
||||
env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
|
||||
env_vars['ENABLE_V1'] = '0'
|
||||
env_vars['SU_TO_USER'] = SU_TO_USER
|
||||
env_vars['DISABLE_VSCODE_PLUGIN'] = str(DISABLE_VSCODE_PLUGIN).lower()
|
||||
env_vars['BROWSERGYM_DOWNLOAD_DIR'] = '/workspace/.downloads/'
|
||||
env_vars['PLAYWRIGHT_BROWSERS_PATH'] = '/opt/playwright-browsers'
|
||||
|
||||
# We need this for LLM traces tracking to identify the source of the LLM calls
|
||||
env_vars['WEB_HOST'] = WEB_HOST
|
||||
@@ -804,11 +933,18 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
if self._runtime_container_image:
|
||||
config.sandbox.runtime_container_image = self._runtime_container_image
|
||||
|
||||
plugins = [
|
||||
plugin
|
||||
for plugin in agent.sandbox_plugins
|
||||
if not (DISABLE_VSCODE_PLUGIN and isinstance(plugin, VSCodeRequirement))
|
||||
]
|
||||
logger.info(f'Loaded plugins for runtime {sid}: {plugins}')
|
||||
|
||||
runtime = RemoteRuntime(
|
||||
config=config,
|
||||
event_stream=None, # type: ignore[arg-type]
|
||||
sid=sid,
|
||||
plugins=agent.sandbox_plugins,
|
||||
plugins=plugins,
|
||||
# env_vars=env_vars,
|
||||
# status_callback: Callable[..., None] | None = None,
|
||||
attach_to_existing=False,
|
||||
|
||||
20
enterprise/server/sharing/README.md
Normal file
20
enterprise/server/sharing/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Sharing Package
|
||||
|
||||
This package contains functionality for sharing conversations.
|
||||
|
||||
## Components
|
||||
|
||||
- **shared.py**: Data models for shared conversations
|
||||
- **shared_conversation_info_service.py**: Service interface for accessing shared conversation info
|
||||
- **sql_shared_conversation_info_service.py**: SQL implementation of the shared conversation info service
|
||||
- **shared_event_service.py**: Service interface for accessing shared events
|
||||
- **shared_event_service_impl.py**: Implementation of the shared event service
|
||||
- **shared_conversation_router.py**: REST API endpoints for shared conversations
|
||||
- **shared_event_router.py**: REST API endpoints for shared events
|
||||
|
||||
## Features
|
||||
|
||||
- Read-only access to shared conversations
|
||||
- Event access for shared conversations
|
||||
- Search and filtering capabilities
|
||||
- Pagination support
|
||||
142
enterprise/server/sharing/filesystem_shared_event_service.py
Normal file
142
enterprise/server/sharing/filesystem_shared_event_service.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""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 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.event.event_service import EventService
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.sdk import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedEventServiceImpl(SharedEventService):
|
||||
"""Implementation of SharedEventService that validates shared access."""
|
||||
|
||||
shared_conversation_info_service: SharedConversationInfoService
|
||||
event_service: EventService
|
||||
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: str
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
# First check if the conversation is shared
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
return None
|
||||
|
||||
# If conversation is shared, get the event
|
||||
return await self.event_service.get_event(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
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info 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 self.event_service.search_events(
|
||||
conversation_id__eq=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,
|
||||
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
return 0
|
||||
|
||||
# If conversation is shared, count events for this conversation
|
||||
return await self.event_service.count_events(
|
||||
conversation_id__eq=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
|
||||
class SharedEventServiceImplInjector(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,
|
||||
get_event_service,
|
||||
)
|
||||
|
||||
async with (
|
||||
get_db_session(state, request) as db_session,
|
||||
get_event_service(state, request) as event_service,
|
||||
):
|
||||
shared_conversation_info_service = SQLSharedConversationInfoService(
|
||||
db_session=db_session
|
||||
)
|
||||
service = SharedEventServiceImpl(
|
||||
shared_conversation_info_service=shared_conversation_info_service,
|
||||
event_service=event_service,
|
||||
)
|
||||
yield service
|
||||
@@ -0,0 +1,66 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from server.sharing.shared_conversation_models import (
|
||||
SharedConversation,
|
||||
SharedConversationPage,
|
||||
SharedConversationSortOrder,
|
||||
)
|
||||
|
||||
from openhands.app_server.services.injector import Injector
|
||||
from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
|
||||
class SharedConversationInfoService(ABC):
|
||||
"""Service for accessing shared conversation info without user restrictions."""
|
||||
|
||||
@abstractmethod
|
||||
async def search_shared_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sort_order: SharedConversationSortOrder = SharedConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
include_sub_conversations: bool = False,
|
||||
) -> SharedConversationPage:
|
||||
"""Search for shared conversations."""
|
||||
|
||||
@abstractmethod
|
||||
async def count_shared_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count shared conversations."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_shared_conversation_info(
|
||||
self, conversation_id: UUID
|
||||
) -> SharedConversation | None:
|
||||
"""Get a single shared conversation info, returning None if missing or not shared."""
|
||||
|
||||
async def batch_get_shared_conversation_info(
|
||||
self, conversation_ids: list[UUID]
|
||||
) -> list[SharedConversation | None]:
|
||||
"""Get a batch of shared conversation info, return None for any missing or non-shared."""
|
||||
return await asyncio.gather(
|
||||
*[
|
||||
self.get_shared_conversation_info(conversation_id)
|
||||
for conversation_id in conversation_ids
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SharedConversationInfoServiceInjector(
|
||||
DiscriminatedUnionMixin, Injector[SharedConversationInfoService], ABC
|
||||
):
|
||||
pass
|
||||
56
enterprise/server/sharing/shared_conversation_models.py
Normal file
56
enterprise/server/sharing/shared_conversation_models.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
# Simplified imports to avoid dependency chain issues
|
||||
# from openhands.integrations.service_types import ProviderType
|
||||
# from openhands.sdk.llm import MetricsSnapshot
|
||||
# from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
# For now, use Any to avoid import issues
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.agent_server.utils import OpenHandsUUID, utc_now
|
||||
|
||||
ProviderType = Any
|
||||
MetricsSnapshot = Any
|
||||
ConversationTrigger = Any
|
||||
|
||||
|
||||
class SharedConversation(BaseModel):
|
||||
"""Shared conversation info model with all fields from AppConversationInfo."""
|
||||
|
||||
id: OpenHandsUUID = Field(default_factory=uuid4)
|
||||
|
||||
created_by_user_id: str | None
|
||||
sandbox_id: str
|
||||
|
||||
selected_repository: str | None = None
|
||||
selected_branch: str | None = None
|
||||
git_provider: ProviderType | None = None
|
||||
title: str | None = None
|
||||
pr_number: list[int] = Field(default_factory=list)
|
||||
llm_model: str | None = None
|
||||
|
||||
metrics: MetricsSnapshot | None = None
|
||||
|
||||
parent_conversation_id: OpenHandsUUID | None = None
|
||||
sub_conversation_ids: list[OpenHandsUUID] = Field(default_factory=list)
|
||||
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
updated_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
class SharedConversationSortOrder(Enum):
|
||||
CREATED_AT = 'CREATED_AT'
|
||||
CREATED_AT_DESC = 'CREATED_AT_DESC'
|
||||
UPDATED_AT = 'UPDATED_AT'
|
||||
UPDATED_AT_DESC = 'UPDATED_AT_DESC'
|
||||
TITLE = 'TITLE'
|
||||
TITLE_DESC = 'TITLE_DESC'
|
||||
|
||||
|
||||
class SharedConversationPage(BaseModel):
|
||||
items: list[SharedConversation]
|
||||
next_page_id: str | None = None
|
||||
135
enterprise/server/sharing/shared_conversation_router.py
Normal file
135
enterprise/server/sharing/shared_conversation_router.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Shared Conversation router for OpenHands Server."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
from server.sharing.shared_conversation_models import (
|
||||
SharedConversation,
|
||||
SharedConversationPage,
|
||||
SharedConversationSortOrder,
|
||||
)
|
||||
from server.sharing.sql_shared_conversation_info_service import (
|
||||
SQLSharedConversationInfoServiceInjector,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/api/shared-conversations', tags=['Sharing'])
|
||||
shared_conversation_info_service_dependency = Depends(
|
||||
SQLSharedConversationInfoServiceInjector().depends
|
||||
)
|
||||
|
||||
# Read methods
|
||||
|
||||
|
||||
@router.get('/search')
|
||||
async def search_shared_conversations(
|
||||
title__contains: Annotated[
|
||||
str | None,
|
||||
Query(title='Filter by title containing this string'),
|
||||
] = None,
|
||||
created_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
created_at__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at less than this datetime'),
|
||||
] = None,
|
||||
updated_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by updated_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
updated_at__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by updated_at less than this datetime'),
|
||||
] = None,
|
||||
sort_order: Annotated[
|
||||
SharedConversationSortOrder,
|
||||
Query(title='Sort order for results'),
|
||||
] = SharedConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(
|
||||
title='The max number of results in the page',
|
||||
gt=0,
|
||||
lte=100,
|
||||
),
|
||||
] = 100,
|
||||
include_sub_conversations: Annotated[
|
||||
bool,
|
||||
Query(
|
||||
title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.'
|
||||
),
|
||||
] = False,
|
||||
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,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
include_sub_conversations=include_sub_conversations,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/count')
|
||||
async def count_shared_conversations(
|
||||
title__contains: Annotated[
|
||||
str | None,
|
||||
Query(title='Filter by title containing this string'),
|
||||
] = None,
|
||||
created_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
created_at__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at less than this datetime'),
|
||||
] = None,
|
||||
updated_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by updated_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
updated_at__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by updated_at less than this datetime'),
|
||||
] = None,
|
||||
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
|
||||
) -> int:
|
||||
"""Count shared conversations matching the given filters."""
|
||||
return await shared_conversation_service.count_shared_conversation_info(
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
)
|
||||
|
||||
|
||||
@router.get('')
|
||||
async def batch_get_shared_conversations(
|
||||
ids: Annotated[list[str], Query()],
|
||||
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
|
||||
) -> list[SharedConversation | None]:
|
||||
"""Get a batch of shared conversations given their ids. Return None for any missing or non-shared."""
|
||||
assert len(ids) <= 100
|
||||
uuids = [UUID(id_) for id_ in ids]
|
||||
shared_conversation_info = (
|
||||
await shared_conversation_service.batch_get_shared_conversation_info(uuids)
|
||||
)
|
||||
return shared_conversation_info
|
||||
126
enterprise/server/sharing/shared_event_router.py
Normal file
126
enterprise/server/sharing/shared_event_router.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Shared Event router for OpenHands Server."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from server.sharing.filesystem_shared_event_service import (
|
||||
SharedEventServiceImplInjector,
|
||||
)
|
||||
from server.sharing.shared_event_service import SharedEventService
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.sdk import Event
|
||||
|
||||
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
|
||||
shared_event_service_dependency = Depends(SharedEventServiceImplInjector().depends)
|
||||
|
||||
|
||||
# Read methods
|
||||
|
||||
|
||||
@router.get('/search')
|
||||
async def search_shared_events(
|
||||
conversation_id: Annotated[
|
||||
str,
|
||||
Query(title='Conversation ID to search events for'),
|
||||
],
|
||||
kind__eq: Annotated[
|
||||
EventKind | None,
|
||||
Query(title='Optional filter by event kind'),
|
||||
] = None,
|
||||
timestamp__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Optional filter by timestamp greater than or equal to'),
|
||||
] = None,
|
||||
timestamp__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Optional filter by timestamp less than'),
|
||||
] = None,
|
||||
sort_order: Annotated[
|
||||
EventSortOrder,
|
||||
Query(title='Sort order for results'),
|
||||
] = EventSortOrder.TIMESTAMP,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
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."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/count')
|
||||
async def count_shared_events(
|
||||
conversation_id: Annotated[
|
||||
str,
|
||||
Query(title='Conversation ID to count events for'),
|
||||
],
|
||||
kind__eq: Annotated[
|
||||
EventKind | None,
|
||||
Query(title='Optional filter by event kind'),
|
||||
] = None,
|
||||
timestamp__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Optional filter by timestamp greater than or equal to'),
|
||||
] = None,
|
||||
timestamp__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Optional filter by timestamp less than'),
|
||||
] = None,
|
||||
sort_order: Annotated[
|
||||
EventSortOrder,
|
||||
Query(title='Sort order for results'),
|
||||
] = EventSortOrder.TIMESTAMP,
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> int:
|
||||
"""Count events for a shared conversation matching the given filters."""
|
||||
return await shared_event_service.count_shared_events(
|
||||
conversation_id=UUID(conversation_id),
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
|
||||
@router.get('')
|
||||
async def batch_get_shared_events(
|
||||
conversation_id: Annotated[
|
||||
UUID,
|
||||
Query(title='Conversation ID to get events for'),
|
||||
],
|
||||
id: Annotated[list[str], Query()],
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> list[Event | None]:
|
||||
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
|
||||
assert len(id) <= 100
|
||||
events = await shared_event_service.batch_get_shared_events(conversation_id, id)
|
||||
return events
|
||||
|
||||
|
||||
@router.get('/{conversation_id}/{event_id}')
|
||||
async def get_shared_event(
|
||||
conversation_id: UUID,
|
||||
event_id: str,
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> Event | None:
|
||||
"""Get a single event from a shared conversation by conversation_id and event_id."""
|
||||
return await shared_event_service.get_shared_event(conversation_id, event_id)
|
||||
64
enterprise/server/sharing/shared_event_service.py
Normal file
64
enterprise/server/sharing/shared_event_service.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.app_server.services.injector import Injector
|
||||
from openhands.sdk import Event
|
||||
from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SharedEventService(ABC):
|
||||
"""Event Service for getting events from shared conversations only."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: str
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
|
||||
@abstractmethod
|
||||
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."""
|
||||
|
||||
@abstractmethod
|
||||
async def count_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,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
|
||||
async def batch_get_shared_events(
|
||||
self, conversation_id: UUID, event_ids: list[str]
|
||||
) -> list[Event | None]:
|
||||
"""Given a conversation_id and list of event_ids, get events if the conversation is shared."""
|
||||
return await asyncio.gather(
|
||||
*[
|
||||
self.get_shared_event(conversation_id, event_id)
|
||||
for event_id in event_ids
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SharedEventServiceInjector(
|
||||
DiscriminatedUnionMixin, Injector[SharedEventService], ABC
|
||||
):
|
||||
pass
|
||||
@@ -0,0 +1,282 @@
|
||||
"""SQL implementation of SharedConversationInfoService.
|
||||
|
||||
This implementation provides read-only access to shared conversations:
|
||||
- Direct database access without user permission checks
|
||||
- Filters only conversations marked as shared (currently public)
|
||||
- Full async/await support using SQL async db_sessions
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
SharedConversationInfoServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_conversation_models import (
|
||||
SharedConversation,
|
||||
SharedConversationPage,
|
||||
SharedConversationSortOrder,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
StoredConversationMetadata,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.llm.utils.metrics import TokenUsage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
"""SQL implementation of SharedConversationInfoService for shared conversations only."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def search_shared_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sort_order: SharedConversationSortOrder = SharedConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
include_sub_conversations: bool = False,
|
||||
) -> SharedConversationPage:
|
||||
"""Search for shared conversations."""
|
||||
query = self._public_select()
|
||||
|
||||
# Conditionally exclude sub-conversations based on the parameter
|
||||
if not include_sub_conversations:
|
||||
# Exclude sub-conversations (only include top-level conversations)
|
||||
query = query.where(
|
||||
StoredConversationMetadata.parent_conversation_id.is_(None)
|
||||
)
|
||||
|
||||
query = self._apply_filters(
|
||||
query=query,
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
)
|
||||
|
||||
# Add sort order
|
||||
if sort_order == SharedConversationSortOrder.CREATED_AT:
|
||||
query = query.order_by(StoredConversationMetadata.created_at)
|
||||
elif sort_order == SharedConversationSortOrder.CREATED_AT_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.created_at.desc())
|
||||
elif sort_order == SharedConversationSortOrder.UPDATED_AT:
|
||||
query = query.order_by(StoredConversationMetadata.last_updated_at)
|
||||
elif sort_order == SharedConversationSortOrder.UPDATED_AT_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.last_updated_at.desc())
|
||||
elif sort_order == SharedConversationSortOrder.TITLE:
|
||||
query = query.order_by(StoredConversationMetadata.title)
|
||||
elif sort_order == SharedConversationSortOrder.TITLE_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.title.desc())
|
||||
|
||||
# Apply pagination
|
||||
if page_id is not None:
|
||||
try:
|
||||
offset = int(page_id)
|
||||
query = query.offset(offset)
|
||||
except ValueError:
|
||||
# If page_id is not a valid integer, start from beginning
|
||||
offset = 0
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
# Apply limit and get one extra to check if there are more results
|
||||
query = query.limit(limit + 1)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
rows = result.scalars().all()
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(rows) > limit
|
||||
if has_more:
|
||||
rows = rows[:limit]
|
||||
|
||||
items = [self._to_shared_conversation(row) for row in rows]
|
||||
|
||||
# Calculate next page ID
|
||||
next_page_id = None
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
|
||||
return SharedConversationPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
async def count_shared_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count shared conversations matching the given filters."""
|
||||
from sqlalchemy import func
|
||||
|
||||
query = select(func.count(StoredConversationMetadata.conversation_id))
|
||||
# Only include shared conversations
|
||||
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
|
||||
query = query.where(StoredConversationMetadata.conversation_version == 'V1')
|
||||
|
||||
query = self._apply_filters(
|
||||
query=query,
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_shared_conversation_info(
|
||||
self, conversation_id: UUID
|
||||
) -> SharedConversation | None:
|
||||
"""Get a single public conversation info, returning None if missing or not shared."""
|
||||
query = self._public_select().where(
|
||||
StoredConversationMetadata.conversation_id == str(conversation_id)
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
stored = result.scalar_one_or_none()
|
||||
|
||||
if stored is None:
|
||||
return None
|
||||
|
||||
return self._to_shared_conversation(stored)
|
||||
|
||||
def _public_select(self):
|
||||
"""Create a select query that only returns public conversations."""
|
||||
query = select(StoredConversationMetadata).where(
|
||||
StoredConversationMetadata.conversation_version == 'V1'
|
||||
)
|
||||
# Only include conversations marked as public
|
||||
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
|
||||
return query
|
||||
|
||||
def _apply_filters(
|
||||
self,
|
||||
query,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
):
|
||||
"""Apply common filters to a query."""
|
||||
if title__contains is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.title.contains(title__contains)
|
||||
)
|
||||
|
||||
if created_at__gte is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.created_at >= created_at__gte
|
||||
)
|
||||
|
||||
if created_at__lt is not None:
|
||||
query = query.where(StoredConversationMetadata.created_at < created_at__lt)
|
||||
|
||||
if updated_at__gte is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.last_updated_at >= updated_at__gte
|
||||
)
|
||||
|
||||
if updated_at__lt is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.last_updated_at < updated_at__lt
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def _to_shared_conversation(
|
||||
self,
|
||||
stored: StoredConversationMetadata,
|
||||
sub_conversation_ids: list[UUID] | None = None,
|
||||
) -> SharedConversation:
|
||||
"""Convert StoredConversationMetadata to SharedConversation."""
|
||||
# V1 conversations should always have a sandbox_id
|
||||
sandbox_id = stored.sandbox_id
|
||||
assert sandbox_id is not None
|
||||
|
||||
# Rebuild token usage
|
||||
token_usage = TokenUsage(
|
||||
prompt_tokens=stored.prompt_tokens,
|
||||
completion_tokens=stored.completion_tokens,
|
||||
cache_read_tokens=stored.cache_read_tokens,
|
||||
cache_write_tokens=stored.cache_write_tokens,
|
||||
context_window=stored.context_window,
|
||||
per_turn_token=stored.per_turn_token,
|
||||
)
|
||||
|
||||
# Rebuild metrics object
|
||||
metrics = MetricsSnapshot(
|
||||
accumulated_cost=stored.accumulated_cost,
|
||||
max_budget_per_task=stored.max_budget_per_task,
|
||||
accumulated_token_usage=token_usage,
|
||||
)
|
||||
|
||||
# Get timestamps
|
||||
created_at = self._fix_timezone(stored.created_at)
|
||||
updated_at = self._fix_timezone(stored.last_updated_at)
|
||||
|
||||
return SharedConversation(
|
||||
id=UUID(stored.conversation_id),
|
||||
created_by_user_id=stored.user_id if stored.user_id else None,
|
||||
sandbox_id=stored.sandbox_id,
|
||||
selected_repository=stored.selected_repository,
|
||||
selected_branch=stored.selected_branch,
|
||||
git_provider=(
|
||||
ProviderType(stored.git_provider) if stored.git_provider else None
|
||||
),
|
||||
title=stored.title,
|
||||
pr_number=stored.pr_number,
|
||||
llm_model=stored.llm_model,
|
||||
metrics=metrics,
|
||||
parent_conversation_id=(
|
||||
UUID(stored.parent_conversation_id)
|
||||
if stored.parent_conversation_id
|
||||
else None
|
||||
),
|
||||
sub_conversation_ids=sub_conversation_ids or [],
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def _fix_timezone(self, value: datetime) -> datetime:
|
||||
"""Sqlite does not store timezones - and since we can't update the existing models
|
||||
we assume UTC if the timezone is missing."""
|
||||
if not value.tzinfo:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
class SQLSharedConversationInfoServiceInjector(SharedConversationInfoServiceInjector):
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SharedConversationInfoService, 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:
|
||||
service = SQLSharedConversationInfoService(db_session=db_session)
|
||||
yield service
|
||||
83
enterprise/server/utils/rate_limit_utils.py
Normal file
83
enterprise/server/utils/rate_limit_utils.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import sio
|
||||
|
||||
# Rate limiting constants
|
||||
RATE_LIMIT_USER_SECONDS = 120 # 2 minutes per user_id
|
||||
RATE_LIMIT_IP_SECONDS = 300 # 5 minutes per IP address
|
||||
|
||||
|
||||
async def check_rate_limit_by_user_id(
|
||||
request: Request,
|
||||
key_prefix: str,
|
||||
user_id: str | None,
|
||||
user_rate_limit_seconds: int = RATE_LIMIT_USER_SECONDS,
|
||||
ip_rate_limit_seconds: int = RATE_LIMIT_IP_SECONDS,
|
||||
) -> None:
|
||||
"""
|
||||
Check rate limit for requests, using user_id when available, falling back to IP address.
|
||||
|
||||
Uses Redis to store rate limit keys with expiration. If a key already exists,
|
||||
it means the rate limit is active and the request will be rejected.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
key_prefix: Prefix for the Redis key (e.g., "email_resend")
|
||||
user_id: User ID if available, None otherwise
|
||||
user_rate_limit_seconds: Rate limit window in seconds for user_id-based limiting (default: 120)
|
||||
ip_rate_limit_seconds: Rate limit window in seconds for IP-based limiting (default: 300)
|
||||
|
||||
Raises:
|
||||
HTTPException: If rate limit is exceeded (429 status code)
|
||||
"""
|
||||
try:
|
||||
redis = sio.manager.redis
|
||||
if not redis:
|
||||
# If Redis is unavailable, log warning and allow request (fail open)
|
||||
logger.warning('Redis unavailable for rate limiting, allowing request')
|
||||
return
|
||||
|
||||
if user_id:
|
||||
# Rate limit by user_id (primary method)
|
||||
rate_limit_key = f'{key_prefix}:{user_id}'
|
||||
rate_limit_seconds = user_rate_limit_seconds
|
||||
else:
|
||||
# Fallback to IP address rate limiting
|
||||
client_ip = request.client.host if request.client else 'unknown'
|
||||
rate_limit_key = f'{key_prefix}:ip:{client_ip}'
|
||||
rate_limit_seconds = ip_rate_limit_seconds
|
||||
|
||||
# Try to set the key with expiration. If it already exists (nx=True fails),
|
||||
# it means the rate limit is active
|
||||
created = await redis.set(rate_limit_key, 1, nx=True, ex=rate_limit_seconds)
|
||||
|
||||
if not created:
|
||||
logger.info(
|
||||
f'Rate limit exceeded for {rate_limit_key}',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'ip': request.client.host if request.client else 'unknown',
|
||||
},
|
||||
)
|
||||
# Format error message based on duration
|
||||
if rate_limit_seconds < 60:
|
||||
wait_message = f'{rate_limit_seconds} seconds'
|
||||
elif rate_limit_seconds % 60 == 0:
|
||||
wait_message = f'{rate_limit_seconds // 60} minute{"s" if rate_limit_seconds // 60 != 1 else ""}'
|
||||
else:
|
||||
minutes = rate_limit_seconds // 60
|
||||
seconds = rate_limit_seconds % 60
|
||||
wait_message = f'{minutes} minute{"s" if minutes != 1 else ""} and {seconds} second{"s" if seconds != 1 else ""}'
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Too many requests. Please wait {wait_message} before trying again.',
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTPException (rate limit exceeded)
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log error but allow request (fail open) to avoid blocking legitimate users
|
||||
logger.warning(f'Error checking rate limit: {e}', exc_info=True)
|
||||
return
|
||||
@@ -17,10 +17,13 @@ from openhands.core.logger import openhands_logger as logger
|
||||
class ApiKeyStore:
|
||||
session_maker: sessionmaker
|
||||
|
||||
API_KEY_PREFIX = 'sk-oh-'
|
||||
|
||||
def generate_api_key(self, length: int = 32) -> str:
|
||||
"""Generate a random API key."""
|
||||
"""Generate a random API key with the sk-oh- prefix."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return f'{self.API_KEY_PREFIX}{random_part}'
|
||||
|
||||
def create_api_key(
|
||||
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
|
||||
@@ -57,9 +60,15 @@ class ApiKeyStore:
|
||||
return None
|
||||
|
||||
# Check if the key has expired
|
||||
if key_record.expires_at and key_record.expires_at < now:
|
||||
logger.info(f'API key has expired: {key_record.id}')
|
||||
return None
|
||||
if key_record.expires_at:
|
||||
# Handle timezone-naive datetime from database by assuming it's UTC
|
||||
expires_at = key_record.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
|
||||
if expires_at < now:
|
||||
logger.info(f'API key has expired: {key_record.id}')
|
||||
return None
|
||||
|
||||
# Update last_used_at timestamp
|
||||
session.execute(
|
||||
@@ -125,6 +134,33 @@ class ApiKeyStore:
|
||||
|
||||
return None
|
||||
|
||||
def retrieve_api_key_by_name(self, user_id: str, name: str) -> str | None:
|
||||
"""Retrieve an API key by name for a specific user."""
|
||||
with self.session_maker() as session:
|
||||
key_record = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
.first()
|
||||
)
|
||||
return key_record.key if key_record else None
|
||||
|
||||
def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
|
||||
"""Delete an API key by name for a specific user."""
|
||||
with self.session_maker() as session:
|
||||
key_record = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
session.delete(key_record)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> ApiKeyStore:
|
||||
"""Get an instance of the ApiKeyStore."""
|
||||
|
||||
@@ -19,17 +19,23 @@ GCP_REGION = os.environ.get('GCP_REGION')
|
||||
|
||||
POOL_SIZE = int(os.environ.get('DB_POOL_SIZE', '25'))
|
||||
MAX_OVERFLOW = int(os.environ.get('DB_MAX_OVERFLOW', '10'))
|
||||
POOL_RECYCLE = int(os.environ.get('DB_POOL_RECYCLE', '1800'))
|
||||
|
||||
# Initialize Cloud SQL Connector once at module level for GCP environments.
|
||||
_connector = None
|
||||
|
||||
|
||||
def _get_db_engine():
|
||||
if GCP_DB_INSTANCE: # GCP environments
|
||||
|
||||
def get_db_connection():
|
||||
global _connector
|
||||
from google.cloud.sql.connector import Connector
|
||||
|
||||
connector = Connector()
|
||||
if not _connector:
|
||||
_connector = Connector()
|
||||
instance_string = f'{GCP_PROJECT}:{GCP_REGION}:{GCP_DB_INSTANCE}'
|
||||
return connector.connect(
|
||||
return _connector.connect(
|
||||
instance_string, 'pg8000', user=DB_USER, password=DB_PASS, db=DB_NAME
|
||||
)
|
||||
|
||||
@@ -38,6 +44,7 @@ def _get_db_engine():
|
||||
creator=get_db_connection,
|
||||
pool_size=POOL_SIZE,
|
||||
max_overflow=MAX_OVERFLOW,
|
||||
pool_recycle=POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
else:
|
||||
@@ -48,6 +55,7 @@ def _get_db_engine():
|
||||
host_string,
|
||||
pool_size=POOL_SIZE,
|
||||
max_overflow=MAX_OVERFLOW,
|
||||
pool_recycle=POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
|
||||
109
enterprise/storage/device_code.py
Normal file
109
enterprise/storage/device_code.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Device code storage model for OAuth 2.0 Device Flow."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class DeviceCodeStatus(Enum):
|
||||
"""Status of a device code authorization request."""
|
||||
|
||||
PENDING = 'pending'
|
||||
AUTHORIZED = 'authorized'
|
||||
EXPIRED = 'expired'
|
||||
DENIED = 'denied'
|
||||
|
||||
|
||||
class DeviceCode(Base):
|
||||
"""Device code for OAuth 2.0 Device Flow.
|
||||
|
||||
This stores the device codes issued during the device authorization flow,
|
||||
along with their status and associated user information once authorized.
|
||||
"""
|
||||
|
||||
__tablename__ = 'device_codes'
|
||||
|
||||
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 = Column(String(255), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
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 = 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}')>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the device code has expired."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return now > self.expires_at
|
||||
|
||||
def is_pending(self) -> bool:
|
||||
"""Check if the device code is still pending authorization."""
|
||||
return self.status == DeviceCodeStatus.PENDING.value and not self.is_expired()
|
||||
|
||||
def is_authorized(self) -> bool:
|
||||
"""Check if the device code has been authorized."""
|
||||
return self.status == DeviceCodeStatus.AUTHORIZED.value
|
||||
|
||||
def authorize(self, user_id: str) -> None:
|
||||
"""Mark the device code as authorized."""
|
||||
self.status = DeviceCodeStatus.AUTHORIZED.value
|
||||
self.keycloak_user_id = user_id # Set the Keycloak user ID during authorization
|
||||
self.authorized_at = datetime.now(timezone.utc)
|
||||
|
||||
def deny(self) -> None:
|
||||
"""Mark the device code as denied."""
|
||||
self.status = DeviceCodeStatus.DENIED.value
|
||||
|
||||
def expire(self) -> None:
|
||||
"""Mark the device code as expired."""
|
||||
self.status = DeviceCodeStatus.EXPIRED.value
|
||||
|
||||
def check_rate_limit(self) -> tuple[bool, int]:
|
||||
"""Check if the client is polling too fast.
|
||||
|
||||
Returns:
|
||||
tuple: (is_too_fast, current_interval)
|
||||
- is_too_fast: True if client should receive slow_down error
|
||||
- current_interval: Current polling interval to use
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# If this is the first poll, allow it
|
||||
if self.last_poll_time is None:
|
||||
return False, self.current_interval
|
||||
|
||||
# Calculate time since last poll
|
||||
time_since_last_poll = (now - self.last_poll_time).total_seconds()
|
||||
|
||||
# Check if polling too fast
|
||||
if time_since_last_poll < self.current_interval:
|
||||
# Increase interval for slow_down (RFC 8628 section 3.5)
|
||||
new_interval = min(self.current_interval + 5, 60) # Cap at 60 seconds
|
||||
return True, new_interval
|
||||
|
||||
return False, self.current_interval
|
||||
|
||||
def update_poll_time(self, increase_interval: bool = False) -> None:
|
||||
"""Update the last poll time and optionally increase the interval.
|
||||
|
||||
Args:
|
||||
increase_interval: If True, increase the current interval for slow_down
|
||||
"""
|
||||
self.last_poll_time = datetime.now(timezone.utc)
|
||||
|
||||
if increase_interval:
|
||||
# Increase interval by 5 seconds, cap at 60 seconds (RFC 8628)
|
||||
self.current_interval = min(self.current_interval + 5, 60)
|
||||
167
enterprise/storage/device_code_store.py
Normal file
167
enterprise/storage/device_code_store.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Device code store for OAuth 2.0 Device Flow."""
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.device_code import DeviceCode
|
||||
|
||||
|
||||
class DeviceCodeStore:
|
||||
"""Store for managing OAuth 2.0 device codes."""
|
||||
|
||||
def __init__(self, session_maker):
|
||||
self.session_maker = session_maker
|
||||
|
||||
def generate_user_code(self) -> str:
|
||||
"""Generate a human-readable user code (8 characters, uppercase letters and digits)."""
|
||||
# Use a mix of uppercase letters and digits, avoiding confusing characters
|
||||
alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # No I, O, 0, 1
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
def generate_device_code(self) -> str:
|
||||
"""Generate a secure device code (128 characters)."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(128))
|
||||
|
||||
def create_device_code(
|
||||
self,
|
||||
expires_in: int = 600, # 10 minutes default
|
||||
max_attempts: int = 10,
|
||||
) -> DeviceCode:
|
||||
"""Create a new device code entry.
|
||||
|
||||
Uses database constraints to ensure uniqueness, avoiding TOCTOU race conditions.
|
||||
Retries on constraint violations until unique codes are generated.
|
||||
|
||||
Args:
|
||||
expires_in: Expiration time in seconds
|
||||
max_attempts: Maximum number of attempts to generate unique codes
|
||||
|
||||
Returns:
|
||||
The created DeviceCode instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If unable to generate unique codes after max_attempts
|
||||
"""
|
||||
for attempt in range(max_attempts):
|
||||
user_code = self.generate_user_code()
|
||||
device_code = self.generate_device_code()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
||||
|
||||
device_code_entry = DeviceCode(
|
||||
device_code=device_code,
|
||||
user_code=user_code,
|
||||
keycloak_user_id=None, # Will be set during authorization
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
try:
|
||||
with self.session_maker() as session:
|
||||
session.add(device_code_entry)
|
||||
session.commit()
|
||||
session.refresh(device_code_entry)
|
||||
session.expunge(device_code_entry) # Detach from session cleanly
|
||||
return device_code_entry
|
||||
except IntegrityError:
|
||||
# Constraint violation - codes already exist, retry with new codes
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
f'Failed to generate unique device codes after {max_attempts} attempts'
|
||||
)
|
||||
|
||||
def get_by_device_code(self, device_code: str) -> DeviceCode | None:
|
||||
"""Get device code entry by device code."""
|
||||
with self.session_maker() as session:
|
||||
result = (
|
||||
session.query(DeviceCode).filter_by(device_code=device_code).first()
|
||||
)
|
||||
if result:
|
||||
session.expunge(result) # Detach from session cleanly
|
||||
return result
|
||||
|
||||
def get_by_user_code(self, user_code: str) -> DeviceCode | None:
|
||||
"""Get device code entry by user code."""
|
||||
with self.session_maker() as session:
|
||||
result = session.query(DeviceCode).filter_by(user_code=user_code).first()
|
||||
if result:
|
||||
session.expunge(result) # Detach from session cleanly
|
||||
return result
|
||||
|
||||
def authorize_device_code(self, user_code: str, user_id: str) -> bool:
|
||||
"""Authorize a device code.
|
||||
|
||||
Args:
|
||||
user_code: The user code to authorize
|
||||
user_id: The user ID from Keycloak
|
||||
|
||||
Returns:
|
||||
True if authorization was successful, False otherwise
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
device_code_entry = (
|
||||
session.query(DeviceCode).filter_by(user_code=user_code).first()
|
||||
)
|
||||
|
||||
if not device_code_entry:
|
||||
return False
|
||||
|
||||
if not device_code_entry.is_pending():
|
||||
return False
|
||||
|
||||
device_code_entry.authorize(user_id)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
def deny_device_code(self, user_code: str) -> bool:
|
||||
"""Deny a device code authorization.
|
||||
|
||||
Args:
|
||||
user_code: The user code to deny
|
||||
|
||||
Returns:
|
||||
True if denial was successful, False otherwise
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
device_code_entry = (
|
||||
session.query(DeviceCode).filter_by(user_code=user_code).first()
|
||||
)
|
||||
|
||||
if not device_code_entry:
|
||||
return False
|
||||
|
||||
if not device_code_entry.is_pending():
|
||||
return False
|
||||
|
||||
device_code_entry.deny()
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
def update_poll_time(
|
||||
self, device_code: str, increase_interval: bool = False
|
||||
) -> bool:
|
||||
"""Update the poll time for a device code and optionally increase interval.
|
||||
|
||||
Args:
|
||||
device_code: The device code to update
|
||||
increase_interval: If True, increase the polling interval for slow_down
|
||||
|
||||
Returns:
|
||||
True if update was successful, False otherwise
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
device_code_entry = (
|
||||
session.query(DeviceCode).filter_by(device_code=device_code).first()
|
||||
)
|
||||
|
||||
if not device_code_entry:
|
||||
return False
|
||||
|
||||
device_code_entry.update_poll_time(increase_interval)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
@@ -220,6 +220,127 @@ class GitlabWebhookStore:
|
||||
return webhooks[0].webhook_secret
|
||||
return None
|
||||
|
||||
async def get_webhook_by_resource_only(
|
||||
self, resource_type: GitLabResourceType, resource_id: str
|
||||
) -> GitlabWebhook | None:
|
||||
"""Get a webhook by resource without filtering by user_id.
|
||||
|
||||
This allows any admin user in the organization to manage webhooks,
|
||||
not just the original installer.
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource (PROJECT or GROUP)
|
||||
resource_id: The ID of the resource
|
||||
|
||||
Returns:
|
||||
GitlabWebhook object if found, None otherwise
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
if resource_type == GitLabResourceType.PROJECT:
|
||||
query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.project_id == resource_id
|
||||
)
|
||||
else: # GROUP
|
||||
query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.group_id == resource_id
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
webhook = result.scalars().first()
|
||||
return webhook
|
||||
|
||||
async def get_webhooks_by_resources(
|
||||
self, project_ids: list[str], group_ids: list[str]
|
||||
) -> tuple[dict[str, GitlabWebhook], dict[str, GitlabWebhook]]:
|
||||
"""Bulk fetch webhooks for multiple resources.
|
||||
|
||||
This is more efficient than fetching one at a time in a loop.
|
||||
|
||||
Args:
|
||||
project_ids: List of project IDs to fetch
|
||||
group_ids: List of group IDs to fetch
|
||||
|
||||
Returns:
|
||||
Tuple of (project_webhook_map, group_webhook_map)
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
project_webhook_map = {}
|
||||
group_webhook_map = {}
|
||||
|
||||
# Fetch all project webhooks in one query
|
||||
if project_ids:
|
||||
project_query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.project_id.in_(project_ids)
|
||||
)
|
||||
result = await session.execute(project_query)
|
||||
project_webhooks = result.scalars().all()
|
||||
project_webhook_map = {wh.project_id: wh for wh in project_webhooks}
|
||||
|
||||
# Fetch all group webhooks in one query
|
||||
if group_ids:
|
||||
group_query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.group_id.in_(group_ids)
|
||||
)
|
||||
result = await session.execute(group_query)
|
||||
group_webhooks = result.scalars().all()
|
||||
group_webhook_map = {wh.group_id: wh for wh in group_webhooks}
|
||||
|
||||
return project_webhook_map, group_webhook_map
|
||||
|
||||
async def reset_webhook_for_reinstallation_by_resource(
|
||||
self, resource_type: GitLabResourceType, resource_id: str, updating_user_id: str
|
||||
) -> bool:
|
||||
"""Reset webhook for reinstallation without filtering by user_id.
|
||||
|
||||
This allows any admin user to reset webhooks, and updates the user_id
|
||||
to track who last modified it.
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource (PROJECT or GROUP)
|
||||
resource_id: The ID of the resource
|
||||
updating_user_id: The user ID performing the update (for audit purposes)
|
||||
|
||||
Returns:
|
||||
True if webhook was reset, False if not found
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
async with session.begin():
|
||||
if resource_type == GitLabResourceType.PROJECT:
|
||||
update_statement = (
|
||||
update(GitlabWebhook)
|
||||
.where(GitlabWebhook.project_id == resource_id)
|
||||
.values(
|
||||
webhook_exists=False,
|
||||
webhook_uuid=None,
|
||||
user_id=updating_user_id, # Update to track who modified it
|
||||
)
|
||||
)
|
||||
else: # GROUP
|
||||
update_statement = (
|
||||
update(GitlabWebhook)
|
||||
.where(GitlabWebhook.group_id == resource_id)
|
||||
.values(
|
||||
webhook_exists=False,
|
||||
webhook_uuid=None,
|
||||
user_id=updating_user_id, # Update to track who modified it
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(update_statement)
|
||||
rows_updated = result.rowcount
|
||||
|
||||
logger.info(
|
||||
'Reset webhook for reinstallation (organization-wide)',
|
||||
extra={
|
||||
'updating_user_id': updating_user_id,
|
||||
'resource_type': resource_type.value,
|
||||
'resource_id': resource_id,
|
||||
'rows_updated': rows_updated,
|
||||
},
|
||||
)
|
||||
|
||||
return rows_updated > 0
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> GitlabWebhookStore:
|
||||
"""Get an instance of the GitlabWebhookStore.
|
||||
|
||||
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, desc
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.database import session_maker
|
||||
from storage.openhands_pr import OpenhandsPR
|
||||
@@ -135,22 +136,29 @@ class OpenhandsPRStore:
|
||||
Returns:
|
||||
List of OpenhandsPR objects that need processing
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
unprocessed_prs = (
|
||||
session.query(OpenhandsPR)
|
||||
.filter(
|
||||
and_(
|
||||
~OpenhandsPR.processed,
|
||||
OpenhandsPR.process_attempts < max_retries,
|
||||
OpenhandsPR.provider == ProviderType.GITHUB.value,
|
||||
try:
|
||||
with self.session_maker() as session:
|
||||
unprocessed_prs = (
|
||||
session.query(OpenhandsPR)
|
||||
.filter(
|
||||
and_(
|
||||
~OpenhandsPR.processed,
|
||||
OpenhandsPR.process_attempts < max_retries,
|
||||
OpenhandsPR.provider == ProviderType.GITHUB.value,
|
||||
)
|
||||
)
|
||||
.order_by(desc(OpenhandsPR.updated_at))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
.order_by(desc(OpenhandsPR.updated_at))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return unprocessed_prs
|
||||
return unprocessed_prs
|
||||
except ProgrammingError as e:
|
||||
logger.warning(
|
||||
f'Could not query openhands_prs table - it may not exist yet. '
|
||||
f'Run database migrations first. Error: {e}'
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
|
||||
@@ -61,6 +61,7 @@ class SaasConversationStore(ConversationStore):
|
||||
kwargs.pop('context_window', None)
|
||||
kwargs.pop('per_turn_token', None)
|
||||
kwargs.pop('parent_conversation_id', None)
|
||||
kwargs.pop('public')
|
||||
|
||||
return ConversationMetadata(**kwargs)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from server.constants import (
|
||||
LITE_LLM_API_URL,
|
||||
LITE_LLM_TEAM_ID,
|
||||
REQUIRE_PAYMENT,
|
||||
USER_SETTINGS_VERSION_TO_MODEL,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.logger import logger
|
||||
@@ -94,6 +95,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
}
|
||||
self._decrypt_kwargs(kwargs)
|
||||
settings = Settings(**kwargs)
|
||||
|
||||
return settings
|
||||
|
||||
async def store(self, item: Settings):
|
||||
@@ -201,6 +203,53 @@ class SaasSettingsStore(SettingsStore):
|
||||
)
|
||||
return None
|
||||
|
||||
def _has_custom_settings(
|
||||
self, settings: Settings, old_user_version: int | None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has custom LLM settings that should be preserved.
|
||||
Returns True if user customized either model or base_url.
|
||||
|
||||
Args:
|
||||
settings: The user's current settings
|
||||
old_user_version: The user's old settings version, if any
|
||||
|
||||
Returns:
|
||||
True if user has custom settings, False if using old defaults
|
||||
"""
|
||||
# Normalize values
|
||||
user_model = (
|
||||
settings.llm_model.strip()
|
||||
if settings.llm_model and settings.llm_model.strip()
|
||||
else None
|
||||
)
|
||||
user_base_url = (
|
||||
settings.llm_base_url.strip()
|
||||
if settings.llm_base_url and settings.llm_base_url.strip()
|
||||
else None
|
||||
)
|
||||
|
||||
# Custom base_url = definitely custom settings (BYOK)
|
||||
if user_base_url and user_base_url != LITE_LLM_API_URL:
|
||||
return True
|
||||
|
||||
# No model set = using defaults
|
||||
if not user_model:
|
||||
return False
|
||||
|
||||
# Check if model matches old version's default
|
||||
if (
|
||||
old_user_version
|
||||
and old_user_version < CURRENT_USER_SETTINGS_VERSION
|
||||
and old_user_version in USER_SETTINGS_VERSION_TO_MODEL
|
||||
):
|
||||
old_default_base = USER_SETTINGS_VERSION_TO_MODEL[old_user_version]
|
||||
user_model_base = user_model.split('/')[-1]
|
||||
if user_model_base == old_default_base:
|
||||
return False # Matches old default
|
||||
|
||||
return True # Custom model
|
||||
|
||||
async def update_settings_with_litellm_default(
|
||||
self, settings: Settings
|
||||
) -> Settings | None:
|
||||
@@ -212,6 +261,17 @@ class SaasSettingsStore(SettingsStore):
|
||||
return None
|
||||
local_deploy = os.environ.get('LOCAL_DEPLOYMENT', None)
|
||||
key = LITE_LLM_API_KEY
|
||||
|
||||
# Check if user has custom settings
|
||||
has_custom = self._has_custom_settings(settings, settings.user_version)
|
||||
|
||||
# Determine model to use (needed before LiteLLM user creation)
|
||||
llm_model_to_use = (
|
||||
settings.llm_model
|
||||
if has_custom and settings.llm_model
|
||||
else get_default_litellm_model()
|
||||
)
|
||||
|
||||
if not local_deploy:
|
||||
# Get user info to add to litellm
|
||||
token_manager = TokenManager()
|
||||
@@ -225,14 +285,21 @@ class SaasSettingsStore(SettingsStore):
|
||||
'x-goog-api-key': LITE_LLM_API_KEY,
|
||||
},
|
||||
) as client:
|
||||
# Get the previous max budget to prevent accidental loss
|
||||
# In Litellm a get always succeeds, regardless of whether the user actually exists
|
||||
# Get the previous max budget to prevent accidental loss.
|
||||
#
|
||||
# LiteLLM v1.80+ returns 404 for non-existent users (previously returned empty user_info)
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/user/info?user_id={self.user_id}'
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
user_info = response_json.get('user_info') or {}
|
||||
user_info: dict
|
||||
if response.status_code == 404:
|
||||
# New user - doesn't exist in LiteLLM yet (v1.80+ behavior)
|
||||
user_info = {}
|
||||
else:
|
||||
# For any other status, use standard error handling
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
user_info = response_json.get('user_info') or {}
|
||||
logger.info(
|
||||
f'creating_litellm_user: {self.user_id}; prev_max_budget: {user_info.get("max_budget")}; prev_metadata: {user_info.get("metadata")}'
|
||||
)
|
||||
@@ -275,7 +342,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
|
||||
# Create the new litellm user
|
||||
response = await self._create_user_in_lite_llm(
|
||||
client, email, max_budget, spend
|
||||
client, email, max_budget, spend, llm_model_to_use
|
||||
)
|
||||
if not response.is_success:
|
||||
logger.warning(
|
||||
@@ -284,7 +351,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
)
|
||||
# Litellm insists on unique email addresses - it is possible the email address was registered with a different user.
|
||||
response = await self._create_user_in_lite_llm(
|
||||
client, None, max_budget, spend
|
||||
client, None, max_budget, spend, llm_model_to_use
|
||||
)
|
||||
|
||||
# User failed to create in litellm - this is an unforseen error state...
|
||||
@@ -310,11 +377,17 @@ class SaasSettingsStore(SettingsStore):
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
|
||||
if has_custom:
|
||||
settings.llm_model = settings.llm_model or get_default_litellm_model()
|
||||
settings.llm_base_url = settings.llm_base_url or LITE_LLM_API_URL
|
||||
settings.llm_api_key = settings.llm_api_key or SecretStr(key)
|
||||
else:
|
||||
settings.llm_model = get_default_litellm_model()
|
||||
settings.llm_base_url = LITE_LLM_API_URL
|
||||
settings.llm_api_key = SecretStr(key)
|
||||
|
||||
settings.agent = 'CodeActAgent'
|
||||
# Use the model corresponding to the current user settings version
|
||||
settings.llm_model = get_default_litellm_model()
|
||||
settings.llm_api_key = SecretStr(key)
|
||||
settings.llm_base_url = LITE_LLM_API_URL
|
||||
|
||||
return settings
|
||||
|
||||
@classmethod
|
||||
@@ -397,7 +470,12 @@ class SaasSettingsStore(SettingsStore):
|
||||
)
|
||||
|
||||
async def _create_user_in_lite_llm(
|
||||
self, client: httpx.AsyncClient, email: str | None, max_budget: int, spend: int
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
email: str | None,
|
||||
max_budget: int,
|
||||
spend: int,
|
||||
llm_model: str,
|
||||
):
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/user/new',
|
||||
@@ -412,7 +490,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
'send_invite_email': False,
|
||||
'metadata': {
|
||||
'version': CURRENT_USER_SETTINGS_VERSION,
|
||||
'model': get_default_litellm_model(),
|
||||
'model': llm_model,
|
||||
},
|
||||
'key_alias': f'OpenHands Cloud - user {self.user_id}',
|
||||
},
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import asyncio
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from sqlalchemy import text
|
||||
from storage.database import a_session_maker
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
@@ -12,20 +18,6 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.service_types import GitService
|
||||
|
||||
CHUNK_SIZE = 100
|
||||
WEBHOOK_NAME = 'OpenHands Resolver'
|
||||
SCOPES: list[str] = [
|
||||
'note_events',
|
||||
'merge_requests_events',
|
||||
'confidential_issues_events',
|
||||
'issues_events',
|
||||
'confidential_note_events',
|
||||
'job_events',
|
||||
'pipeline_events',
|
||||
]
|
||||
|
||||
|
||||
class BreakLoopException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VerifyWebhookStatus:
|
||||
@@ -41,77 +33,6 @@ class VerifyWebhookStatus:
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_resource_exists(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
"""
|
||||
Check if the GitLab resource still exists
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
does_resource_exist, status = await gitlab_service.check_resource_exists(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does resource exists',
|
||||
extra={
|
||||
'does_resource_exist': does_resource_exist,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_user_has_admin_acccess_to_resource(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
"""
|
||||
Check is user still has permission to resource
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
(
|
||||
is_user_admin_of_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Is user admin',
|
||||
extra={
|
||||
'is_user_admin': is_user_admin_of_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
if not is_user_admin_of_resource:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_webhook_already_exists_on_resource(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
@@ -160,23 +81,8 @@ class VerifyWebhookStatus:
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
await self.check_if_resource_exists(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
await self.check_if_user_has_admin_acccess_to_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
await self.check_if_webhook_already_exists_on_resource(
|
||||
# Use the standalone function
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
@@ -195,51 +101,15 @@ class VerifyWebhookStatus:
|
||||
"""
|
||||
Install webhook on resource
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
|
||||
webhook_uuid = f'{str(uuid4())}'
|
||||
|
||||
webhook_id, status = await gitlab_service.install_webhook(
|
||||
# Use the standalone function
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_name=WEBHOOK_NAME,
|
||||
webhook_url=GITLAB_WEBHOOK_URL,
|
||||
webhook_secret=webhook_secret,
|
||||
webhook_uuid=webhook_uuid,
|
||||
scopes=SCOPES,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Creating new webhook',
|
||||
extra={
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
|
||||
if webhook_id:
|
||||
await webhook_store.update_webhook(
|
||||
webhook=webhook,
|
||||
update_fields={
|
||||
'webhook_secret': webhook_secret,
|
||||
'webhook_exists': True, # webhook was created
|
||||
'webhook_url': GITLAB_WEBHOOK_URL,
|
||||
'scopes': SCOPES,
|
||||
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
|
||||
)
|
||||
|
||||
async def install_webhooks(self):
|
||||
"""
|
||||
Periodically check the conditions for installing a webhook on resource as valid
|
||||
@@ -258,6 +128,25 @@ class VerifyWebhookStatus:
|
||||
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
# Check if the table exists before proceeding
|
||||
# This handles cases where the CronJob runs before database migrations complete
|
||||
async with a_session_maker() as session:
|
||||
query = text("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'gitlab_webhook'
|
||||
)
|
||||
""")
|
||||
result = await session.execute(query)
|
||||
table_exists = result.scalar() or False
|
||||
|
||||
if not table_exists:
|
||||
logger.info(
|
||||
'gitlab_webhook table does not exist yet, '
|
||||
'waiting for database migrations to complete'
|
||||
)
|
||||
return
|
||||
|
||||
# Get an instance of the webhook store
|
||||
webhook_store = await GitlabWebhookStore.get_instance()
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from storage.base import Base
|
||||
# Anything not loaded here may not have a table created for it.
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.conversation_work import ConversationWork
|
||||
from storage.device_code import DeviceCode # noqa: F401
|
||||
from storage.feedback import Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
|
||||
204
enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py
Normal file
204
enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Unit tests for SaaSGitLabService."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitlab_service():
|
||||
"""Create a SaaSGitLabService instance for testing."""
|
||||
return SaaSGitLabService(external_auth_id='test_user_id')
|
||||
|
||||
|
||||
class TestGetUserResourcesWithAdminAccess:
|
||||
"""Test cases for get_user_resources_with_admin_access method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_single_page_projects_and_groups(self, gitlab_service):
|
||||
"""Test fetching resources when all data fits in a single page."""
|
||||
# Arrange
|
||||
mock_projects = [
|
||||
{'id': 1, 'name': 'Project 1'},
|
||||
{'id': 2, 'name': 'Project 2'},
|
||||
]
|
||||
mock_groups = [
|
||||
{'id': 10, 'name': 'Group 1'},
|
||||
]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
# First call for projects, second for groups
|
||||
mock_request.side_effect = [
|
||||
(mock_projects, {'Link': ''}), # No next page
|
||||
(mock_groups, {'Link': ''}), # No next page
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 2
|
||||
assert len(groups) == 1
|
||||
assert projects[0]['id'] == 1
|
||||
assert projects[1]['id'] == 2
|
||||
assert groups[0]['id'] == 10
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_multiple_pages_projects(self, gitlab_service):
|
||||
"""Test fetching projects across multiple pages."""
|
||||
# Arrange
|
||||
page1_projects = [{'id': i, 'name': f'Project {i}'} for i in range(1, 101)]
|
||||
page2_projects = [{'id': i, 'name': f'Project {i}'} for i in range(101, 151)]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
(page1_projects, {'Link': '<url>; rel="next"'}), # Has next page
|
||||
(page2_projects, {'Link': ''}), # Last page
|
||||
([], {'Link': ''}), # Groups (empty)
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 150
|
||||
assert len(groups) == 0
|
||||
assert mock_request.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_multiple_pages_groups(self, gitlab_service):
|
||||
"""Test fetching groups across multiple pages."""
|
||||
# Arrange
|
||||
page1_groups = [{'id': i, 'name': f'Group {i}'} for i in range(1, 101)]
|
||||
page2_groups = [{'id': i, 'name': f'Group {i}'} for i in range(101, 151)]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects (empty)
|
||||
(page1_groups, {'Link': '<url>; rel="next"'}), # Has next page
|
||||
(page2_groups, {'Link': ''}), # Last page
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert len(groups) == 150
|
||||
assert mock_request.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_empty_response(self, gitlab_service):
|
||||
"""Test when user has no projects or groups with admin access."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # No projects
|
||||
([], {'Link': ''}), # No groups
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert len(groups) == 0
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_uses_correct_params_for_projects(self, gitlab_service):
|
||||
"""Test that projects API is called with correct parameters."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Check first call (projects)
|
||||
first_call = mock_request.call_args_list[0]
|
||||
assert 'projects' in first_call[0][0]
|
||||
assert first_call[0][1]['membership'] == 1
|
||||
assert first_call[0][1]['min_access_level'] == 40
|
||||
assert first_call[0][1]['per_page'] == '100'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_uses_correct_params_for_groups(self, gitlab_service):
|
||||
"""Test that groups API is called with correct parameters."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Check second call (groups)
|
||||
second_call = mock_request.call_args_list[1]
|
||||
assert 'groups' in second_call[0][0]
|
||||
assert second_call[0][1]['min_access_level'] == 40
|
||||
assert second_call[0][1]['top_level_only'] == 'true'
|
||||
assert second_call[0][1]['per_page'] == '100'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_handles_api_error_gracefully(self, gitlab_service):
|
||||
"""Test that API errors are handled gracefully and don't crash."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
# First call succeeds, second call fails
|
||||
mock_request.side_effect = [
|
||||
([{'id': 1, 'name': 'Project 1'}], {'Link': ''}),
|
||||
Exception('API Error'),
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Should return what was fetched before the error
|
||||
assert len(projects) == 1
|
||||
assert len(groups) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_stops_on_empty_response(self, gitlab_service):
|
||||
"""Test that pagination stops when API returns empty response."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
(None, {'Link': ''}), # Empty response stops pagination
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert mock_request.call_count == 2 # Should not continue pagination
|
||||
@@ -18,7 +18,11 @@ from integrations.jira.jira_view import (
|
||||
from integrations.models import Message, SourceType
|
||||
|
||||
from openhands.integrations.service_types import ProviderType, Repository
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
|
||||
|
||||
class TestJiraManagerInit:
|
||||
@@ -732,6 +736,32 @@ class TestStartJob:
|
||||
call_args = jira_manager.send_message.call_args[0]
|
||||
assert 'valid LLM API key' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_session_expired_error(
|
||||
self, jira_manager, sample_jira_workspace
|
||||
):
|
||||
"""Test job start with session expired error."""
|
||||
mock_view = MagicMock(spec=JiraNewConversationView)
|
||||
mock_view.jira_user = MagicMock()
|
||||
mock_view.jira_user.keycloak_user_id = 'test_user'
|
||||
mock_view.job_context = MagicMock()
|
||||
mock_view.job_context.issue_key = 'PROJ-123'
|
||||
mock_view.jira_workspace = sample_jira_workspace
|
||||
mock_view.create_or_update_conversation = AsyncMock(
|
||||
side_effect=SessionExpiredError('Session expired')
|
||||
)
|
||||
|
||||
jira_manager.send_message = AsyncMock()
|
||||
jira_manager.token_manager.decrypt_text.return_value = 'decrypted_key'
|
||||
|
||||
await jira_manager.start_job(mock_view)
|
||||
|
||||
# Should send error message about session expired
|
||||
jira_manager.send_message.assert_called_once()
|
||||
call_args = jira_manager.send_message.call_args[0]
|
||||
assert 'session has expired' in call_args[0].message
|
||||
assert 'login again' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_unexpected_error(
|
||||
self, jira_manager, sample_jira_workspace
|
||||
|
||||
@@ -18,7 +18,11 @@ from integrations.jira_dc.jira_dc_view import (
|
||||
from integrations.models import Message, SourceType
|
||||
|
||||
from openhands.integrations.service_types import ProviderType, Repository
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
|
||||
|
||||
class TestJiraDcManagerInit:
|
||||
@@ -761,6 +765,32 @@ class TestStartJob:
|
||||
call_args = jira_dc_manager.send_message.call_args[0]
|
||||
assert 'valid LLM API key' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_session_expired_error(
|
||||
self, jira_dc_manager, sample_jira_dc_workspace
|
||||
):
|
||||
"""Test job start with session expired error."""
|
||||
mock_view = MagicMock(spec=JiraDcNewConversationView)
|
||||
mock_view.jira_dc_user = MagicMock()
|
||||
mock_view.jira_dc_user.keycloak_user_id = 'test_user'
|
||||
mock_view.job_context = MagicMock()
|
||||
mock_view.job_context.issue_key = 'PROJ-123'
|
||||
mock_view.jira_dc_workspace = sample_jira_dc_workspace
|
||||
mock_view.create_or_update_conversation = AsyncMock(
|
||||
side_effect=SessionExpiredError('Session expired')
|
||||
)
|
||||
|
||||
jira_dc_manager.send_message = AsyncMock()
|
||||
jira_dc_manager.token_manager.decrypt_text.return_value = 'decrypted_key'
|
||||
|
||||
await jira_dc_manager.start_job(mock_view)
|
||||
|
||||
# Should send error message about session expired
|
||||
jira_dc_manager.send_message.assert_called_once()
|
||||
call_args = jira_dc_manager.send_message.call_args[0]
|
||||
assert 'session has expired' in call_args[0].message
|
||||
assert 'login again' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_unexpected_error(
|
||||
self, jira_dc_manager, sample_jira_dc_workspace
|
||||
|
||||
@@ -18,7 +18,11 @@ from integrations.linear.linear_view import (
|
||||
from integrations.models import Message, SourceType
|
||||
|
||||
from openhands.integrations.service_types import ProviderType, Repository
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
|
||||
|
||||
class TestLinearManagerInit:
|
||||
@@ -826,6 +830,33 @@ class TestStartJob:
|
||||
call_args = linear_manager.send_message.call_args[0]
|
||||
assert 'valid LLM API key' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_session_expired_error(
|
||||
self, linear_manager, sample_linear_workspace
|
||||
):
|
||||
"""Test job start with session expired error."""
|
||||
mock_view = MagicMock(spec=LinearNewConversationView)
|
||||
mock_view.linear_user = MagicMock()
|
||||
mock_view.linear_user.keycloak_user_id = 'test_user'
|
||||
mock_view.job_context = MagicMock()
|
||||
mock_view.job_context.issue_key = 'TEST-123'
|
||||
mock_view.job_context.issue_id = 'issue_id'
|
||||
mock_view.linear_workspace = sample_linear_workspace
|
||||
mock_view.create_or_update_conversation = AsyncMock(
|
||||
side_effect=SessionExpiredError('Session expired')
|
||||
)
|
||||
|
||||
linear_manager.send_message = AsyncMock()
|
||||
linear_manager.token_manager.decrypt_text.return_value = 'decrypted_key'
|
||||
|
||||
await linear_manager.start_job(mock_view)
|
||||
|
||||
# Should send error message about session expired
|
||||
linear_manager.send_message.assert_called_once()
|
||||
call_args = linear_manager.send_message.call_args[0]
|
||||
assert 'session has expired' in call_args[0].message
|
||||
assert 'login again' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_unexpected_error(
|
||||
self, linear_manager, sample_linear_workspace
|
||||
|
||||
133
enterprise/tests/unit/integrations/test_resolver_context.py
Normal file
133
enterprise/tests/unit/integrations/test_resolver_context.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Test for ResolverUserContext get_secrets conversion logic.
|
||||
|
||||
This test focuses on testing the actual ResolverUserContext implementation.
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from enterprise.integrations.resolver_context import ResolverUserContext
|
||||
|
||||
# Import the real classes we want to test
|
||||
from openhands.integrations.provider import CustomSecret
|
||||
|
||||
# Import the SDK types we need for testing
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_saas_user_auth():
|
||||
"""Mock SaasUserAuth for testing."""
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver_context(mock_saas_user_auth):
|
||||
"""Create a ResolverUserContext instance for testing."""
|
||||
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
|
||||
|
||||
|
||||
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
|
||||
"""Helper to create CustomSecret instances."""
|
||||
return CustomSecret(secret=SecretStr(value), description=description)
|
||||
|
||||
|
||||
def create_secrets(custom_secrets_dict: dict[str, CustomSecret]) -> Secrets:
|
||||
"""Helper to create Secrets instances."""
|
||||
return Secrets(custom_secrets=MappingProxyType(custom_secrets_dict))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_secrets_converts_custom_to_static(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that get_secrets correctly converts CustomSecret objects to StaticSecret objects."""
|
||||
# Arrange
|
||||
secrets = create_secrets(
|
||||
{
|
||||
'TEST_SECRET_1': create_custom_secret('secret_value_1'),
|
||||
'TEST_SECRET_2': create_custom_secret('secret_value_2'),
|
||||
}
|
||||
)
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(secret, StaticSecret) for secret in result.values())
|
||||
assert result['TEST_SECRET_1'].value.get_secret_value() == 'secret_value_1'
|
||||
assert result['TEST_SECRET_2'].value.get_secret_value() == 'secret_value_2'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_secrets_with_special_characters(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that secret values with special characters are preserved during conversion."""
|
||||
# Arrange
|
||||
special_value = 'very_secret_password_123!@#$%^&*()'
|
||||
secrets = create_secrets({'SPECIAL_SECRET': create_custom_secret(special_value)})
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert isinstance(result['SPECIAL_SECRET'], StaticSecret)
|
||||
assert result['SPECIAL_SECRET'].value.get_secret_value() == special_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'secrets_input,expected_result',
|
||||
[
|
||||
(None, {}), # No secrets available
|
||||
(create_secrets({}), {}), # Empty custom secrets
|
||||
],
|
||||
)
|
||||
async def test_get_secrets_empty_cases(
|
||||
resolver_context, mock_saas_user_auth, secrets_input, expected_result
|
||||
):
|
||||
"""Test that get_secrets handles empty cases correctly."""
|
||||
# Arrange
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets_input
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
def test_static_secret_is_valid_secret_source():
|
||||
"""Test that StaticSecret is a valid SecretSource for SDK validation."""
|
||||
# Arrange & Act
|
||||
static_secret = StaticSecret(value='test_secret_123')
|
||||
|
||||
# Assert
|
||||
assert isinstance(static_secret, StaticSecret)
|
||||
assert isinstance(static_secret, SecretSource)
|
||||
assert static_secret.value.get_secret_value() == 'test_secret_123'
|
||||
|
||||
|
||||
def test_custom_to_static_conversion():
|
||||
"""Test the complete conversion flow from CustomSecret to StaticSecret."""
|
||||
# Arrange
|
||||
secret_value = 'conversion_test_secret'
|
||||
custom_secret = create_custom_secret(secret_value, 'Conversion test')
|
||||
|
||||
# Act - simulate the conversion logic from the actual method
|
||||
extracted_value = custom_secret.secret.get_secret_value()
|
||||
static_secret = StaticSecret(value=extracted_value)
|
||||
|
||||
# Assert
|
||||
assert isinstance(static_secret, StaticSecret)
|
||||
assert isinstance(static_secret, SecretSource)
|
||||
assert static_secret.value.get_secret_value() == secret_value
|
||||
@@ -1,7 +1,14 @@
|
||||
"""Tests for enterprise integrations utils module."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from integrations.utils import get_summary_for_agent_state
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
append_conversation_footer,
|
||||
get_session_expired_message,
|
||||
get_summary_for_agent_state,
|
||||
)
|
||||
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
@@ -157,3 +164,200 @@ class TestGetSummaryForAgentState:
|
||||
assert 'try again later' in result.lower()
|
||||
# RATE_LIMITED doesn't include conversation link in response
|
||||
assert self.conversation_link not in result
|
||||
|
||||
|
||||
class TestGetSessionExpiredMessage:
|
||||
"""Test cases for get_session_expired_message function."""
|
||||
|
||||
def test_message_with_username_contains_at_prefix(self):
|
||||
"""Test that the message contains the username with @ prefix."""
|
||||
result = get_session_expired_message('testuser')
|
||||
assert '@testuser' in result
|
||||
|
||||
def test_message_with_username_contains_session_expired_text(self):
|
||||
"""Test that the message contains session expired text."""
|
||||
result = get_session_expired_message('testuser')
|
||||
assert 'session has expired' in result
|
||||
|
||||
def test_message_with_username_contains_login_instruction(self):
|
||||
"""Test that the message contains login instruction."""
|
||||
result = get_session_expired_message('testuser')
|
||||
assert 'login again' in result
|
||||
|
||||
def test_message_with_username_contains_host_url(self):
|
||||
"""Test that the message contains the OpenHands Cloud URL."""
|
||||
result = get_session_expired_message('testuser')
|
||||
assert HOST_URL in result
|
||||
assert 'OpenHands Cloud' in result
|
||||
|
||||
def test_different_usernames(self):
|
||||
"""Test that different usernames produce different messages."""
|
||||
result1 = get_session_expired_message('user1')
|
||||
result2 = get_session_expired_message('user2')
|
||||
assert '@user1' in result1
|
||||
assert '@user2' in result2
|
||||
assert '@user1' not in result2
|
||||
assert '@user2' not in result1
|
||||
|
||||
def test_message_without_username_contains_session_expired_text(self):
|
||||
"""Test that the message without username contains session expired text."""
|
||||
result = get_session_expired_message()
|
||||
assert 'session has expired' in result
|
||||
|
||||
def test_message_without_username_contains_login_instruction(self):
|
||||
"""Test that the message without username contains login instruction."""
|
||||
result = get_session_expired_message()
|
||||
assert 'login again' in result
|
||||
|
||||
def test_message_without_username_contains_host_url(self):
|
||||
"""Test that the message without username contains the OpenHands Cloud URL."""
|
||||
result = get_session_expired_message()
|
||||
assert HOST_URL in result
|
||||
assert 'OpenHands Cloud' in result
|
||||
|
||||
def test_message_without_username_does_not_contain_at_prefix(self):
|
||||
"""Test that the message without username does not contain @ prefix."""
|
||||
result = get_session_expired_message()
|
||||
assert not result.startswith('@')
|
||||
assert 'Your session' in result
|
||||
|
||||
def test_message_with_none_username(self):
|
||||
"""Test that passing None explicitly works the same as no argument."""
|
||||
result = get_session_expired_message(None)
|
||||
assert not result.startswith('@')
|
||||
assert 'Your session' in result
|
||||
|
||||
|
||||
class TestAppendConversationFooter:
|
||||
"""Test cases for append_conversation_footer function."""
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_appends_footer_with_markdown_link(self):
|
||||
"""Test that footer is appended with correct markdown link format."""
|
||||
# Arrange
|
||||
message = 'This is a test message'
|
||||
conversation_id = 'test-conv-123'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert result.startswith(message)
|
||||
assert (
|
||||
'[View full conversation](https://example.com/conversations/test-conv-123)'
|
||||
in result
|
||||
)
|
||||
assert result.endswith(
|
||||
'[View full conversation](https://example.com/conversations/test-conv-123)'
|
||||
)
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_footer_does_not_contain_html_tags(self):
|
||||
"""Test that footer does not contain HTML tags like <sub>."""
|
||||
# Arrange
|
||||
message = 'Test message'
|
||||
conversation_id = 'test-conv-456'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert '<sub>' not in result
|
||||
assert '</sub>' not in result
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_footer_format_with_newlines(self):
|
||||
"""Test that footer is properly separated with newlines."""
|
||||
# Arrange
|
||||
message = 'Original message content'
|
||||
conversation_id = 'test-conv-789'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert (
|
||||
result
|
||||
== 'Original message content\n\n[View full conversation](https://example.com/conversations/test-conv-789)'
|
||||
)
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_empty_message_still_appends_footer(self):
|
||||
"""Test that footer is appended even when message is empty."""
|
||||
# Arrange
|
||||
message = ''
|
||||
conversation_id = 'empty-msg-conv'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert result.startswith('\n\n')
|
||||
assert (
|
||||
'[View full conversation](https://example.com/conversations/empty-msg-conv)'
|
||||
in result
|
||||
)
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_conversation_id_with_special_characters(self):
|
||||
"""Test that footer handles conversation IDs with special characters."""
|
||||
# Arrange
|
||||
message = 'Test message'
|
||||
conversation_id = 'conv-123_abc-456'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
expected_url = 'https://example.com/conversations/conv-123_abc-456'
|
||||
assert expected_url in result
|
||||
assert '[View full conversation]' in result
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_multiline_message_preserves_content(self):
|
||||
"""Test that multiline messages are preserved correctly."""
|
||||
# Arrange
|
||||
message = 'Line 1\nLine 2\nLine 3'
|
||||
conversation_id = 'multiline-conv'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert result.startswith('Line 1\nLine 2\nLine 3')
|
||||
assert '\n\n[View full conversation]' in result
|
||||
assert message in result
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_footer_contains_only_markdown_syntax(self):
|
||||
"""Test that footer uses only markdown syntax, not HTML."""
|
||||
# Arrange
|
||||
message = 'Test message'
|
||||
conversation_id = 'markdown-test'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
footer_part = result[len(message) :]
|
||||
# Should only contain markdown link syntax: [text](url)
|
||||
assert footer_part.startswith('\n\n[')
|
||||
assert '](' in footer_part
|
||||
assert footer_part.endswith(')')
|
||||
# Should not contain any HTML tags (specifically <sub> tags that were removed)
|
||||
assert '<sub>' not in footer_part
|
||||
assert '</sub>' not in footer_part
|
||||
|
||||
330
enterprise/tests/unit/server/routes/test_api_keys.py
Normal file
330
enterprise/tests/unit/server/routes/test_api_keys.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Unit tests for API keys routes, focusing on BYOR key validation and retrieval."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from server.routes.api_keys import (
|
||||
get_llm_api_key_for_byor,
|
||||
verify_byor_key_in_litellm,
|
||||
)
|
||||
|
||||
|
||||
class TestVerifyByorKeyInLitellm:
|
||||
"""Test the verify_byor_key_in_litellm function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com')
|
||||
@patch('server.routes.api_keys.httpx.AsyncClient')
|
||||
async def test_verify_valid_key_returns_true(self, mock_client_class):
|
||||
"""Test that a valid key (200 response) returns True."""
|
||||
# Arrange
|
||||
byor_key = 'sk-valid-key-123'
|
||||
user_id = 'user-123'
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.is_success = True
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Act
|
||||
result = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_client.get.assert_called_once_with(
|
||||
'https://litellm.example.com/v1/models',
|
||||
headers={'Authorization': f'Bearer {byor_key}'},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com')
|
||||
@patch('server.routes.api_keys.httpx.AsyncClient')
|
||||
async def test_verify_invalid_key_401_returns_false(self, mock_client_class):
|
||||
"""Test that an invalid key (401 response) returns False."""
|
||||
# Arrange
|
||||
byor_key = 'sk-invalid-key-123'
|
||||
user_id = 'user-123'
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Act
|
||||
result = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com')
|
||||
@patch('server.routes.api_keys.httpx.AsyncClient')
|
||||
async def test_verify_invalid_key_403_returns_false(self, mock_client_class):
|
||||
"""Test that an invalid key (403 response) returns False."""
|
||||
# Arrange
|
||||
byor_key = 'sk-forbidden-key-123'
|
||||
user_id = 'user-123'
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 403
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Act
|
||||
result = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com')
|
||||
@patch('server.routes.api_keys.httpx.AsyncClient')
|
||||
async def test_verify_server_error_returns_false(self, mock_client_class):
|
||||
"""Test that a server error (500) returns False to ensure key validity."""
|
||||
# Arrange
|
||||
byor_key = 'sk-key-123'
|
||||
user_id = 'user-123'
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.is_success = False
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Act
|
||||
result = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com')
|
||||
@patch('server.routes.api_keys.httpx.AsyncClient')
|
||||
async def test_verify_timeout_returns_false(self, mock_client_class):
|
||||
"""Test that a timeout returns False to ensure key validity."""
|
||||
# Arrange
|
||||
byor_key = 'sk-key-123'
|
||||
user_id = 'user-123'
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get.side_effect = httpx.TimeoutException('Request timed out')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Act
|
||||
result = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com')
|
||||
@patch('server.routes.api_keys.httpx.AsyncClient')
|
||||
async def test_verify_network_error_returns_false(self, mock_client_class):
|
||||
"""Test that a network error returns False to ensure key validity."""
|
||||
# Arrange
|
||||
byor_key = 'sk-key-123'
|
||||
user_id = 'user-123'
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get.side_effect = httpx.NetworkError('Network error')
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Act
|
||||
result = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.LITE_LLM_API_URL', None)
|
||||
async def test_verify_missing_api_url_returns_false(self):
|
||||
"""Test that missing LITE_LLM_API_URL returns False."""
|
||||
# Arrange
|
||||
byor_key = 'sk-key-123'
|
||||
user_id = 'user-123'
|
||||
|
||||
# Act
|
||||
result = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com')
|
||||
async def test_verify_empty_key_returns_false(self):
|
||||
"""Test that empty key returns False."""
|
||||
# Arrange
|
||||
byor_key = ''
|
||||
user_id = 'user-123'
|
||||
|
||||
# Act
|
||||
result = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestGetLlmApiKeyForByor:
|
||||
"""Test the get_llm_api_key_for_byor endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.store_byor_key_in_db')
|
||||
@patch('server.routes.api_keys.generate_byor_key')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_no_key_in_database_generates_new(
|
||||
self, mock_get_key, mock_generate_key, mock_store_key
|
||||
):
|
||||
"""Test that when no key exists in database, a new one is generated."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
new_key = 'sk-new-generated-key'
|
||||
mock_get_key.return_value = None
|
||||
mock_generate_key.return_value = new_key
|
||||
mock_store_key.return_value = None
|
||||
|
||||
# Act
|
||||
result = await get_llm_api_key_for_byor(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result == {'key': new_key}
|
||||
mock_get_key.assert_called_once_with(user_id)
|
||||
mock_generate_key.assert_called_once_with(user_id)
|
||||
mock_store_key.assert_called_once_with(user_id, new_key)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.verify_byor_key_in_litellm')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_valid_key_in_database_returns_key(
|
||||
self, mock_get_key, mock_verify_key
|
||||
):
|
||||
"""Test that when a valid key exists in database, it is returned."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
existing_key = 'sk-existing-valid-key'
|
||||
mock_get_key.return_value = existing_key
|
||||
mock_verify_key.return_value = True
|
||||
|
||||
# Act
|
||||
result = await get_llm_api_key_for_byor(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result == {'key': existing_key}
|
||||
mock_get_key.assert_called_once_with(user_id)
|
||||
mock_verify_key.assert_called_once_with(existing_key, user_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.store_byor_key_in_db')
|
||||
@patch('server.routes.api_keys.generate_byor_key')
|
||||
@patch('server.routes.api_keys.delete_byor_key_from_litellm')
|
||||
@patch('server.routes.api_keys.verify_byor_key_in_litellm')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_invalid_key_in_database_regenerates(
|
||||
self,
|
||||
mock_get_key,
|
||||
mock_verify_key,
|
||||
mock_delete_key,
|
||||
mock_generate_key,
|
||||
mock_store_key,
|
||||
):
|
||||
"""Test that when an invalid key exists in database, it is regenerated."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
invalid_key = 'sk-invalid-key'
|
||||
new_key = 'sk-new-generated-key'
|
||||
mock_get_key.return_value = invalid_key
|
||||
mock_verify_key.return_value = False
|
||||
mock_delete_key.return_value = True
|
||||
mock_generate_key.return_value = new_key
|
||||
mock_store_key.return_value = None
|
||||
|
||||
# Act
|
||||
result = await get_llm_api_key_for_byor(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result == {'key': new_key}
|
||||
mock_get_key.assert_called_once_with(user_id)
|
||||
mock_verify_key.assert_called_once_with(invalid_key, user_id)
|
||||
mock_delete_key.assert_called_once_with(user_id, invalid_key)
|
||||
mock_generate_key.assert_called_once_with(user_id)
|
||||
mock_store_key.assert_called_once_with(user_id, new_key)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.store_byor_key_in_db')
|
||||
@patch('server.routes.api_keys.generate_byor_key')
|
||||
@patch('server.routes.api_keys.delete_byor_key_from_litellm')
|
||||
@patch('server.routes.api_keys.verify_byor_key_in_litellm')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_invalid_key_deletion_failure_still_regenerates(
|
||||
self,
|
||||
mock_get_key,
|
||||
mock_verify_key,
|
||||
mock_delete_key,
|
||||
mock_generate_key,
|
||||
mock_store_key,
|
||||
):
|
||||
"""Test that even if deletion fails, regeneration still proceeds."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
invalid_key = 'sk-invalid-key'
|
||||
new_key = 'sk-new-generated-key'
|
||||
mock_get_key.return_value = invalid_key
|
||||
mock_verify_key.return_value = False
|
||||
mock_delete_key.return_value = False # Deletion fails
|
||||
mock_generate_key.return_value = new_key
|
||||
mock_store_key.return_value = None
|
||||
|
||||
# Act
|
||||
result = await get_llm_api_key_for_byor(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result == {'key': new_key}
|
||||
mock_delete_key.assert_called_once_with(user_id, invalid_key)
|
||||
mock_generate_key.assert_called_once_with(user_id)
|
||||
mock_store_key.assert_called_once_with(user_id, new_key)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.generate_byor_key')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_key_generation_failure_raises_exception(
|
||||
self, mock_get_key, mock_generate_key
|
||||
):
|
||||
"""Test that when key generation fails, an HTTPException is raised."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_get_key.return_value = None
|
||||
mock_generate_key.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_llm_api_key_for_byor(user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to generate new BYOR LLM API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_database_error_raises_exception(self, mock_get_key):
|
||||
"""Test that database errors are properly handled."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_get_key.side_effect = Exception('Database connection error')
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_llm_api_key_for_byor(user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to retrieve BYOR LLM API key' in exc_info.value.detail
|
||||
361
enterprise/tests/unit/server/routes/test_email_routes.py
Normal file
361
enterprise/tests/unit/server/routes/test_email_routes.py
Normal file
@@ -0,0 +1,361 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.email import (
|
||||
ResendEmailVerificationRequest,
|
||||
resend_email_verification,
|
||||
verified_email,
|
||||
verify_email,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Create a mock request object."""
|
||||
request = MagicMock(spec=Request)
|
||||
request.url = MagicMock()
|
||||
request.url.hostname = 'localhost'
|
||||
request.url.netloc = 'localhost:8000'
|
||||
request.url.path = '/api/email/verified'
|
||||
request.base_url = 'http://localhost:8000/'
|
||||
request.headers = {}
|
||||
request.cookies = {}
|
||||
request.query_params = MagicMock()
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_auth():
|
||||
"""Create a mock SaasUserAuth object."""
|
||||
auth = MagicMock(spec=SaasUserAuth)
|
||||
auth.access_token = SecretStr('test_access_token')
|
||||
auth.refresh_token = SecretStr('test_refresh_token')
|
||||
auth.email = 'test@example.com'
|
||||
auth.email_verified = False
|
||||
auth.accepted_tos = True
|
||||
auth.refresh = AsyncMock()
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_email_default_behavior(mock_request):
|
||||
"""Test verify_email with default is_auth_flow=False."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_keycloak_admin = AsyncMock()
|
||||
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
):
|
||||
await verify_email(request=mock_request, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
mock_keycloak_admin.a_send_verify_email.assert_called_once()
|
||||
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||
assert call_args.kwargs['user_id'] == user_id
|
||||
assert (
|
||||
call_args.kwargs['redirect_uri'] == 'http://localhost:8000/api/email/verified'
|
||||
)
|
||||
assert 'client_id' in call_args.kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_email_with_auth_flow(mock_request):
|
||||
"""Test verify_email with is_auth_flow=True."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_keycloak_admin = AsyncMock()
|
||||
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
):
|
||||
await verify_email(request=mock_request, user_id=user_id, is_auth_flow=True)
|
||||
|
||||
# Assert
|
||||
mock_keycloak_admin.a_send_verify_email.assert_called_once()
|
||||
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||
assert call_args.kwargs['user_id'] == user_id
|
||||
assert (
|
||||
call_args.kwargs['redirect_uri'] == 'http://localhost:8000?email_verified=true'
|
||||
)
|
||||
assert 'client_id' in call_args.kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_email_https_scheme(mock_request):
|
||||
"""Test verify_email uses https scheme for non-localhost hosts."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_request.url.hostname = 'example.com'
|
||||
mock_request.url.netloc = 'example.com'
|
||||
mock_keycloak_admin = AsyncMock()
|
||||
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
):
|
||||
await verify_email(request=mock_request, user_id=user_id, is_auth_flow=True)
|
||||
|
||||
# Assert
|
||||
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||
assert call_args.kwargs['redirect_uri'].startswith('https://')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verified_email_default_redirect(mock_request, mock_user_auth):
|
||||
"""Test verified_email redirects to /settings/user by default."""
|
||||
# Arrange
|
||||
mock_request.query_params.get.return_value = None
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
|
||||
patch('server.routes.email.set_response_cookie') as mock_set_cookie,
|
||||
):
|
||||
result = await verified_email(mock_request)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
assert result.headers['location'] == 'http://localhost:8000/settings/user'
|
||||
mock_user_auth.refresh.assert_called_once()
|
||||
mock_set_cookie.assert_called_once()
|
||||
assert mock_user_auth.email_verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verified_email_https_scheme(mock_request, mock_user_auth):
|
||||
"""Test verified_email uses https scheme for non-localhost hosts."""
|
||||
# Arrange
|
||||
mock_request.url.hostname = 'example.com'
|
||||
mock_request.url.netloc = 'example.com'
|
||||
mock_request.query_params.get.return_value = None
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
|
||||
patch('server.routes.email.set_response_cookie') as mock_set_cookie,
|
||||
):
|
||||
result = await verified_email(mock_request)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.headers['location'].startswith('https://')
|
||||
mock_set_cookie.assert_called_once()
|
||||
# Verify secure flag is True for https
|
||||
call_kwargs = mock_set_cookie.call_args.kwargs
|
||||
assert call_kwargs['secure'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_email_verification_with_user_id_from_body_succeeds(mock_request):
|
||||
"""Test resend_email_verification succeeds when user_id is provided in body."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
body = ResendEmailVerificationRequest(user_id=user_id, is_auth_flow=False)
|
||||
mock_keycloak_admin = AsyncMock()
|
||||
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||
|
||||
with (
|
||||
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
|
||||
patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
),
|
||||
patch('server.routes.email.logger') as mock_logger,
|
||||
):
|
||||
mock_rate_limit.return_value = None # Rate limit check passes
|
||||
|
||||
# Act
|
||||
result = await resend_email_verification(request=mock_request, body=body)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_200_OK
|
||||
assert 'message' in result.body.decode()
|
||||
mock_rate_limit.assert_called_once_with(
|
||||
request=mock_request,
|
||||
key_prefix='email_resend',
|
||||
user_id=user_id,
|
||||
user_rate_limit_seconds=30,
|
||||
ip_rate_limit_seconds=60,
|
||||
)
|
||||
mock_keycloak_admin.a_send_verify_email.assert_called_once()
|
||||
# Logger is called multiple times (verify_email and resend_email_verification)
|
||||
# Check that the resend message was logged
|
||||
assert any(
|
||||
'Resending verification email for' in str(call)
|
||||
for call in mock_logger.info.call_args_list
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_email_verification_with_user_id_from_auth_succeeds(mock_request):
|
||||
"""Test resend_email_verification succeeds when user_id comes from authentication."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_keycloak_admin = AsyncMock()
|
||||
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.email.get_user_id', return_value=user_id
|
||||
) as mock_get_user_id,
|
||||
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
|
||||
patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
),
|
||||
):
|
||||
mock_rate_limit.return_value = None # Rate limit check passes
|
||||
|
||||
# Act
|
||||
result = await resend_email_verification(request=mock_request, body=None)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_200_OK
|
||||
mock_get_user_id.assert_called_once_with(mock_request)
|
||||
mock_rate_limit.assert_called_once_with(
|
||||
request=mock_request,
|
||||
key_prefix='email_resend',
|
||||
user_id=user_id,
|
||||
user_rate_limit_seconds=30,
|
||||
ip_rate_limit_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_email_verification_without_user_id_returns_400(mock_request):
|
||||
"""Test resend_email_verification returns 400 when user_id is not available."""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.routes.email.get_user_id', side_effect=Exception('Not authenticated')
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await resend_email_verification(request=mock_request, body=None)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'user_id is required' in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_email_verification_rate_limit_exceeded_returns_429(mock_request):
|
||||
"""Test resend_email_verification returns 429 when rate limit is exceeded."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
body = ResendEmailVerificationRequest(user_id=user_id)
|
||||
|
||||
with (
|
||||
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
|
||||
):
|
||||
mock_rate_limit.side_effect = HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests. Please wait 2 minutes before trying again.',
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await resend_email_verification(request=mock_request, body=body)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
assert 'Too many requests' in exc_info.value.detail
|
||||
mock_rate_limit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_email_verification_with_is_auth_flow_true(mock_request):
|
||||
"""Test resend_email_verification passes is_auth_flow to verify_email."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
body = ResendEmailVerificationRequest(user_id=user_id, is_auth_flow=True)
|
||||
mock_keycloak_admin = AsyncMock()
|
||||
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||
|
||||
with (
|
||||
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
|
||||
patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
),
|
||||
):
|
||||
mock_rate_limit.return_value = None
|
||||
|
||||
# Act
|
||||
await resend_email_verification(request=mock_request, body=body)
|
||||
|
||||
# Assert
|
||||
mock_keycloak_admin.a_send_verify_email.assert_called_once()
|
||||
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||
# Verify that verify_email was called with is_auth_flow=True
|
||||
# We check this indirectly by verifying the redirect_uri
|
||||
assert 'email_verified=true' in call_args.kwargs['redirect_uri']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_email_verification_with_is_auth_flow_false(mock_request):
|
||||
"""Test resend_email_verification uses default is_auth_flow=False when not specified."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
body = ResendEmailVerificationRequest(user_id=user_id, is_auth_flow=False)
|
||||
mock_keycloak_admin = AsyncMock()
|
||||
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||
|
||||
with (
|
||||
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
|
||||
patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
),
|
||||
):
|
||||
mock_rate_limit.return_value = None
|
||||
|
||||
# Act
|
||||
await resend_email_verification(request=mock_request, body=body)
|
||||
|
||||
# Assert
|
||||
mock_keycloak_admin.a_send_verify_email.assert_called_once()
|
||||
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||
# Verify that verify_email was called with is_auth_flow=False
|
||||
assert '/api/email/verified' in call_args.kwargs['redirect_uri']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_email_verification_body_none_uses_auth(mock_request):
|
||||
"""Test resend_email_verification uses auth when body is None."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_keycloak_admin = AsyncMock()
|
||||
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.email.get_user_id', return_value=user_id
|
||||
) as mock_get_user_id,
|
||||
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
|
||||
patch(
|
||||
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||
),
|
||||
):
|
||||
mock_rate_limit.return_value = None
|
||||
|
||||
# Act
|
||||
result = await resend_email_verification(request=mock_request, body=None)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_200_OK
|
||||
mock_get_user_id.assert_called_once()
|
||||
mock_rate_limit.assert_called_once_with(
|
||||
request=mock_request,
|
||||
key_prefix='email_resend',
|
||||
user_id=user_id,
|
||||
user_rate_limit_seconds=30,
|
||||
ip_rate_limit_seconds=60,
|
||||
)
|
||||
@@ -0,0 +1,502 @@
|
||||
"""Unit tests for GitLab integration routes."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, status
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from integrations.gitlab.webhook_installation import BreakLoopException
|
||||
from integrations.types import GitLabResourceType
|
||||
from server.routes.integration.gitlab import (
|
||||
ReinstallWebhookRequest,
|
||||
ResourceIdentifier,
|
||||
get_gitlab_resources,
|
||||
reinstall_gitlab_webhook,
|
||||
)
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitlab_service():
|
||||
"""Create a mock SaaSGitLabService instance."""
|
||||
service = MagicMock(spec=SaaSGitLabService)
|
||||
service.get_user_resources_with_admin_access = AsyncMock(
|
||||
return_value=(
|
||||
[
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'Test Project',
|
||||
'path_with_namespace': 'user/test-project',
|
||||
'namespace': {'kind': 'user'},
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'name': 'Group Project',
|
||||
'path_with_namespace': 'group/group-project',
|
||||
'namespace': {'kind': 'group'},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
'id': 10,
|
||||
'name': 'Test Group',
|
||||
'full_path': 'test-group',
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
service.check_webhook_exists_on_resource = AsyncMock(return_value=(True, None))
|
||||
service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_webhook():
|
||||
"""Create a mock webhook object."""
|
||||
webhook = MagicMock(spec=GitlabWebhook)
|
||||
webhook.webhook_uuid = 'test-uuid'
|
||||
webhook.last_synced = None
|
||||
return webhook
|
||||
|
||||
|
||||
class TestGetGitLabResources:
|
||||
"""Test cases for get_gitlab_resources endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_success(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test successfully retrieving GitLab resources with webhook status."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(
|
||||
return_value=({}, {}) # Empty maps for simplicity
|
||||
)
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert len(response.resources) == 2 # 1 project (filtered) + 1 group
|
||||
assert response.resources[0].type == 'project'
|
||||
assert response.resources[0].id == '1'
|
||||
assert response.resources[0].name == 'Test Project'
|
||||
assert response.resources[1].type == 'group'
|
||||
assert response.resources[1].id == '10'
|
||||
mock_gitlab_service.get_user_resources_with_admin_access.assert_called_once()
|
||||
mock_webhook_store.get_webhooks_by_resources.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_filters_nested_projects(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test that projects nested under groups are filtered out."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(return_value=({}, {}))
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
# Should only include the user project, not the group project
|
||||
project_resources = [r for r in response.resources if r.type == 'project']
|
||||
assert len(project_resources) == 1
|
||||
assert project_resources[0].id == '1'
|
||||
assert project_resources[0].name == 'Test Project'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_includes_webhook_metadata(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test that webhook metadata is included in the response."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(
|
||||
return_value=({'1': mock_webhook}, {'10': mock_webhook})
|
||||
)
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert response.resources[0].webhook_uuid == 'test-uuid'
|
||||
assert response.resources[1].webhook_uuid == 'test-uuid'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
async def test_get_resources_non_saas_service(
|
||||
self, mock_gitlab_service_impl, mock_gitlab_service
|
||||
):
|
||||
"""Test that non-SaaS GitLab service raises an error."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
non_saas_service = AsyncMock()
|
||||
mock_gitlab_service_impl.return_value = non_saas_service
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'Only SaaS GitLab service is supported' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_parallel_api_calls(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test that webhook status checks are made in parallel."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(return_value=({}, {}))
|
||||
call_count = 0
|
||||
|
||||
async def track_calls(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return (True, None)
|
||||
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
side_effect=track_calls
|
||||
)
|
||||
|
||||
# Act
|
||||
await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
# Should be called for each resource (1 project + 1 group)
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
class TestReinstallGitLabWebhook:
|
||||
"""Test cases for reinstall_gitlab_webhook endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_success_existing_webhook(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test successful webhook reinstallation when webhook record exists."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-123'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-123', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.resource_id == resource_id
|
||||
assert result.resource_type == resource_type.value
|
||||
assert result.error is None
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource.assert_called_once_with(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
mock_verify_conditions.assert_called_once()
|
||||
mock_install_webhook.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_success_new_webhook_record(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test successful webhook reinstallation when webhook record doesn't exist."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-456'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = (
|
||||
AsyncMock(return_value=False) # No existing webhook to reset
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
side_effect=[
|
||||
None,
|
||||
MagicMock(),
|
||||
] # First call returns None, second returns new webhook
|
||||
)
|
||||
mock_webhook_store.store_webhooks = AsyncMock()
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-456', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
mock_webhook_store.store_webhooks.assert_called_once()
|
||||
# Should fetch webhook twice: once to check, once after creating
|
||||
assert mock_webhook_store.get_webhook_by_resource_only.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_no_admin_access(
|
||||
self, mock_isinstance, mock_gitlab_service_impl, mock_gitlab_service
|
||||
):
|
||||
"""Test reinstallation when user doesn't have admin access."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-789'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'does not have admin access' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
async def test_reinstall_webhook_non_saas_service(self, mock_gitlab_service_impl):
|
||||
"""Test reinstallation with non-SaaS GitLab service."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-999'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
non_saas_service = AsyncMock()
|
||||
mock_gitlab_service_impl.return_value = non_saas_service
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'Only SaaS GitLab service is supported' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_conditions_not_met(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation when webhook conditions are not met."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-111'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.side_effect = BreakLoopException()
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'conditions not met' in exc_info.value.detail.lower()
|
||||
mock_install_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_installation_fails(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation when webhook installation fails."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-222'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = (None, None) # Installation failed
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert 'Failed to install webhook' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_group_resource(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation for a group resource."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'group-333'
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-group', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.resource_id == resource_id
|
||||
assert result.resource_type == resource_type.value
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource.assert_called_once_with(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
610
enterprise/tests/unit/server/routes/test_oauth_device.py
Normal file
610
enterprise/tests/unit/server/routes/test_oauth_device.py
Normal file
@@ -0,0 +1,610 @@
|
||||
"""Unit tests for OAuth2 Device Flow endpoints."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from server.routes.oauth_device import (
|
||||
device_authorization,
|
||||
device_token,
|
||||
device_verification_authenticated,
|
||||
)
|
||||
from storage.device_code import DeviceCode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_code_store():
|
||||
"""Mock device code store."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_key_store():
|
||||
"""Mock API key store."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_token_manager():
|
||||
"""Mock token manager."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Mock FastAPI request."""
|
||||
request = MagicMock(spec=Request)
|
||||
request.base_url = 'https://test.example.com/'
|
||||
return request
|
||||
|
||||
|
||||
class TestDeviceAuthorization:
|
||||
"""Test device authorization endpoint."""
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_device_authorization_success(self, mock_store, mock_request):
|
||||
"""Test successful device authorization."""
|
||||
mock_device = DeviceCode(
|
||||
device_code='test-device-code-123',
|
||||
user_code='ABC12345',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
current_interval=5, # Default interval
|
||||
)
|
||||
mock_store.create_device_code.return_value = mock_device
|
||||
|
||||
result = await device_authorization(mock_request)
|
||||
|
||||
assert result.device_code == 'test-device-code-123'
|
||||
assert result.user_code == 'ABC12345'
|
||||
assert result.expires_in == 600
|
||||
assert result.interval == 5 # Should match device's current_interval
|
||||
assert 'verify' in result.verification_uri
|
||||
assert 'ABC12345' in result.verification_uri_complete
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_device_authorization_with_increased_interval(
|
||||
self, mock_store, mock_request
|
||||
):
|
||||
"""Test device authorization returns increased interval from rate limiting."""
|
||||
mock_device = DeviceCode(
|
||||
device_code='test-device-code-456',
|
||||
user_code='XYZ98765',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
current_interval=15, # Increased interval from previous rate limiting
|
||||
)
|
||||
mock_store.create_device_code.return_value = mock_device
|
||||
|
||||
result = await device_authorization(mock_request)
|
||||
|
||||
assert result.device_code == 'test-device-code-456'
|
||||
assert result.user_code == 'XYZ98765'
|
||||
assert result.expires_in == 600
|
||||
assert result.interval == 15 # Should match device's increased current_interval
|
||||
assert 'verify' in result.verification_uri
|
||||
assert 'XYZ98765' in result.verification_uri_complete
|
||||
|
||||
|
||||
class TestDeviceToken:
|
||||
"""Test device token endpoint."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'device_exists,status,expected_error',
|
||||
[
|
||||
(False, None, 'invalid_grant'),
|
||||
(True, 'expired', 'expired_token'),
|
||||
(True, 'denied', 'access_denied'),
|
||||
(True, 'pending', 'authorization_pending'),
|
||||
],
|
||||
)
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_device_token_error_cases(
|
||||
self, mock_store, device_exists, status, expected_error
|
||||
):
|
||||
"""Test various error cases for device token endpoint."""
|
||||
device_code = 'test-device-code'
|
||||
|
||||
if device_exists:
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_expired.return_value = status == 'expired'
|
||||
mock_device.status = status
|
||||
# Mock rate limiting - return False (not too fast) and default interval
|
||||
mock_device.check_rate_limit.return_value = (False, 5)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
else:
|
||||
mock_store.get_by_device_code.return_value = None
|
||||
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
# Check error in response content
|
||||
content = result.body.decode()
|
||||
assert expected_error in content
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_device_token_success(self, mock_store, mock_api_key_class):
|
||||
"""Test successful device token retrieval."""
|
||||
device_code = 'test-device-code'
|
||||
|
||||
# Mock authorized device
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_expired.return_value = False
|
||||
mock_device.status = 'authorized'
|
||||
mock_device.keycloak_user_id = 'user-123'
|
||||
mock_device.user_code = (
|
||||
'ABC12345' # Add user_code for device-specific API key lookup
|
||||
)
|
||||
# Mock rate limiting - return False (not too fast) and default interval
|
||||
mock_device.check_rate_limit.return_value = (False, 5)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
# Mock API key retrieval
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.retrieve_api_key_by_name.return_value = 'test-api-key'
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Check that result is a DeviceTokenResponse
|
||||
assert result.access_token == 'test-api-key'
|
||||
assert result.token_type == 'Bearer'
|
||||
|
||||
# Verify that the correct device-specific API key name was used
|
||||
mock_api_key_store.retrieve_api_key_by_name.assert_called_once_with(
|
||||
'user-123', 'Device Link Access Key (ABC12345)'
|
||||
)
|
||||
|
||||
|
||||
class TestDeviceVerificationAuthenticated:
|
||||
"""Test device verification authenticated endpoint."""
|
||||
|
||||
async def test_verification_unauthenticated_user(self):
|
||||
"""Test verification with unauthenticated user."""
|
||||
with pytest.raises(HTTPException):
|
||||
await device_verification_authenticated(user_code='ABC12345', user_id=None)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_verification_invalid_device_code(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test verification with invalid device code."""
|
||||
mock_store.get_by_user_code.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await device_verification_authenticated(
|
||||
user_code='INVALID', user_id='user-123'
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_verification_already_processed(self, mock_store, mock_api_key_class):
|
||||
"""Test verification with already processed device code."""
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = False
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_verification_success(self, mock_store, mock_api_key_class):
|
||||
"""Test successful device verification."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = True
|
||||
|
||||
# Mock API key store
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
result = await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 200
|
||||
# Should NOT delete existing API keys (multiple devices allowed)
|
||||
mock_api_key_store.delete_api_key_by_name.assert_not_called()
|
||||
# Should create a new API key with device-specific name
|
||||
mock_api_key_store.create_api_key.assert_called_once()
|
||||
call_args = mock_api_key_store.create_api_key.call_args
|
||||
assert call_args[1]['name'] == 'Device Link Access Key (ABC12345)'
|
||||
mock_store.authorize_device_code.assert_called_once_with(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_multiple_device_authentication(self, mock_store, mock_api_key_class):
|
||||
"""Test that multiple devices can authenticate simultaneously."""
|
||||
# Mock API key store
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
# Simulate two different devices
|
||||
device1_code = 'ABC12345'
|
||||
device2_code = 'XYZ67890'
|
||||
user_id = 'user-123'
|
||||
|
||||
# Mock device codes
|
||||
mock_device1 = MagicMock()
|
||||
mock_device1.is_pending.return_value = True
|
||||
mock_device2 = MagicMock()
|
||||
mock_device2.is_pending.return_value = True
|
||||
|
||||
# Configure mock store to return appropriate device for each user_code
|
||||
def get_by_user_code_side_effect(user_code):
|
||||
if user_code == device1_code:
|
||||
return mock_device1
|
||||
elif user_code == device2_code:
|
||||
return mock_device2
|
||||
return None
|
||||
|
||||
mock_store.get_by_user_code.side_effect = get_by_user_code_side_effect
|
||||
mock_store.authorize_device_code.return_value = True
|
||||
|
||||
# Authenticate first device
|
||||
result1 = await device_verification_authenticated(
|
||||
user_code=device1_code, user_id=user_id
|
||||
)
|
||||
|
||||
# Authenticate second device
|
||||
result2 = await device_verification_authenticated(
|
||||
user_code=device2_code, user_id=user_id
|
||||
)
|
||||
|
||||
# Both should succeed
|
||||
assert isinstance(result1, JSONResponse)
|
||||
assert result1.status_code == 200
|
||||
assert isinstance(result2, JSONResponse)
|
||||
assert result2.status_code == 200
|
||||
|
||||
# Should create two separate API keys with different names
|
||||
assert mock_api_key_store.create_api_key.call_count == 2
|
||||
|
||||
# Check that each device got a unique API key name
|
||||
call_args_list = mock_api_key_store.create_api_key.call_args_list
|
||||
device1_name = call_args_list[0][1]['name']
|
||||
device2_name = call_args_list[1][1]['name']
|
||||
|
||||
assert device1_name == f'Device Link Access Key ({device1_code})'
|
||||
assert device2_name == f'Device Link Access Key ({device2_code})'
|
||||
assert device1_name != device2_name # Ensure they're different
|
||||
|
||||
# Should NOT delete any existing API keys
|
||||
mock_api_key_store.delete_api_key_by_name.assert_not_called()
|
||||
|
||||
|
||||
class TestDeviceTokenRateLimiting:
|
||||
"""Test rate limiting for device token polling (RFC 8628 section 3.5)."""
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_first_poll_allowed(self, mock_store):
|
||||
"""Test that the first poll is always allowed."""
|
||||
# Create a device code with no previous poll time
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=None, # First poll
|
||||
current_interval=5,
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return authorization_pending, not slow_down
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'authorization_pending' in content
|
||||
assert 'slow_down' not in content
|
||||
|
||||
# Should update poll time without increasing interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=False
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_normal_polling_allowed(self, mock_store):
|
||||
"""Test that normal polling (respecting interval) is allowed."""
|
||||
# Create a device code with last poll time 6 seconds ago (interval is 5)
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=6)
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=5,
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return authorization_pending, not slow_down
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'authorization_pending' in content
|
||||
assert 'slow_down' not in content
|
||||
|
||||
# Should update poll time without increasing interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=False
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_fast_polling_returns_slow_down(self, mock_store):
|
||||
"""Test that polling too fast returns slow_down error."""
|
||||
# Create a device code with last poll time 2 seconds ago (interval is 5)
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=2)
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=5,
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return slow_down error
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'slow_down' in content
|
||||
assert 'interval' in content
|
||||
assert '10' in content # New interval should be 5 + 5 = 10
|
||||
|
||||
# Should update poll time and increase interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=True
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_interval_increases_with_repeated_fast_polling(self, mock_store):
|
||||
"""Test that interval increases with repeated fast polling."""
|
||||
# Create a device code with higher current interval from previous slow_down
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=5) # 5 seconds ago
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=15, # Already increased from previous slow_down
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return slow_down error with increased interval
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'slow_down' in content
|
||||
assert '20' in content # New interval should be 15 + 5 = 20
|
||||
|
||||
# Should update poll time and increase interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=True
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_interval_caps_at_maximum(self, mock_store):
|
||||
"""Test that interval is capped at maximum value."""
|
||||
# Create a device code with interval near maximum
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=30)
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=58, # Near maximum of 60
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return slow_down error with capped interval
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'slow_down' in content
|
||||
assert '60' in content # Should be capped at 60, not 63
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_rate_limiting_with_authorized_device(self, mock_store):
|
||||
"""Test that rate limiting still applies to authorized devices."""
|
||||
# Create an authorized device code with recent poll
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=2)
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='authorized', # Device is authorized
|
||||
keycloak_user_id='user123',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=5,
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should still return slow_down error even for authorized device
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'slow_down' in content
|
||||
|
||||
# Should update poll time and increase interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=True
|
||||
)
|
||||
|
||||
|
||||
class TestDeviceVerificationTransactionIntegrity:
|
||||
"""Test transaction integrity for device verification to prevent orphaned API keys."""
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_authorization_failure_prevents_api_key_creation(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test that if device authorization fails, no API key is created."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = False # Authorization fails
|
||||
|
||||
# Mock API key store
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
# Should raise HTTPException due to authorization failure
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to authorize the device' in exc_info.value.detail
|
||||
|
||||
# API key should NOT be created since authorization failed
|
||||
mock_api_key_store.create_api_key.assert_not_called()
|
||||
mock_store.authorize_device_code.assert_called_once_with(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_api_key_creation_failure_reverts_authorization(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test that if API key creation fails after authorization, the authorization is reverted."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = True # Authorization succeeds
|
||||
mock_store.deny_device_code.return_value = True # Cleanup succeeds
|
||||
|
||||
# Mock API key store to fail on creation
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.create_api_key.side_effect = Exception('Database error')
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
# Should raise HTTPException due to API key creation failure
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to create API key for device access' in exc_info.value.detail
|
||||
|
||||
# Authorization should have been attempted first
|
||||
mock_store.authorize_device_code.assert_called_once_with(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
# API key creation should have been attempted after authorization
|
||||
mock_api_key_store.create_api_key.assert_called_once()
|
||||
|
||||
# Authorization should be reverted due to API key creation failure
|
||||
mock_store.deny_device_code.assert_called_once_with('ABC12345')
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_api_key_creation_failure_cleanup_failure_logged(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test that cleanup failure is logged but doesn't prevent the main error from being raised."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = True # Authorization succeeds
|
||||
mock_store.deny_device_code.side_effect = Exception(
|
||||
'Cleanup failed'
|
||||
) # Cleanup fails
|
||||
|
||||
# Mock API key store to fail on creation
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.create_api_key.side_effect = Exception('Database error')
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
# Should still raise HTTPException for the original API key creation failure
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to create API key for device access' in exc_info.value.detail
|
||||
|
||||
# Both operations should have been attempted
|
||||
mock_store.authorize_device_code.assert_called_once()
|
||||
mock_api_key_store.create_api_key.assert_called_once()
|
||||
mock_store.deny_device_code.assert_called_once_with('ABC12345')
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_successful_flow_creates_api_key_after_authorization(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test that in the successful flow, API key is created only after authorization."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = True # Authorization succeeds
|
||||
|
||||
# Mock API key store
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
result = await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 200
|
||||
|
||||
# Verify the order: authorization first, then API key creation
|
||||
mock_store.authorize_device_code.assert_called_once_with(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
mock_api_key_store.create_api_key.assert_called_once()
|
||||
|
||||
# No cleanup should be needed in successful case
|
||||
mock_store.deny_device_code.assert_not_called()
|
||||
@@ -699,12 +699,11 @@ class TestProcessBatchOperationsBackground:
|
||||
# Should not raise exceptions
|
||||
await _process_batch_operations_background(batch_ops, 'test-api-key')
|
||||
|
||||
# Should log the error
|
||||
mock_logger.error.assert_called_once_with(
|
||||
'error_processing_batch_operation',
|
||||
extra={
|
||||
'path': 'invalid-path',
|
||||
'method': 'BatchMethod.POST',
|
||||
'error': mock_logger.error.call_args[1]['extra']['error'],
|
||||
},
|
||||
)
|
||||
# Should log the error with exception type and message in the log message
|
||||
mock_logger.error.assert_called_once()
|
||||
call_args = mock_logger.error.call_args
|
||||
log_message = call_args[0][0]
|
||||
assert log_message.startswith('error_processing_batch_operation:')
|
||||
assert call_args[1]['extra']['path'] == 'invalid-path'
|
||||
assert call_args[1]['extra']['method'] == 'BatchMethod.POST'
|
||||
assert call_args[1]['exc_info'] is True
|
||||
|
||||
290
enterprise/tests/unit/server/test_rate_limit_utils.py
Normal file
290
enterprise/tests/unit/server/test_rate_limit_utils.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request, status
|
||||
from server.utils.rate_limit_utils import (
|
||||
RATE_LIMIT_IP_SECONDS,
|
||||
RATE_LIMIT_USER_SECONDS,
|
||||
check_rate_limit_by_user_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Create a mock request object."""
|
||||
request = MagicMock(spec=Request)
|
||||
request.client = MagicMock()
|
||||
request.client.host = '192.168.1.1'
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""Create a mock Redis client."""
|
||||
redis = AsyncMock()
|
||||
redis.set = AsyncMock(return_value=True) # First call succeeds (key doesn't exist)
|
||||
return redis
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_by_user_id_first_request_succeeds(mock_request, mock_redis):
|
||||
"""Test that first request with user_id succeeds and sets rate limit key."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
key_prefix = 'email_resend'
|
||||
|
||||
with (
|
||||
patch('server.utils.rate_limit_utils.sio') as mock_sio,
|
||||
patch('server.utils.rate_limit_utils.logger') as mock_logger,
|
||||
):
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_redis.set.assert_called_once_with(
|
||||
f'{key_prefix}:{user_id}', 1, nx=True, ex=RATE_LIMIT_USER_SECONDS
|
||||
)
|
||||
mock_logger.warning.assert_not_called()
|
||||
mock_logger.info.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_by_user_id_second_request_within_window_fails(
|
||||
mock_request, mock_redis
|
||||
):
|
||||
"""Test that second request with same user_id within rate limit window fails."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
key_prefix = 'email_resend'
|
||||
mock_redis.set = AsyncMock(return_value=False) # Key already exists
|
||||
|
||||
with (
|
||||
patch('server.utils.rate_limit_utils.sio') as mock_sio,
|
||||
patch('server.utils.rate_limit_utils.logger') as mock_logger,
|
||||
):
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
assert 'Too many requests' in exc_info.value.detail
|
||||
assert f'{RATE_LIMIT_USER_SECONDS // 60} minutes' in exc_info.value.detail
|
||||
mock_logger.info.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_by_ip_when_user_id_is_none(mock_request, mock_redis):
|
||||
"""Test that rate limiting falls back to IP address when user_id is None."""
|
||||
# Arrange
|
||||
key_prefix = 'email_resend'
|
||||
|
||||
with (
|
||||
patch('server.utils.rate_limit_utils.sio') as mock_sio,
|
||||
patch('server.utils.rate_limit_utils.logger') as mock_logger,
|
||||
):
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=None
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_redis.set.assert_called_once_with(
|
||||
f'{key_prefix}:ip:{mock_request.client.host}',
|
||||
1,
|
||||
nx=True,
|
||||
ex=RATE_LIMIT_IP_SECONDS,
|
||||
)
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_by_ip_second_request_within_window_fails(
|
||||
mock_request, mock_redis
|
||||
):
|
||||
"""Test that second request from same IP within rate limit window fails."""
|
||||
# Arrange
|
||||
key_prefix = 'email_resend'
|
||||
mock_redis.set = AsyncMock(return_value=False) # Key already exists
|
||||
|
||||
with (
|
||||
patch('server.utils.rate_limit_utils.sio') as mock_sio,
|
||||
):
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=None
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
assert f'{RATE_LIMIT_IP_SECONDS // 60} minutes' in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_redis_unavailable_fails_open(mock_request):
|
||||
"""Test that rate limiting fails open when Redis is unavailable."""
|
||||
# Arrange
|
||||
key_prefix = 'email_resend'
|
||||
user_id = 'test_user_id'
|
||||
|
||||
with (
|
||||
patch('server.utils.rate_limit_utils.sio') as mock_sio,
|
||||
patch('server.utils.rate_limit_utils.logger') as mock_logger,
|
||||
):
|
||||
mock_sio.manager.redis = None # Redis unavailable
|
||||
|
||||
# Act
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
'Redis unavailable for rate limiting, allowing request'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_redis_exception_fails_open(mock_request, mock_redis):
|
||||
"""Test that rate limiting fails open when Redis raises an exception."""
|
||||
# Arrange
|
||||
key_prefix = 'email_resend'
|
||||
user_id = 'test_user_id'
|
||||
mock_redis.set = AsyncMock(side_effect=Exception('Redis connection error'))
|
||||
|
||||
with (
|
||||
patch('server.utils.rate_limit_utils.sio') as mock_sio,
|
||||
patch('server.utils.rate_limit_utils.logger') as mock_logger,
|
||||
):
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert 'Error checking rate limit' in str(mock_logger.warning.call_args[0][0])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_custom_key_prefix(mock_request, mock_redis):
|
||||
"""Test that different key prefixes create different rate limit keys."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
key_prefix = 'password_reset'
|
||||
|
||||
with patch('server.utils.rate_limit_utils.sio') as mock_sio:
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_redis.set.assert_called_once_with(
|
||||
f'{key_prefix}:{user_id}', 1, nx=True, ex=RATE_LIMIT_USER_SECONDS
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_custom_rate_limit_seconds(mock_request, mock_redis):
|
||||
"""Test that custom rate limit seconds are used correctly."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
key_prefix = 'email_resend'
|
||||
custom_user_seconds = 60
|
||||
custom_ip_seconds = 180
|
||||
|
||||
with patch('server.utils.rate_limit_utils.sio') as mock_sio:
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request,
|
||||
key_prefix=key_prefix,
|
||||
user_id=user_id,
|
||||
user_rate_limit_seconds=custom_user_seconds,
|
||||
ip_rate_limit_seconds=custom_ip_seconds,
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_redis.set.assert_called_once_with(
|
||||
f'{key_prefix}:{user_id}', 1, nx=True, ex=custom_user_seconds
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_ip_with_unknown_client(mock_request, mock_redis):
|
||||
"""Test that rate limiting handles missing client host gracefully."""
|
||||
# Arrange
|
||||
key_prefix = 'email_resend'
|
||||
mock_request.client = None # No client information
|
||||
|
||||
with patch('server.utils.rate_limit_utils.sio') as mock_sio:
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=None
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_redis.set.assert_called_once_with(
|
||||
f'{key_prefix}:ip:unknown', 1, nx=True, ex=RATE_LIMIT_IP_SECONDS
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_different_users_have_separate_limits(
|
||||
mock_request, mock_redis
|
||||
):
|
||||
"""Test that different user_ids have separate rate limit keys."""
|
||||
# Arrange
|
||||
key_prefix = 'email_resend'
|
||||
user_id_1 = 'user_1'
|
||||
user_id_2 = 'user_2'
|
||||
|
||||
with patch('server.utils.rate_limit_utils.sio') as mock_sio:
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
# Act
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=user_id_1
|
||||
)
|
||||
await check_rate_limit_by_user_id(
|
||||
request=mock_request, key_prefix=key_prefix, user_id=user_id_2
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert mock_redis.set.call_count == 2
|
||||
# Extract call arguments properly
|
||||
call_args_list = [
|
||||
(call[0][0], call[0][1], call[1]['nx'], call[1]['ex'])
|
||||
for call in mock_redis.set.call_args_list
|
||||
]
|
||||
assert (
|
||||
f'{key_prefix}:{user_id_1}',
|
||||
1,
|
||||
True,
|
||||
RATE_LIMIT_USER_SECONDS,
|
||||
) in call_args_list
|
||||
assert (
|
||||
f'{key_prefix}:{user_id_2}',
|
||||
1,
|
||||
True,
|
||||
RATE_LIMIT_USER_SECONDS,
|
||||
) in call_args_list
|
||||
83
enterprise/tests/unit/storage/test_device_code.py
Normal file
83
enterprise/tests/unit/storage/test_device_code.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Unit tests for DeviceCode model."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from storage.device_code import DeviceCode, DeviceCodeStatus
|
||||
|
||||
|
||||
class TestDeviceCode:
|
||||
"""Test cases for DeviceCode model."""
|
||||
|
||||
@pytest.fixture
|
||||
def device_code(self):
|
||||
"""Create a test device code."""
|
||||
return DeviceCode(
|
||||
device_code='test-device-code-123',
|
||||
user_code='ABC12345',
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'expires_delta,expected',
|
||||
[
|
||||
(timedelta(minutes=5), False), # Future expiry
|
||||
(timedelta(minutes=-5), True), # Past expiry
|
||||
(timedelta(seconds=1), False), # Just future (not expired)
|
||||
],
|
||||
)
|
||||
def test_is_expired(self, expires_delta, expected):
|
||||
"""Test expiration check with various time deltas."""
|
||||
device_code = DeviceCode(
|
||||
device_code='test-device-code',
|
||||
user_code='ABC12345',
|
||||
expires_at=datetime.now(timezone.utc) + expires_delta,
|
||||
)
|
||||
assert device_code.is_expired() == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'status,expired,expected',
|
||||
[
|
||||
(DeviceCodeStatus.PENDING.value, False, True),
|
||||
(DeviceCodeStatus.PENDING.value, True, False),
|
||||
(DeviceCodeStatus.AUTHORIZED.value, False, False),
|
||||
(DeviceCodeStatus.DENIED.value, False, False),
|
||||
],
|
||||
)
|
||||
def test_is_pending(self, status, expired, expected):
|
||||
"""Test pending status check."""
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
if expired
|
||||
else datetime.now(timezone.utc) + timedelta(minutes=10)
|
||||
)
|
||||
device_code = DeviceCode(
|
||||
device_code='test-device-code',
|
||||
user_code='ABC12345',
|
||||
status=status,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
assert device_code.is_pending() == expected
|
||||
|
||||
def test_authorize(self, device_code):
|
||||
"""Test device authorization."""
|
||||
user_id = 'test-user-123'
|
||||
|
||||
device_code.authorize(user_id)
|
||||
|
||||
assert device_code.status == DeviceCodeStatus.AUTHORIZED.value
|
||||
assert device_code.keycloak_user_id == user_id
|
||||
assert device_code.authorized_at is not None
|
||||
assert isinstance(device_code.authorized_at, datetime)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'method,expected_status',
|
||||
[
|
||||
('deny', DeviceCodeStatus.DENIED.value),
|
||||
('expire', DeviceCodeStatus.EXPIRED.value),
|
||||
],
|
||||
)
|
||||
def test_status_changes(self, device_code, method, expected_status):
|
||||
"""Test status change methods."""
|
||||
getattr(device_code, method)()
|
||||
assert device_code.status == expected_status
|
||||
193
enterprise/tests/unit/storage/test_device_code_store.py
Normal file
193
enterprise/tests/unit/storage/test_device_code_store.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for DeviceCodeStore."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.device_code import DeviceCode
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Mock database session."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_maker(mock_session):
|
||||
"""Mock session maker."""
|
||||
session_maker = MagicMock()
|
||||
session_maker.return_value.__enter__.return_value = mock_session
|
||||
session_maker.return_value.__exit__.return_value = None
|
||||
return session_maker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_code_store(mock_session_maker):
|
||||
"""Create DeviceCodeStore instance."""
|
||||
return DeviceCodeStore(mock_session_maker)
|
||||
|
||||
|
||||
class TestDeviceCodeStore:
|
||||
"""Test cases for DeviceCodeStore."""
|
||||
|
||||
def test_generate_user_code(self, device_code_store):
|
||||
"""Test user code generation."""
|
||||
code = device_code_store.generate_user_code()
|
||||
|
||||
assert len(code) == 8
|
||||
assert code.isupper()
|
||||
# Should not contain confusing characters
|
||||
assert not any(char in code for char in 'IO01')
|
||||
|
||||
def test_generate_device_code(self, device_code_store):
|
||||
"""Test device code generation."""
|
||||
code = device_code_store.generate_device_code()
|
||||
|
||||
assert len(code) == 128
|
||||
assert code.isalnum()
|
||||
|
||||
def test_create_device_code_success(self, device_code_store, mock_session):
|
||||
"""Test successful device code creation."""
|
||||
# Mock successful creation (no IntegrityError)
|
||||
mock_device_code = MagicMock(spec=DeviceCode)
|
||||
mock_device_code.device_code = 'test-device-code-123'
|
||||
mock_device_code.user_code = 'TESTCODE'
|
||||
|
||||
# Mock the session to return our mock device code after refresh
|
||||
def mock_refresh(obj):
|
||||
obj.device_code = mock_device_code.device_code
|
||||
obj.user_code = mock_device_code.user_code
|
||||
|
||||
mock_session.refresh.side_effect = mock_refresh
|
||||
|
||||
result = device_code_store.create_device_code(expires_in=600)
|
||||
|
||||
assert isinstance(result, DeviceCode)
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_session.refresh.assert_called_once()
|
||||
mock_session.expunge.assert_called_once()
|
||||
|
||||
def test_create_device_code_with_retries(
|
||||
self, device_code_store, mock_session_maker
|
||||
):
|
||||
"""Test device code creation with constraint violation retries."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session_maker.return_value.__exit__.return_value = None
|
||||
|
||||
# First attempt fails with IntegrityError, second succeeds
|
||||
mock_session.commit.side_effect = [IntegrityError('', '', ''), None]
|
||||
|
||||
mock_device_code = MagicMock(spec=DeviceCode)
|
||||
mock_device_code.device_code = 'test-device-code-456'
|
||||
mock_device_code.user_code = 'TESTCD2'
|
||||
|
||||
def mock_refresh(obj):
|
||||
obj.device_code = mock_device_code.device_code
|
||||
obj.user_code = mock_device_code.user_code
|
||||
|
||||
mock_session.refresh.side_effect = mock_refresh
|
||||
|
||||
store = DeviceCodeStore(mock_session_maker)
|
||||
result = store.create_device_code(expires_in=600)
|
||||
|
||||
assert isinstance(result, DeviceCode)
|
||||
assert mock_session.add.call_count == 2 # Two attempts
|
||||
assert mock_session.commit.call_count == 2 # Two attempts
|
||||
|
||||
def test_create_device_code_max_attempts_exceeded(
|
||||
self, device_code_store, mock_session_maker
|
||||
):
|
||||
"""Test device code creation failure after max attempts."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session_maker.return_value.__exit__.return_value = None
|
||||
|
||||
# All attempts fail with IntegrityError
|
||||
mock_session.commit.side_effect = IntegrityError('', '', '')
|
||||
|
||||
store = DeviceCodeStore(mock_session_maker)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='Failed to generate unique device codes after 3 attempts',
|
||||
):
|
||||
store.create_device_code(expires_in=600, max_attempts=3)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'lookup_method,lookup_field',
|
||||
[
|
||||
('get_by_device_code', 'device_code'),
|
||||
('get_by_user_code', 'user_code'),
|
||||
],
|
||||
)
|
||||
def test_lookup_methods(
|
||||
self, device_code_store, mock_session, lookup_method, lookup_field
|
||||
):
|
||||
"""Test device code lookup methods."""
|
||||
test_code = 'test-code-123'
|
||||
mock_device_code = MagicMock()
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
mock_device_code
|
||||
)
|
||||
|
||||
result = getattr(device_code_store, lookup_method)(test_code)
|
||||
|
||||
assert result == mock_device_code
|
||||
mock_session.query.assert_called_once_with(DeviceCode)
|
||||
mock_session.query.return_value.filter_by.assert_called_once_with(
|
||||
**{lookup_field: test_code}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'device_exists,is_pending,expected_result',
|
||||
[
|
||||
(True, True, True), # Success case
|
||||
(False, True, False), # Device not found
|
||||
(True, False, False), # Device not pending
|
||||
],
|
||||
)
|
||||
def test_authorize_device_code(
|
||||
self,
|
||||
device_code_store,
|
||||
mock_session,
|
||||
device_exists,
|
||||
is_pending,
|
||||
expected_result,
|
||||
):
|
||||
"""Test device code authorization."""
|
||||
user_code = 'ABC12345'
|
||||
user_id = 'test-user-123'
|
||||
|
||||
if device_exists:
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = is_pending
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_device
|
||||
else:
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
result = device_code_store.authorize_device_code(user_code, user_id)
|
||||
|
||||
assert result == expected_result
|
||||
if expected_result:
|
||||
mock_device.authorize.assert_called_once_with(user_id)
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_deny_device_code(self, device_code_store, mock_session):
|
||||
"""Test device code denial."""
|
||||
user_code = 'ABC12345'
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
mock_device
|
||||
)
|
||||
|
||||
result = device_code_store.deny_device_code(user_code)
|
||||
|
||||
assert result is True
|
||||
mock_device.deny.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
388
enterprise/tests/unit/storage/test_gitlab_webhook_store.py
Normal file
388
enterprise/tests/unit/storage/test_gitlab_webhook_store.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""Unit tests for GitlabWebhookStore."""
|
||||
|
||||
import pytest
|
||||
from integrations.types import GitLabResourceType
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def webhook_store(async_session_maker):
|
||||
"""Create a GitlabWebhookStore instance for testing."""
|
||||
return GitlabWebhookStore(a_session_maker=async_session_maker)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_webhooks(async_session_maker):
|
||||
"""Create sample webhook records for testing."""
|
||||
async with async_session_maker() as session:
|
||||
# Create webhooks for user_1
|
||||
webhook1 = GitlabWebhook(
|
||||
project_id='project-1',
|
||||
group_id=None,
|
||||
user_id='user_1',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-1',
|
||||
webhook_uuid='uuid-1',
|
||||
)
|
||||
webhook2 = GitlabWebhook(
|
||||
project_id='project-2',
|
||||
group_id=None,
|
||||
user_id='user_1',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-2',
|
||||
webhook_uuid='uuid-2',
|
||||
)
|
||||
webhook3 = GitlabWebhook(
|
||||
project_id=None,
|
||||
group_id='group-1',
|
||||
user_id='user_1',
|
||||
webhook_exists=False, # Already marked for reinstallation
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-3',
|
||||
webhook_uuid='uuid-3',
|
||||
)
|
||||
|
||||
# Create webhook for user_2
|
||||
webhook4 = GitlabWebhook(
|
||||
project_id='project-3',
|
||||
group_id=None,
|
||||
user_id='user_2',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-4',
|
||||
webhook_uuid='uuid-4',
|
||||
)
|
||||
|
||||
session.add_all([webhook1, webhook2, webhook3, webhook4])
|
||||
await session.commit()
|
||||
|
||||
# Refresh to get IDs (outside of begin() context)
|
||||
await session.refresh(webhook1)
|
||||
await session.refresh(webhook2)
|
||||
await session.refresh(webhook3)
|
||||
await session.refresh(webhook4)
|
||||
|
||||
return [webhook1, webhook2, webhook3, webhook4]
|
||||
|
||||
|
||||
class TestGetWebhookByResourceOnly:
|
||||
"""Test cases for get_webhook_by_resource_only method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_webhook_by_resource_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test getting a project webhook by resource ID without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-1'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.project_id == resource_id
|
||||
assert webhook.user_id == 'user_1'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_webhook_by_resource_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test getting a group webhook by resource ID without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-1'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.group_id == resource_id
|
||||
assert webhook.user_id == 'user_1'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook_by_resource_only_not_found(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test getting a webhook that doesn't exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'non-existent-project'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook_by_resource_only_organization_wide(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test that webhook lookup works regardless of which user originally created it."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-3' # Created by user_2
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.project_id == resource_id
|
||||
# Should find webhook even though it was created by a different user
|
||||
assert webhook.user_id == 'user_2'
|
||||
|
||||
|
||||
class TestResetWebhookForReinstallationByResource:
|
||||
"""Test cases for reset_webhook_for_reinstallation_by_resource method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_project_webhook_by_resource(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test resetting a project webhook by resource without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-1'
|
||||
updating_user_id = 'user_2' # Different user can reset it
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.project_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.webhook_uuid is None
|
||||
assert (
|
||||
webhook.user_id == updating_user_id
|
||||
) # Updated to track who modified it
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_group_webhook_by_resource(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test resetting a group webhook by resource without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-1'
|
||||
updating_user_id = 'user_2'
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.group_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.webhook_uuid is None
|
||||
assert webhook.user_id == updating_user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_webhook_by_resource_not_found(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test resetting a webhook that doesn't exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'non-existent-project'
|
||||
updating_user_id = 'user_1'
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_webhook_by_resource_organization_wide(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test that any user can reset a webhook regardless of original creator."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-3' # Created by user_2
|
||||
updating_user_id = 'user_1' # Different user resetting it
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset and user_id updated
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.project_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.user_id == updating_user_id
|
||||
|
||||
|
||||
class TestGetWebhooksByResources:
|
||||
"""Test cases for get_webhooks_by_resources method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_projects_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for multiple projects."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'project-2', 'project-3']
|
||||
group_ids: list[str] = []
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 3
|
||||
assert 'project-1' in project_map
|
||||
assert 'project-2' in project_map
|
||||
assert 'project-3' in project_map
|
||||
assert len(group_map) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_groups_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for multiple groups."""
|
||||
# Arrange
|
||||
project_ids: list[str] = []
|
||||
group_ids = ['group-1']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 0
|
||||
assert len(group_map) == 1
|
||||
assert 'group-1' in group_map
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_mixed(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for both projects and groups."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'project-2']
|
||||
group_ids = ['group-1']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 2
|
||||
assert len(group_map) == 1
|
||||
assert 'project-1' in project_map
|
||||
assert 'project-2' in project_map
|
||||
assert 'group-1' in group_map
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_empty_lists(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test bulk fetching with empty ID lists."""
|
||||
# Arrange
|
||||
project_ids: list[str] = []
|
||||
group_ids: list[str] = []
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 0
|
||||
assert len(group_map) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_partial_matches(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching when some IDs don't exist."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'non-existent-project']
|
||||
group_ids = ['group-1', 'non-existent-group']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 1
|
||||
assert 'project-1' in project_map
|
||||
assert 'non-existent-project' not in project_map
|
||||
assert len(group_map) == 1
|
||||
assert 'group-1' in group_map
|
||||
assert 'non-existent-group' not in group_map
|
||||
438
enterprise/tests/unit/sync/test_install_gitlab_webhooks.py
Normal file
438
enterprise/tests/unit/sync/test_install_gitlab_webhooks.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Unit tests for install_gitlab_webhooks module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitlab_service():
|
||||
"""Create a mock GitLab service."""
|
||||
service = MagicMock()
|
||||
service.check_resource_exists = AsyncMock(return_value=(True, None))
|
||||
service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
service.check_webhook_exists_on_resource = AsyncMock(return_value=(False, None))
|
||||
service.install_webhook = AsyncMock(return_value=('webhook-id-123', None))
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_webhook_store():
|
||||
"""Create a mock webhook store."""
|
||||
store = MagicMock()
|
||||
store.delete_webhook = AsyncMock()
|
||||
store.update_webhook = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_webhook():
|
||||
"""Create a sample webhook object."""
|
||||
webhook = MagicMock(spec=GitlabWebhook)
|
||||
webhook.user_id = 'test_user_id'
|
||||
webhook.webhook_exists = False
|
||||
webhook.webhook_uuid = None
|
||||
return webhook
|
||||
|
||||
|
||||
class TestVerifyWebhookConditions:
|
||||
"""Test cases for verify_webhook_conditions function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_all_pass(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when all conditions are met for webhook installation."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
# Should not raise any exception
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_gitlab_service.check_resource_exists.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_gitlab_service.check_webhook_exists_on_resource.assert_called_once_with(
|
||||
resource_type, resource_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_resource_does_not_exist(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when resource does not exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-999'
|
||||
mock_gitlab_service.check_resource_exists = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook is deleted
|
||||
mock_webhook_store.delete_webhook.assert_called_once_with(sample_webhook)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_resource_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during resource existence check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_resource_exists = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not delete webhook on rate limit
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_user_no_admin_access(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when user does not have admin access."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-456'
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook is deleted
|
||||
mock_webhook_store.delete_webhook.assert_called_once_with(sample_webhook)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_admin_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during admin access check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not delete webhook on rate limit
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_webhook_already_exists(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when webhook already exists on resource."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_webhook_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during webhook existence check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_updates_webhook_status_mismatch(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that webhook status is updated when database and API don't match."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
sample_webhook.webhook_exists = True # DB says exists
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(False, None) # API says doesn't exist
|
||||
)
|
||||
|
||||
# Act
|
||||
# Should not raise BreakLoopException when webhook doesn't exist (allows installation)
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook status was updated to match API
|
||||
mock_webhook_store.update_webhook.assert_called_once_with(
|
||||
sample_webhook, {'webhook_exists': False}
|
||||
)
|
||||
|
||||
|
||||
class TestInstallWebhookOnResource:
|
||||
"""Test cases for install_webhook_on_resource function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_success(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test successful webhook installation."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id == 'webhook-id-123'
|
||||
assert status is None
|
||||
mock_gitlab_service.install_webhook.assert_called_once()
|
||||
mock_webhook_store.update_webhook.assert_called_once()
|
||||
# Verify update_webhook was called with correct fields (using keyword arguments)
|
||||
call_args = mock_webhook_store.update_webhook.call_args
|
||||
assert call_args[1]['webhook'] == sample_webhook
|
||||
update_fields = call_args[1]['update_fields']
|
||||
assert update_fields['webhook_exists'] is True
|
||||
assert update_fields['webhook_url'] == GITLAB_WEBHOOK_URL
|
||||
assert 'webhook_secret' in update_fields
|
||||
assert 'webhook_uuid' in update_fields
|
||||
assert 'scopes' in update_fields
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_group_resource(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test webhook installation for a group resource."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-456'
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id == 'webhook-id-123'
|
||||
# Verify install_webhook was called with GROUP type
|
||||
call_args = mock_gitlab_service.install_webhook.call_args
|
||||
assert call_args[1]['resource_type'] == resource_type
|
||||
assert call_args[1]['resource_id'] == resource_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_rate_limited(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when installation is rate limited."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.install_webhook = AsyncMock(
|
||||
return_value=(None, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not update webhook on rate limit
|
||||
mock_webhook_store.update_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_installation_fails(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when webhook installation fails."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.install_webhook = AsyncMock(return_value=(None, None))
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id is None
|
||||
assert status is None
|
||||
# Should not update webhook when installation fails
|
||||
mock_webhook_store.update_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_generates_unique_secrets(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that unique webhook secrets and UUIDs are generated."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act - First call
|
||||
webhook_id1, _ = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Capture first call's values before resetting
|
||||
call1_secret = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_secret']
|
||||
call1_uuid = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_uuid']
|
||||
|
||||
# Reset mocks and call again
|
||||
mock_gitlab_service.install_webhook.reset_mock()
|
||||
mock_webhook_store.update_webhook.reset_mock()
|
||||
|
||||
# Act - Second call
|
||||
webhook_id2, _ = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Capture second call's values
|
||||
call2_secret = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_secret']
|
||||
call2_uuid = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_uuid']
|
||||
|
||||
# Assert - Secrets and UUIDs should be different
|
||||
assert call1_secret != call2_secret
|
||||
assert call1_uuid != call2_uuid
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_uses_correct_webhook_name_and_url(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that correct webhook name and URL are used."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_gitlab_service.install_webhook.call_args
|
||||
assert call_args[1]['webhook_name'] == 'OpenHands Resolver'
|
||||
assert call_args[1]['webhook_url'] == GITLAB_WEBHOOK_URL
|
||||
@@ -25,10 +25,12 @@ def api_key_store(mock_session_maker):
|
||||
|
||||
|
||||
def test_generate_api_key(api_key_store):
|
||||
"""Test that generate_api_key returns a string of the expected length."""
|
||||
"""Test that generate_api_key returns a string with sk-oh- prefix and expected length."""
|
||||
key = api_key_store.generate_api_key(length=32)
|
||||
assert isinstance(key, str)
|
||||
assert len(key) == 32
|
||||
assert key.startswith('sk-oh-')
|
||||
# Total length should be prefix (6 chars) + random part (32 chars) = 38 chars
|
||||
assert len(key) == len('sk-oh-') + 32
|
||||
|
||||
|
||||
def test_create_api_key(api_key_store, mock_session):
|
||||
@@ -90,6 +92,50 @@ def test_validate_api_key_expired(api_key_store, mock_session):
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_api_key_expired_timezone_naive(api_key_store, mock_session):
|
||||
"""Test validating an expired API key with timezone-naive datetime from database."""
|
||||
# Setup
|
||||
api_key = 'test-api-key'
|
||||
mock_key_record = MagicMock()
|
||||
# Simulate timezone-naive datetime as returned from database
|
||||
mock_key_record.expires_at = datetime.now() - timedelta(days=1) # No UTC timezone
|
||||
mock_key_record.id = 1
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = (
|
||||
mock_key_record
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = api_key_store.validate_api_key(api_key)
|
||||
|
||||
# Verify
|
||||
assert result is None
|
||||
mock_session.execute.assert_not_called()
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_api_key_valid_timezone_naive(api_key_store, mock_session):
|
||||
"""Test validating a valid API key with timezone-naive datetime from database."""
|
||||
# Setup
|
||||
api_key = 'test-api-key'
|
||||
user_id = 'test-user-123'
|
||||
mock_key_record = MagicMock()
|
||||
mock_key_record.user_id = user_id
|
||||
# Simulate timezone-naive datetime as returned from database (future date)
|
||||
mock_key_record.expires_at = datetime.now() + timedelta(days=1) # No UTC timezone
|
||||
mock_key_record.id = 1
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = (
|
||||
mock_key_record
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = api_key_store.validate_api_key(api_key)
|
||||
|
||||
# Verify
|
||||
assert result == user_id
|
||||
mock_session.execute.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_validate_api_key_not_found(api_key_store, mock_session):
|
||||
"""Test validating a non-existent API key."""
|
||||
# Setup
|
||||
|
||||
@@ -234,3 +234,53 @@ async def test_middleware_with_other_auth_error(middleware, mock_request):
|
||||
assert 'set-cookie' in result.headers
|
||||
# Logger should be called for non-NoCredentialsError
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_ignores_email_resend_path(
|
||||
middleware, mock_request, mock_response
|
||||
):
|
||||
"""Test middleware ignores /api/email/resend path and doesn't require authentication."""
|
||||
# Arrange
|
||||
mock_request.cookies = {}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.hostname = 'localhost'
|
||||
mock_request.url.path = '/api/email/resend'
|
||||
mock_call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Act
|
||||
result = await middleware(mock_request, mock_call_next)
|
||||
|
||||
# Assert
|
||||
assert result == mock_response
|
||||
mock_call_next.assert_called_once_with(mock_request)
|
||||
# Should not raise NoCredentialsError even without auth cookie
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_ignores_email_resend_path_no_tos_check(
|
||||
middleware, mock_request, mock_response
|
||||
):
|
||||
"""Test middleware doesn't check TOS for /api/email/resend path."""
|
||||
# Arrange
|
||||
mock_request.cookies = {'keycloak_auth': 'test_cookie'}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.hostname = 'localhost'
|
||||
mock_request.url.path = '/api/email/resend'
|
||||
mock_call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
with (
|
||||
patch('server.middleware.jwt.decode') as mock_decode,
|
||||
patch('server.middleware.config') as mock_config,
|
||||
):
|
||||
# Even with accepted_tos=False, should not raise TosNotAcceptedError
|
||||
mock_decode.return_value = {'accepted_tos': False}
|
||||
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
|
||||
|
||||
# Act
|
||||
result = await middleware(mock_request, mock_call_next)
|
||||
|
||||
# Assert
|
||||
assert result == mock_response
|
||||
mock_call_next.assert_called_once_with(mock_request)
|
||||
# Should not raise TosNotAcceptedError for this path
|
||||
|
||||
@@ -136,6 +136,7 @@ async def test_keycloak_callback_user_not_allowed(mock_request):
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
@@ -184,6 +185,7 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
@@ -214,6 +216,84 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
|
||||
mock_posthog.set.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_email_not_verified(mock_request):
|
||||
"""Test keycloak_callback when email is not verified."""
|
||||
# Arrange
|
||||
mock_verify_email = AsyncMock()
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.email.verify_email', mock_verify_email),
|
||||
):
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': False,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_verifier.is_active.return_value = False
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
assert 'email_verification_required=true' in result.headers['location']
|
||||
assert 'user_id=test_user_id' in result.headers['location']
|
||||
mock_verify_email.assert_called_once_with(
|
||||
request=mock_request, user_id='test_user_id', is_auth_flow=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_email_not_verified_missing_field(mock_request):
|
||||
"""Test keycloak_callback when email_verified field is missing (defaults to False)."""
|
||||
# Arrange
|
||||
mock_verify_email = AsyncMock()
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.email.verify_email', mock_verify_email),
|
||||
):
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'identity_provider': 'github',
|
||||
# email_verified field is missing
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_verifier.is_active.return_value = False
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
assert 'email_verification_required=true' in result.headers['location']
|
||||
assert 'user_id=test_user_id' in result.headers['location']
|
||||
mock_verify_email.assert_called_once_with(
|
||||
request=mock_request, user_id='test_user_id', is_auth_flow=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_success_without_offline_token(mock_request):
|
||||
"""Test successful keycloak_callback without valid offline token."""
|
||||
@@ -248,6 +328,7 @@ async def test_keycloak_callback_success_without_offline_token(mock_request):
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
@@ -442,3 +523,418 @@ async def test_logout_without_refresh_token():
|
||||
|
||||
mock_token_manager.logout.assert_not_called()
|
||||
assert 'set-cookie' in result.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_blocked_email_domain(mock_request):
|
||||
"""Test keycloak_callback when email domain is blocked."""
|
||||
# Arrange
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
):
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@colsch.us',
|
||||
'identity_provider': 'github',
|
||||
}
|
||||
)
|
||||
mock_token_manager.disable_keycloak_user = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert 'error' in result.body.decode()
|
||||
assert 'email domain is not allowed' in result.body.decode()
|
||||
mock_domain_blocker.is_domain_blocked.assert_called_once_with('user@colsch.us')
|
||||
mock_token_manager.disable_keycloak_user.assert_called_once_with(
|
||||
'test_user_id', 'user@colsch.us'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_allowed_email_domain(mock_request):
|
||||
"""Test keycloak_callback when email domain is not blocked."""
|
||||
# Arrange
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
mock_domain_blocker.is_domain_blocked.assert_called_once_with(
|
||||
'user@example.com'
|
||||
)
|
||||
mock_token_manager.disable_keycloak_user.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_domain_blocking_inactive(mock_request):
|
||||
"""Test keycloak_callback when domain blocking is not active."""
|
||||
# Arrange
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@colsch.us',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_domain_blocker.is_active.return_value = False
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
mock_domain_blocker.is_domain_blocked.assert_not_called()
|
||||
mock_token_manager.disable_keycloak_user.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_missing_email(mock_request):
|
||||
"""Test keycloak_callback when user info does not contain email."""
|
||||
# Arrange
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
# No email field
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
mock_domain_blocker.is_domain_blocked.assert_not_called()
|
||||
mock_token_manager.disable_keycloak_user.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_duplicate_email_detected(mock_request):
|
||||
"""Test keycloak_callback when duplicate email is detected."""
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
):
|
||||
# Arrange
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'joe+test@example.com',
|
||||
'identity_provider': 'github',
|
||||
}
|
||||
)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(return_value=True)
|
||||
mock_token_manager.delete_keycloak_user = AsyncMock(return_value=True)
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
assert 'duplicated_email=true' in result.headers['location']
|
||||
mock_token_manager.check_duplicate_base_email.assert_called_once_with(
|
||||
'joe+test@example.com', 'test_user_id'
|
||||
)
|
||||
mock_token_manager.delete_keycloak_user.assert_called_once_with('test_user_id')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_duplicate_email_deletion_fails(mock_request):
|
||||
"""Test keycloak_callback when duplicate is detected but deletion fails."""
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
):
|
||||
# Arrange
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'joe+test@example.com',
|
||||
'identity_provider': 'github',
|
||||
}
|
||||
)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(return_value=True)
|
||||
mock_token_manager.delete_keycloak_user = AsyncMock(return_value=False)
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
assert 'duplicated_email=true' in result.headers['location']
|
||||
mock_token_manager.delete_keycloak_user.assert_called_once_with('test_user_id')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_duplicate_check_exception(mock_request):
|
||||
"""Test keycloak_callback when duplicate check raises exception."""
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
):
|
||||
# Arrange
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'joe+test@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
side_effect=Exception('Check failed')
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Should proceed with normal flow despite exception (fail open)
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_no_duplicate_email(mock_request):
|
||||
"""Test keycloak_callback when no duplicate email is found."""
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
):
|
||||
# Arrange
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'joe+test@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(return_value=False)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
mock_token_manager.check_duplicate_base_email.assert_called_once_with(
|
||||
'joe+test@example.com', 'test_user_id'
|
||||
)
|
||||
# Should not delete user when no duplicate found
|
||||
mock_token_manager.delete_keycloak_user.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_no_email_in_user_info(mock_request):
|
||||
"""Test keycloak_callback when email is not in user_info."""
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
):
|
||||
# Arrange
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
# No email field
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state='test_state', request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
# Should not check for duplicate when email is missing
|
||||
mock_token_manager.check_duplicate_base_email.assert_not_called()
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
import stripe
|
||||
from fastapi import HTTPException, Request, status
|
||||
from httpx import HTTPStatusError, Response
|
||||
from httpx import Response
|
||||
from integrations.stripe_service import has_payment_method
|
||||
from server.routes.billing import (
|
||||
CreateBillingSessionResponse,
|
||||
@@ -78,8 +78,6 @@ def mock_subscription_request():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_credits_lite_llm_error():
|
||||
mock_request = Request(scope={'type': 'http', 'state': {'user_id': 'mock_user'}})
|
||||
|
||||
mock_response = Response(
|
||||
status_code=500, json={'error': 'Internal Server Error'}, request=MagicMock()
|
||||
)
|
||||
@@ -88,11 +86,12 @@ async def test_get_credits_lite_llm_error():
|
||||
|
||||
with patch('integrations.stripe_service.STRIPE_API_KEY', 'mock_key'):
|
||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await get_credits(mock_request)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_credits('mock_user')
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert (
|
||||
exc_info.value.response.status_code
|
||||
== status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
exc_info.value.detail
|
||||
== 'Failed to retrieve credit balance from billing service'
|
||||
)
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user