mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
Merge origin/main and fix marketplace_path CI drift
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
202
.agents/skills/cross-repo-testing/SKILL.md
Normal file
202
.agents/skills/cross-repo-testing/SKILL.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
name: cross-repo-testing
|
||||
description: This skill should be used when the user asks to "test a cross-repo feature", "deploy a feature branch to staging", "test SDK against OH Cloud", "e2e test a cloud workspace feature", "test provider tokens", "test secrets inheritance", or when changes span the SDK and OpenHands server repos and need end-to-end validation against a staging deployment.
|
||||
triggers:
|
||||
- cross-repo
|
||||
- staging deployment
|
||||
- feature branch deploy
|
||||
- test against cloud
|
||||
- e2e cloud
|
||||
---
|
||||
|
||||
# Cross-Repo Testing: SDK ↔ OpenHands Cloud
|
||||
|
||||
How to end-to-end test features that span `OpenHands/software-agent-sdk` and `OpenHands/OpenHands` (the Cloud backend).
|
||||
|
||||
## Repository Map
|
||||
|
||||
| Repo | Role | What lives here |
|
||||
|------|------|-----------------|
|
||||
| [`software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Agent core | `openhands-sdk`, `openhands-workspace`, `openhands-tools` packages. `OpenHandsCloudWorkspace` lives here. |
|
||||
| [`OpenHands`](https://github.com/OpenHands/OpenHands) | Cloud backend | FastAPI server (`openhands/app_server/`), sandbox management, auth, enterprise integrations. Deployed as OH Cloud. |
|
||||
| [`deploy`](https://github.com/OpenHands/deploy) | Infrastructure | Helm charts + GitHub Actions that build the enterprise Docker image and deploy to staging/production. |
|
||||
|
||||
**Data flow:** SDK client → OH Cloud API (`/api/v1/...`) → sandbox agent-server (inside runtime container)
|
||||
|
||||
## When You Need This
|
||||
|
||||
There are **two flows** depending on which direction the dependency goes:
|
||||
|
||||
| Flow | When | Example |
|
||||
|------|------|---------|
|
||||
| **A — SDK client → new Cloud API** | The SDK calls an API that doesn't exist yet on production | `workspace.get_llm()` calling `GET /api/v1/users/me?expose_secrets=true` |
|
||||
| **B — OH server → new SDK code** | The Cloud server needs unreleased SDK packages or a new agent-server image | Server consumes a new tool, agent behavior, or workspace method from the SDK |
|
||||
|
||||
Flow A only requires deploying the server PR. Flow B requires pinning the SDK to an unreleased commit in the server PR **and** using the SDK PR's agent-server image. Both flows may apply simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Flow A: SDK Client Tests Against New Cloud API
|
||||
|
||||
Use this when the SDK calls an endpoint that only exists on the server PR branch.
|
||||
|
||||
### A1. Write and test the server-side changes
|
||||
|
||||
In the `OpenHands` repo, implement the new API endpoint(s). Run unit tests:
|
||||
|
||||
```bash
|
||||
cd OpenHands
|
||||
poetry run pytest tests/unit/app_server/test_<relevant>.py -v
|
||||
```
|
||||
|
||||
Push a PR. Wait for the **"Push Enterprise Image" (Docker) CI job** to succeed — this builds `ghcr.io/openhands/enterprise-server:sha-<COMMIT>`.
|
||||
|
||||
### A2. Write the SDK-side changes
|
||||
|
||||
In `software-agent-sdk`, implement the client code (e.g., new methods on `OpenHandsCloudWorkspace`). Run SDK unit tests:
|
||||
|
||||
```bash
|
||||
cd software-agent-sdk
|
||||
pip install -e openhands-sdk -e openhands-workspace
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Push a PR. SDK CI is independent — it doesn't need the server changes to pass unit tests.
|
||||
|
||||
### A3. Deploy the server PR to staging
|
||||
|
||||
See [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) below.
|
||||
|
||||
### A4. Run the SDK e2e test against staging
|
||||
|
||||
See [Running E2E Tests Against Staging](#running-e2e-tests-against-staging) below.
|
||||
|
||||
---
|
||||
|
||||
## Flow B: OH Server Needs Unreleased SDK Code
|
||||
|
||||
Use this when the Cloud server depends on SDK changes that haven't been released to PyPI yet. The server's runtime containers run the `agent-server` image built from the SDK repo, so the server PR must be configured to use the SDK PR's image and packages.
|
||||
|
||||
### B1. Get the SDK PR merged (or identify the commit)
|
||||
|
||||
The SDK PR must have CI pass so its agent-server Docker image is built. The image is tagged with the **merge-commit SHA** from GitHub Actions — NOT the head-commit SHA shown in the PR.
|
||||
|
||||
Find the correct image tag:
|
||||
- Check the SDK PR description for an `AGENT_SERVER_IMAGES` section
|
||||
- Or check the "Consolidate Build Information" CI job for `"short_sha": "<tag>"`
|
||||
|
||||
### B2. Pin SDK packages to the commit in the OpenHands PR
|
||||
|
||||
In the `OpenHands` repo PR, pin all 3 SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`) to the unreleased commit and update the agent-server image tag. This involves editing 3 files and regenerating 3 lock files.
|
||||
|
||||
Follow the **`update-sdk` skill** → "Development: Pin SDK to an Unreleased Commit" section for the full procedure and file-by-file instructions.
|
||||
|
||||
### B3. Wait for the OpenHands enterprise image to build
|
||||
|
||||
Push the pinned changes. The OpenHands CI will build a new enterprise Docker image (`ghcr.io/openhands/enterprise-server:sha-<OH_COMMIT>`) that bundles the unreleased SDK. Wait for the "Push Enterprise Image" job to succeed.
|
||||
|
||||
### B4. Deploy and test
|
||||
|
||||
Follow [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) using the new OpenHands commit SHA.
|
||||
|
||||
### B5. Before merging: remove the pin
|
||||
|
||||
**CI guard:** `check-package-versions.yml` blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields. Before the OpenHands PR can merge, the SDK PR must be merged and released to PyPI, then the pin must be replaced with the released version number.
|
||||
|
||||
---
|
||||
|
||||
## Deploying to a Staging Feature Environment
|
||||
|
||||
The `deploy` repo creates preview environments from OpenHands PRs.
|
||||
|
||||
**Option A — GitHub Actions UI (preferred):**
|
||||
Go to `OpenHands/deploy` → Actions → "Create OpenHands preview PR" → enter the OpenHands PR number. This creates a branch `ohpr-<PR>-<random>` and opens a deploy PR.
|
||||
|
||||
**Option B — Update an existing feature branch:**
|
||||
```bash
|
||||
cd deploy
|
||||
git checkout ohpr-<PR>-<random>
|
||||
# In .github/workflows/deploy.yaml, update BOTH:
|
||||
# OPENHANDS_SHA: "<full-40-char-commit>"
|
||||
# OPENHANDS_RUNTIME_IMAGE_TAG: "<same-commit>-nikolaik"
|
||||
git commit -am "Update OPENHANDS_SHA to <commit>" && git push
|
||||
```
|
||||
|
||||
**Before updating the SHA**, verify the enterprise Docker image exists:
|
||||
```bash
|
||||
gh api repos/OpenHands/OpenHands/actions/runs \
|
||||
--jq '.workflow_runs[] | select(.head_sha=="<COMMIT>") | "\(.name): \(.conclusion)"' \
|
||||
| grep Docker
|
||||
# Must show: "Docker: success"
|
||||
```
|
||||
|
||||
The deploy CI auto-triggers and creates the environment at:
|
||||
```
|
||||
https://ohpr-<PR>-<random>.staging.all-hands.dev
|
||||
```
|
||||
|
||||
**Wait for it to be live:**
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" https://ohpr-<PR>-<random>.staging.all-hands.dev/api/v1/health
|
||||
# 401 = server is up (auth required). DNS may take 1-2 min on first deploy.
|
||||
```
|
||||
|
||||
## Running E2E Tests Against Staging
|
||||
|
||||
**Critical: Feature deployments have their own Keycloak instance.** API keys from `app.all-hands.dev` or `$OPENHANDS_API_KEY` will NOT work. You need a test API key issued by the specific feature deployment's Keycloak.
|
||||
|
||||
**You (the agent) cannot obtain this key yourself** — the feature environment requires interactive browser login with credentials you do not have. You must **ask the user** to:
|
||||
1. Log in to the feature deployment at `https://ohpr-<PR>-<random>.staging.all-hands.dev` in their browser
|
||||
2. Generate a test API key from the UI
|
||||
3. Provide the key to you so you can proceed with e2e testing
|
||||
|
||||
Do **not** attempt to log in via the browser or guess credentials. Wait for the user to supply the key before running any e2e tests.
|
||||
|
||||
```python
|
||||
from openhands.workspace import OpenHandsCloudWorkspace
|
||||
|
||||
STAGING = "https://ohpr-<PR>-<random>.staging.all-hands.dev"
|
||||
|
||||
with OpenHandsCloudWorkspace(
|
||||
cloud_api_url=STAGING,
|
||||
cloud_api_key="<test-api-key-for-this-deployment>",
|
||||
) as workspace:
|
||||
# Test the new feature
|
||||
llm = workspace.get_llm()
|
||||
secrets = workspace.get_secrets()
|
||||
print(f"LLM: {llm.model}, secrets: {list(secrets.keys())}")
|
||||
```
|
||||
|
||||
Or run an example script:
|
||||
```bash
|
||||
OPENHANDS_CLOUD_API_KEY="<key>" \
|
||||
OPENHANDS_CLOUD_API_URL="https://ohpr-<PR>-<random>.staging.all-hands.dev" \
|
||||
python examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py
|
||||
```
|
||||
|
||||
### Recording results
|
||||
|
||||
Both repos support a `.pr/` directory for temporary PR artifacts (design docs, test logs, scripts). These files are automatically removed when the PR is approved — see `.github/workflows/pr-artifacts.yml` and the "PR-Specific Artifacts" section in each repo's `AGENTS.md`.
|
||||
|
||||
Push test output to the `.pr/logs/` directory of whichever repo you're working in:
|
||||
```bash
|
||||
mkdir -p .pr/logs
|
||||
python test_script.py 2>&1 | tee .pr/logs/<test_name>.log
|
||||
git add -f .pr/logs/
|
||||
git commit -m "docs: add e2e test results" && git push
|
||||
```
|
||||
|
||||
Comment on **both PRs** with pass/fail summary and link to logs.
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
| Gotcha | Details |
|
||||
|--------|---------|
|
||||
| **Feature env auth is isolated** | Each `ohpr-*` deployment has its own Keycloak. Production API keys don't work. Agents cannot log in — you must ask the user to provide a test API key from the feature deployment's UI. |
|
||||
| **Two SHAs in deploy.yaml** | `OPENHANDS_SHA` and `OPENHANDS_RUNTIME_IMAGE_TAG` must both be updated. The runtime tag is `<sha>-nikolaik`. |
|
||||
| **Enterprise image must exist** | The Docker CI job on the OpenHands PR must succeed before you can deploy. If it hasn't run, push an empty commit to trigger it. |
|
||||
| **DNS propagation** | First deployment of a new branch takes 1-2 min for DNS. Subsequent deploys are instant. |
|
||||
| **Merge-commit SHA ≠ head SHA** | SDK CI tags Docker images with GitHub Actions' merge-commit SHA, not the PR head SHA. Check the SDK PR description or CI logs for the correct tag. |
|
||||
| **SDK pin blocks merge** | `check-package-versions.yml` prevents merging an OpenHands PR that has `rev` fields in `[tool.poetry.dependencies]`. The SDK must be released to PyPI first. |
|
||||
| **Flow A: stock agent-server is fine** | When only the Cloud API changes, `OpenHandsCloudWorkspace` talks to the Cloud server, not the agent-server. No custom image needed. |
|
||||
| **Flow B: agent-server image is required** | When the server needs new SDK code inside runtime containers, you must pin to the SDK PR's agent-server image. |
|
||||
123
.agents/skills/update-sdk/SKILL.md
Normal file
123
.agents/skills/update-sdk/SKILL.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: update-sdk
|
||||
description: This skill should be used when the user asks to "update SDK", "bump SDK version", "pin SDK to a commit", "test unreleased SDK", "update agent-server image", "bump the version", "prepare a release", "what files change for a release", or needs to know how SDK packages are managed in the OpenHands repository. For detailed reference material, see references/docker-image-locations.md and references/sdk-pinning-examples.md in this skill directory.
|
||||
---
|
||||
|
||||
# Update SDK
|
||||
|
||||
Bump SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`), pin them to unreleased commits for testing, and cut an OpenHands release.
|
||||
|
||||
## Quick Summary — How Many Files Change?
|
||||
|
||||
| Activity | Manual edits | Auto-regenerated | Total |
|
||||
|----------|:------------:|:----------------:|:-----:|
|
||||
| **SDK bump** (released PyPI version) | 2 | 3 | **5** |
|
||||
| **SDK pin** (unreleased git commit) | 3 | 3 | **6** |
|
||||
| **Release commit** (version bump) | 3 | 0 | **3** |
|
||||
|
||||
The 3 auto-regenerated files are always: `poetry.lock`, `uv.lock`, `enterprise/poetry.lock`.
|
||||
|
||||
## SDK Package Bump — 2 Files + 3 Lock Files
|
||||
|
||||
Land as a separate PR before the release. Examples: `929dcc3` (SDK 1.11.5), `cd235cc` (SDK 1.11.4).
|
||||
|
||||
| File | What to change |
|
||||
|------|----------------|
|
||||
| `pyproject.toml` | `openhands-sdk`, `openhands-agent-server`, `openhands-tools` in **two** sections: the `dependencies` array (PEP 508) **and** `[tool.poetry.dependencies]` |
|
||||
| `openhands/app_server/sandbox/sandbox_spec_service.py` | `AGENT_SERVER_IMAGE` constant — set to `ghcr.io/openhands/agent-server:<version>-python` |
|
||||
|
||||
Then regenerate lock files:
|
||||
```bash
|
||||
poetry lock && uv lock && cd enterprise && poetry lock && cd ..
|
||||
```
|
||||
|
||||
## Docker Image Locations — All Hardcoded References
|
||||
|
||||
For the complete inventory of every file containing a hardcoded Docker image tag or repository, see `references/docker-image-locations.md`. Key files that must stay in sync during an SDK bump:
|
||||
|
||||
| File | Image reference | Updated during SDK bump? |
|
||||
|------|----------------|:------------------------:|
|
||||
| `openhands/app_server/sandbox/sandbox_spec_service.py` | `AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:<tag>-python'` | ✅ Yes |
|
||||
| `docker-compose.yml` | `AGENT_SERVER_IMAGE_TAG` default | ✅ Should be |
|
||||
| `containers/dev/compose.yml` | `AGENT_SERVER_IMAGE_REPOSITORY` + `_TAG` defaults | ✅ Should be |
|
||||
|
||||
> **CI enforcement:** `.github/workflows/check-version-consistency.yml` validates version consistency and compose file image references on every PR and push to main.
|
||||
|
||||
### ⚠️ Docker Image Tag Gotcha (merge-commit SHA)
|
||||
|
||||
The SDK CI in `software-agent-sdk` repo tags Docker images with the **GitHub Actions merge-commit SHA**, NOT the PR head-commit SHA. When pinning to an SDK PR branch:
|
||||
|
||||
1. Check the SDK PR description for the actual image tag (look for the `AGENT_SERVER_IMAGES` section)
|
||||
2. Or query the CI logs: the "Consolidate Build Information" job prints `"short_sha": "<tag>"`
|
||||
3. The merge-commit SHA differs from the head SHA shown in the PR
|
||||
|
||||
For released SDK versions, images use a version tag (e.g., `1.12.0-python`) — no merge-commit ambiguity.
|
||||
|
||||
## Cutting a Release — 3 Files
|
||||
|
||||
A release commit updates the version string across 3 files. Gold-standard examples: 1.3.0 (`d063c8c`), 1.4.0 (`495f48b`).
|
||||
|
||||
| File | What to change |
|
||||
|------|----------------|
|
||||
| `pyproject.toml` | `version = "X.Y.Z"` under `[tool.poetry]` |
|
||||
| `frontend/package.json` | `"version": "X.Y.Z"` |
|
||||
| `frontend/package-lock.json` | `"version": "X.Y.Z"` in **two** places (root object and `packages[""]`) |
|
||||
|
||||
> **Note:** `openhands/version.py` reads the version from `pyproject.toml` at runtime — no manual edit needed there.
|
||||
|
||||
### Compose Files (2 files)
|
||||
|
||||
Both compose files should use `ghcr.io/openhands/agent-server` with the current SDK version tag.
|
||||
|
||||
| File | What to verify |
|
||||
|------|----------------|
|
||||
| `docker-compose.yml` | `AGENT_SERVER_IMAGE_REPOSITORY` defaults to agent-server, `AGENT_SERVER_IMAGE_TAG` is current |
|
||||
| `containers/dev/compose.yml` | Same — must use agent-server, not runtime |
|
||||
|
||||
### Release Workflow
|
||||
|
||||
#### Step 1: Verify the SDK bump has landed
|
||||
|
||||
```bash
|
||||
grep -n "openhands-sdk\|openhands-agent-server\|openhands-tools" pyproject.toml
|
||||
grep -n "AGENT_SERVER_IMAGE" openhands/app_server/sandbox/sandbox_spec_service.py
|
||||
grep "AGENT_SERVER_IMAGE_TAG" docker-compose.yml containers/dev/compose.yml
|
||||
```
|
||||
|
||||
#### Step 2: Bump version numbers
|
||||
|
||||
```bash
|
||||
# Edit pyproject.toml, frontend/package.json, frontend/package-lock.json
|
||||
git add pyproject.toml frontend/package.json frontend/package-lock.json
|
||||
git commit -m "Release X.Y.Z"
|
||||
git tag X.Y.Z
|
||||
```
|
||||
|
||||
Create a `saas-rel-X.Y.Z` branch from the tagged commit for the SaaS deployment pipeline.
|
||||
|
||||
#### Step 3: CI builds Docker images automatically
|
||||
|
||||
The `ghcr-build.yml` workflow triggers on tag pushes and produces:
|
||||
- `ghcr.io/openhands/openhands:X.Y.Z`, `X.Y`, `X`, `latest`
|
||||
- `ghcr.io/openhands/runtime:X.Y.Z-nikolaik`, `X.Y-nikolaik`
|
||||
|
||||
The tagging logic lives in `containers/build.sh` — when `GITHUB_REF_NAME` matches a semver pattern (`^[0-9]+\.[0-9]+\.[0-9]+$`), it auto-generates major, major.minor, and `latest` tags.
|
||||
|
||||
## Development: Pin SDK to an Unreleased Commit
|
||||
|
||||
For detailed examples of all pinning formats (commit, branch, uv-only), see `references/sdk-pinning-examples.md`.
|
||||
|
||||
### Files to change (3 manual + 3 lock files)
|
||||
|
||||
| File | What to change |
|
||||
|------|----------------|
|
||||
| `pyproject.toml` | Pin all 3 SDK packages in **both** `dependencies` and `[tool.poetry.dependencies]` |
|
||||
| `openhands/app_server/sandbox/sandbox_spec_service.py` | `AGENT_SERVER_IMAGE` — use the merge-commit SHA tag, NOT the head-commit SHA |
|
||||
| `docker-compose.yml` | `AGENT_SERVER_IMAGE_TAG` default (for local development) |
|
||||
| `poetry.lock` | Auto-regenerated via `poetry lock` |
|
||||
| `uv.lock` | Auto-regenerated via `uv lock` |
|
||||
| `enterprise/poetry.lock` | Auto-regenerated via `cd enterprise && poetry lock` |
|
||||
|
||||
### CI guard
|
||||
|
||||
The `check-package-versions.yml` workflow blocks merging to `main` if `[tool.poetry.dependencies]` contains any `rev` fields. This ensures unreleased SDK pins do not accidentally ship in a release.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Docker Image Locations — Complete Inventory
|
||||
|
||||
Every file in the OpenHands repository containing a hardcoded Docker image tag, repository, or version-pinned image reference. Organized by update cadence.
|
||||
|
||||
## Updated During SDK Bump (must change)
|
||||
|
||||
These files contain image tags that **must** be updated whenever the SDK version or pinned commit changes.
|
||||
|
||||
### `openhands/app_server/sandbox/sandbox_spec_service.py`
|
||||
- **Line:** `AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:<tag>-python'`
|
||||
- **Format:** `<sdk-version>-python` for releases (e.g., `1.12.0-python`), `<7-char-commit-hash>-python` for dev pins
|
||||
- **Source of truth** for which agent-server image the app server pulls at runtime
|
||||
- **⚠️ Gotcha:** When pinning to an SDK PR, the image tag is the **merge-commit SHA** from GitHub Actions, not the PR head-commit SHA. Check the SDK PR description or CI logs for the correct tag.
|
||||
|
||||
### `docker-compose.yml`
|
||||
- **Lines:**
|
||||
```yaml
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-<tag>-python}
|
||||
```
|
||||
- Used by `docker compose up` for local development
|
||||
|
||||
### `containers/dev/compose.yml`
|
||||
- **Lines:**
|
||||
```yaml
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-<tag>-python}
|
||||
```
|
||||
- Used by the dev container setup
|
||||
- **Known issue:** On main as of 1.4.0, this file still points to `ghcr.io/openhands/runtime` instead of `agent-server`, and the tag is `1.2-nikolaik` (stale from the V0 era). The `check-version-consistency.yml` CI workflow catches this.
|
||||
|
||||
## Updated During Release Commit (version string only)
|
||||
|
||||
### `pyproject.toml`
|
||||
- **Line:** `version = "X.Y.Z"` under `[tool.poetry]`
|
||||
- The Python version is derived from this at runtime via `openhands/version.py`
|
||||
|
||||
### `frontend/package.json`
|
||||
- **Line:** `"version": "X.Y.Z"`
|
||||
|
||||
### `frontend/package-lock.json`
|
||||
- **Two places:** root `"version": "X.Y.Z"` and `packages[""].version`
|
||||
|
||||
## Dynamic References (auto-derived, no manual update)
|
||||
|
||||
### `openhands/version.py`
|
||||
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
|
||||
|
||||
### `openhands/resolver/issue_resolver.py`
|
||||
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
|
||||
|
||||
### `openhands/runtime/utils/runtime_build.py`
|
||||
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
|
||||
|
||||
### `.github/scripts/update_pr_description.sh`
|
||||
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
|
||||
|
||||
### `enterprise/Dockerfile`
|
||||
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
|
||||
|
||||
## V0 Legacy Files (separate update cadence)
|
||||
|
||||
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
|
||||
|
||||
### `Development.md`
|
||||
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
|
||||
|
||||
### `openhands/runtime/impl/kubernetes/README.md`
|
||||
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
|
||||
|
||||
### `enterprise/enterprise_local/README.md`
|
||||
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
|
||||
|
||||
### `third_party/runtime/impl/daytona/README.md`
|
||||
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
|
||||
|
||||
## Image Registries
|
||||
|
||||
| Registry | Usage |
|
||||
|----------|-------|
|
||||
| `ghcr.io/openhands/agent-server` | V1 agent-server (sandbox) — built by SDK repo CI |
|
||||
| `ghcr.io/openhands/openhands` | Main app image — built by `ghcr-build.yml` |
|
||||
| `ghcr.io/openhands/runtime` | V0 runtime sandbox — built by `ghcr-build.yml` |
|
||||
| `docker.openhands.dev/openhands/*` | Mirror/CDN for the above images |
|
||||
103
.agents/skills/update-sdk/references/sdk-pinning-examples.md
Normal file
103
.agents/skills/update-sdk/references/sdk-pinning-examples.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# SDK Pinning Examples
|
||||
|
||||
Examples from real commits showing how to pin SDK packages to unreleased commits, branches, or released versions.
|
||||
|
||||
## Pin to a Specific Commit
|
||||
|
||||
Example from commit `169fb76` (pinning all 3 packages to SDK commit `100e9af`):
|
||||
|
||||
### `dependencies` array (PEP 508 format)
|
||||
|
||||
```toml
|
||||
"openhands-agent-server @ git+https://github.com/OpenHands/software-agent-sdk.git@100e9af#subdirectory=openhands-agent-server",
|
||||
"openhands-sdk @ git+https://github.com/OpenHands/software-agent-sdk.git@100e9af#subdirectory=openhands-sdk",
|
||||
"openhands-tools @ git+https://github.com/OpenHands/software-agent-sdk.git@100e9af#subdirectory=openhands-tools",
|
||||
```
|
||||
|
||||
### `[tool.poetry.dependencies]` (Poetry format)
|
||||
|
||||
```toml
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "100e9af", subdirectory = "openhands-sdk" }
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "100e9af", subdirectory = "openhands-agent-server" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "100e9af", subdirectory = "openhands-tools" }
|
||||
```
|
||||
|
||||
### `openhands/app_server/sandbox/sandbox_spec_service.py`
|
||||
|
||||
```python
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:<merge-commit-sha>-python'
|
||||
```
|
||||
|
||||
**⚠️ Important:** The image tag is the **merge-commit SHA** from the SDK CI, not the commit hash used in `pyproject.toml`. Look up the correct tag from the SDK PR description or CI logs.
|
||||
|
||||
## Pin to a Branch
|
||||
|
||||
Example from commit `430ee1c` (pinning to branch `openhands/issue-2228-sdk-settings-schema`):
|
||||
|
||||
### `[tool.poetry.dependencies]`
|
||||
|
||||
```toml
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-sdk" }
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-agent-server" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-tools" }
|
||||
```
|
||||
|
||||
## Using `[tool.uv.sources]` Override
|
||||
|
||||
When only `uv` needs the override (keep PyPI versions in the main arrays), add a `[tool.uv.sources]` section. Example from commit `1daca49`:
|
||||
|
||||
```toml
|
||||
[tool.uv.sources]
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/software-agent-sdk.git", subdirectory = "openhands-sdk", rev = "4170cca" }
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/software-agent-sdk.git", subdirectory = "openhands-agent-server", rev = "4170cca" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/software-agent-sdk.git", subdirectory = "openhands-tools", rev = "4170cca" }
|
||||
```
|
||||
|
||||
## Released PyPI Version (standard release)
|
||||
|
||||
Example from commit `929dcc3` (SDK 1.11.5):
|
||||
|
||||
### `dependencies` array
|
||||
|
||||
```toml
|
||||
"openhands-agent-server==1.11.5",
|
||||
"openhands-sdk==1.11.5",
|
||||
"openhands-tools==1.11.5",
|
||||
```
|
||||
|
||||
### `[tool.poetry.dependencies]`
|
||||
|
||||
```toml
|
||||
openhands-sdk = "1.11.5"
|
||||
openhands-agent-server = "1.11.5"
|
||||
openhands-tools = "1.11.5"
|
||||
```
|
||||
|
||||
### `openhands/app_server/sandbox/sandbox_spec_service.py`
|
||||
|
||||
For released versions, the image tag uses the version number:
|
||||
|
||||
```python
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.11.5-python'
|
||||
```
|
||||
|
||||
However, **some releases use a commit-hash tag** even for the released version. Check which tag format exists on GHCR. Example from `929dcc3`:
|
||||
|
||||
```python
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:010e847-python'
|
||||
```
|
||||
|
||||
## Regenerate Lock Files
|
||||
|
||||
After any change to `pyproject.toml`, always regenerate:
|
||||
|
||||
```bash
|
||||
poetry lock
|
||||
uv lock
|
||||
cd enterprise && poetry lock && cd ..
|
||||
```
|
||||
|
||||
## CI Guards
|
||||
|
||||
- **`check-package-versions.yml`**: Blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields (prevents shipping unreleased SDK pins)
|
||||
- **`check-version-consistency.yml`**: Validates version strings match across `pyproject.toml`, `package.json`, `package-lock.json`, and verifies compose files use `agent-server` images
|
||||
122
.github/workflows/check-version-consistency.yml
vendored
Normal file
122
.github/workflows/check-version-consistency.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
name: Check Version Consistency
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-version-consistency:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Check version and Docker image tag consistency
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# ── 1. Extract the canonical version from pyproject.toml ──────────
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
pyproject = tomllib.load(f)
|
||||
version = pyproject["tool"]["poetry"]["version"]
|
||||
major_minor = ".".join(version.split(".")[:2])
|
||||
print(f"📦 pyproject.toml version: {version} (major.minor: {major_minor})")
|
||||
|
||||
# ── 2. Check frontend/package.json ────────────────────────────────
|
||||
with open("frontend/package.json") as f:
|
||||
pkg = json.load(f)
|
||||
if pkg["version"] != version:
|
||||
errors.append(
|
||||
f"frontend/package.json version is '{pkg['version']}', expected '{version}'"
|
||||
)
|
||||
else:
|
||||
print(f" ✔ frontend/package.json: {pkg['version']}")
|
||||
|
||||
# ── 3. Check frontend/package-lock.json (2 places) ───────────────
|
||||
with open("frontend/package-lock.json") as f:
|
||||
lock = json.load(f)
|
||||
for key, val in [
|
||||
("root.version", lock.get("version")),
|
||||
('packages[""].version', lock.get("packages", {}).get("", {}).get("version")),
|
||||
]:
|
||||
if val != version:
|
||||
errors.append(
|
||||
f"frontend/package-lock.json {key} is '{val}', expected '{version}'"
|
||||
)
|
||||
else:
|
||||
print(f" ✔ frontend/package-lock.json {key}: {val}")
|
||||
|
||||
# ── 4. Check compose files use agent-server images ─────────────────
|
||||
# Both compose files should use ghcr.io/.../agent-server (not runtime).
|
||||
# Agent-server tags use SDK version (e.g. "1.12.0-python") or commit
|
||||
# hashes (e.g. "31536c8-python") — both are acceptable.
|
||||
repo_pattern = re.compile(r"AGENT_SERVER_IMAGE_REPOSITORY[^}]*:-([^}]+)")
|
||||
tag_pattern = re.compile(r"AGENT_SERVER_IMAGE_TAG:-([^}]+)")
|
||||
|
||||
for filepath in ["docker-compose.yml", "containers/dev/compose.yml"]:
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
warnings.append(f"{filepath}: file not found")
|
||||
continue
|
||||
|
||||
repos = repo_pattern.findall(content)
|
||||
tags = tag_pattern.findall(content)
|
||||
|
||||
if not repos:
|
||||
warnings.append(f"{filepath}: no AGENT_SERVER_IMAGE_REPOSITORY default found")
|
||||
else:
|
||||
repo = repos[0]
|
||||
if "agent-server" not in repo:
|
||||
errors.append(
|
||||
f"{filepath}: AGENT_SERVER_IMAGE_REPOSITORY defaults to '{repo}', "
|
||||
f"expected an agent-server image (not runtime)"
|
||||
)
|
||||
else:
|
||||
print(f" ✔ {filepath} image repository: {repo}")
|
||||
|
||||
if not tags:
|
||||
warnings.append(f"{filepath}: no AGENT_SERVER_IMAGE_TAG default found")
|
||||
else:
|
||||
tag = tags[0]
|
||||
if not tag:
|
||||
errors.append(f"{filepath}: AGENT_SERVER_IMAGE_TAG default is empty")
|
||||
else:
|
||||
print(f" ✔ {filepath} image tag: {tag}")
|
||||
|
||||
# ── 5. Report ─────────────────────────────────────────────────────
|
||||
print()
|
||||
if warnings:
|
||||
print("⚠ Warnings:")
|
||||
for w in warnings:
|
||||
print(f" {w}")
|
||||
print()
|
||||
|
||||
if errors:
|
||||
print("❌ FAILED: Version inconsistencies found:\n")
|
||||
for e in errors:
|
||||
print(f" ✖ {e}")
|
||||
print(
|
||||
"\nAll version numbers and Docker image tags must be consistent."
|
||||
"\nSee .agents/skills/update-sdk/SKILL.md for the full checklist."
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ All version numbers and Docker image tags are consistent.")
|
||||
PY
|
||||
136
.github/workflows/pr-artifacts.yml
vendored
Normal file
136
.github/workflows/pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: PR Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger for testing
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
# Auto-remove .pr/ directory when a reviewer approves
|
||||
cleanup-on-approval:
|
||||
concurrency:
|
||||
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if fork PR
|
||||
id: check-fork
|
||||
run: |
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
|
||||
echo "is_fork=true" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
|
||||
else
|
||||
echo "is_fork=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
|
||||
- name: Remove .pr/ directory
|
||||
id: remove
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
git config user.name "allhands-bot"
|
||||
git config user.email "allhands-bot@users.noreply.github.com"
|
||||
git rm -rf .pr/
|
||||
git commit -m "chore: Remove PR-only artifacts [automated]"
|
||||
git push || {
|
||||
echo "::error::Failed to push cleanup commit. Check branch protection rules."
|
||||
exit 1
|
||||
}
|
||||
echo "removed=true" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Removed .pr/ directory"
|
||||
else
|
||||
echo "removed=false" >> $GITHUB_OUTPUT
|
||||
echo "::notice::No .pr/ directory to remove"
|
||||
fi
|
||||
|
||||
- name: Update PR comment after cleanup
|
||||
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = `${marker}
|
||||
✅ **PR Artifacts Cleaned Up**
|
||||
|
||||
The \`.pr/\` directory has been automatically removed.
|
||||
`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
# Warn if .pr/ directory exists (will be auto-removed on approval)
|
||||
check-pr-artifacts:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Check for .pr/ directory
|
||||
id: check
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.check.outputs.exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = `${marker}
|
||||
📁 **PR Artifacts Notice**
|
||||
|
||||
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
|
||||
|
||||
> For fork PRs: Manual removal is required before merging.
|
||||
`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (!existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -234,6 +234,8 @@ yarn-error.log*
|
||||
|
||||
logs
|
||||
|
||||
ralph/
|
||||
|
||||
# agent
|
||||
.envrc
|
||||
/workspace
|
||||
|
||||
63
AGENTS.md
63
AGENTS.md
@@ -36,9 +36,45 @@ then re-run the command to ensure it passes. Common issues include:
|
||||
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
|
||||
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
|
||||
|
||||
## PR-Specific Artifacts (`.pr/` directory)
|
||||
|
||||
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
.pr/
|
||||
├── design.md # Design decisions and architecture notes
|
||||
├── analysis.md # Investigation or debugging notes
|
||||
├── logs/ # Test output or CI logs for reviewer reference
|
||||
└── notes.md # Any other PR-specific content
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Notification**: When `.pr/` exists, a comment is posted to the PR conversation alerting reviewers
|
||||
2. **Auto-cleanup**: When the PR is approved, the `.pr/` directory is automatically removed via `.github/workflows/pr-artifacts.yml`
|
||||
3. **Fork PRs**: Auto-cleanup cannot push to forks, so manual removal is required before merging
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Do NOT put anything in `.pr/` that needs to be preserved after merge
|
||||
- The `.pr/` check passes (green ✅) during development — it only posts a notification, not a blocking error
|
||||
- For fork PRs: You must manually remove `.pr/` before the PR can be merged
|
||||
|
||||
### When to Use
|
||||
|
||||
- Complex refactoring that benefits from written design rationale
|
||||
- Debugging sessions where you want to document your investigation
|
||||
- E2E test results or logs that demonstrate a cross-repo feature works
|
||||
- Feature implementations that need temporary planning docs
|
||||
- Any analysis that helps reviewers understand the PR but isn't needed long-term
|
||||
|
||||
## Repository Structure
|
||||
Backend:
|
||||
- Located in the `openhands` directory
|
||||
- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`.
|
||||
- For V1 web-app docs, LLM setup should point users to the Settings UI.
|
||||
- Testing:
|
||||
- All tests are in `tests/unit/test_*.py`
|
||||
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
|
||||
@@ -342,3 +378,30 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo
|
||||
- Models appear in CLI provider selection based on the verified arrays
|
||||
- The `organize_models_and_providers` function groups models by provider
|
||||
- Default model selection prioritizes verified models for each provider
|
||||
|
||||
### Sandbox Settings API (SDK Credential Inheritance)
|
||||
|
||||
The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials
|
||||
(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox,
|
||||
never through the SDK client.
|
||||
|
||||
#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`):
|
||||
- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`)
|
||||
- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only)
|
||||
|
||||
Auth requirements for `expose_secrets=true`:
|
||||
- Bearer token (proves user identity via `OPENHANDS_API_KEY`)
|
||||
- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user)
|
||||
|
||||
Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key.
|
||||
|
||||
#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`):
|
||||
- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values)
|
||||
- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox)
|
||||
|
||||
#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()`
|
||||
|
||||
#### Related SDK code (in `software-agent-sdk` repo):
|
||||
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
|
||||
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
|
||||
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`
|
||||
|
||||
168
CONTRIBUTING.md
168
CONTRIBUTING.md
@@ -1,83 +1,105 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions.
|
||||
Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey.
|
||||
|
||||
## Understanding OpenHands's CodeBase
|
||||
## Our Vision
|
||||
|
||||
To understand the codebase, please refer to the README in each module:
|
||||
- [frontend](./frontend/README.md)
|
||||
- [openhands](./openhands/README.md)
|
||||
- [agenthub](./openhands/agenthub/README.md)
|
||||
- [server](./openhands/server/README.md)
|
||||
The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone.
|
||||
|
||||
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
|
||||
We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources.
|
||||
|
||||
## Setting up Your Development Environment
|
||||
## Getting Started
|
||||
|
||||
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
|
||||
you how to set up a development workflow.
|
||||
### Quick Ways to Contribute
|
||||
|
||||
## How Can I Contribute?
|
||||
- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter
|
||||
- **Give feedback** using the thumbs-up/thumbs-down buttons after each session
|
||||
- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands)
|
||||
- **Share OpenHands** with other developers
|
||||
|
||||
There are many ways that you can contribute:
|
||||
### Set Up Your Development Environment
|
||||
|
||||
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
|
||||
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
|
||||
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
|
||||
- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+
|
||||
- **Quick setup**: `make build`
|
||||
- **Run locally**: `make run`
|
||||
- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts
|
||||
|
||||
## What Can I Build?
|
||||
Full details in our [Development Guide](./Development.md).
|
||||
|
||||
Here are a few ways you can help improve the codebase.
|
||||
### Find Your First Issue
|
||||
|
||||
#### UI/UX
|
||||
- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue)
|
||||
- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks
|
||||
- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help
|
||||
|
||||
We're always looking to improve the look and feel of the application. If you've got a small fix
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
## Understanding the Codebase
|
||||
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
|
||||
to gather consensus from our design team first.
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
|
||||
#### Improving the agent
|
||||
## What Can You Build?
|
||||
|
||||
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
|
||||
### Frontend & UI/UX
|
||||
- React & TypeScript development
|
||||
- UI/UX improvements
|
||||
- Mobile responsiveness
|
||||
- Component libraries
|
||||
|
||||
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
|
||||
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
|
||||
locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent
|
||||
is getting better over time.
|
||||
For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first.
|
||||
|
||||
We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation
|
||||
channel in Slack to learn more.
|
||||
### Agent Development
|
||||
- Prompt engineering
|
||||
- New agent types
|
||||
- Agent evaluation
|
||||
- Multi-agent systems
|
||||
|
||||
#### Adding a new agent
|
||||
We use [SWE-bench](https://www.swebench.com/) to evaluate agents.
|
||||
|
||||
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
|
||||
to help expand the capabilities of OpenHands.
|
||||
### Backend & Infrastructure
|
||||
- Python development
|
||||
- Runtime systems (Docker containers, sandboxes)
|
||||
- Cloud integrations
|
||||
- Performance optimization
|
||||
|
||||
#### Adding a new runtime
|
||||
### Testing & Quality Assurance
|
||||
- Unit testing
|
||||
- Integration testing
|
||||
- Bug hunting
|
||||
- Performance testing
|
||||
|
||||
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
|
||||
to do this by default. But there are other ways of creating a sandbox for the agent.
|
||||
### Documentation & Education
|
||||
- Technical documentation
|
||||
- Translation
|
||||
- Community support
|
||||
|
||||
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
|
||||
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
|
||||
## Pull Request Process
|
||||
|
||||
#### Testing
|
||||
### Small Improvements
|
||||
- Quick review and approval
|
||||
- Ensure CI tests pass
|
||||
- Include clear description of changes
|
||||
|
||||
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
|
||||
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
|
||||
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
|
||||
quality of the project.
|
||||
### Core Agent Changes
|
||||
These are evaluated based on:
|
||||
- **Accuracy** - Does it make the agent better at solving problems?
|
||||
- **Efficiency** - Does it improve speed or reduce resource usage?
|
||||
- **Code Quality** - Is the code maintainable and well-tested?
|
||||
|
||||
Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first.
|
||||
|
||||
## Sending Pull Requests to OpenHands
|
||||
|
||||
You'll need to fork our repository to send us a Pull Request. You can learn more
|
||||
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
|
||||
|
||||
### Pull Request title
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
|
||||
### Pull Request Title Format
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
@@ -95,45 +117,27 @@ For example, a PR title could be:
|
||||
- `refactor: modify package path`
|
||||
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
|
||||
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
### Pull Request Description
|
||||
|
||||
### Pull Request description
|
||||
- Explain what the PR does and why
|
||||
- Link to related issues
|
||||
- Include screenshots for UI changes
|
||||
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
|
||||
please include a short message that we can add to our changelog
|
||||
|
||||
- If your PR is small (such as a typo fix), you can go brief.
|
||||
- If it contains a lot of changes, it's better to write more details.
|
||||
## Becoming a Maintainer
|
||||
|
||||
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
|
||||
please include a short message that we can add to our changelog.
|
||||
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team.
|
||||
The process for this is as follows:
|
||||
|
||||
## How to Make Effective Contributions
|
||||
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
|
||||
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
|
||||
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
|
||||
|
||||
### Opening Issues
|
||||
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
|
||||
|
||||
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
|
||||
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
|
||||
the community has interest/effort for.
|
||||
## Need Help?
|
||||
|
||||
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
|
||||
|
||||
### Making Pull Requests
|
||||
|
||||
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
|
||||
|
||||
#### For Small Improvements
|
||||
|
||||
Small improvements with few downsides are typically reviewed and approved quickly.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
|
||||
before getting a review.
|
||||
|
||||
#### For Core Agent Changes
|
||||
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
|
||||
evaluated based on three key metrics:
|
||||
|
||||
1. **Accuracy**
|
||||
2. **Efficiency**
|
||||
3. **Code Complexity**
|
||||
|
||||
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
|
||||
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
|
||||
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
|
||||
- **Slack**: [Join our community](https://openhands.dev/joinslack)
|
||||
- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues)
|
||||
- **Email**: contact@openhands.dev
|
||||
|
||||
382
Development.md
382
Development.md
@@ -6,22 +6,196 @@ If you wish to contribute your changes, check out the
|
||||
on how to clone and setup the project initially before moving on. Otherwise,
|
||||
you can clone the OpenHands project directly.
|
||||
|
||||
## Start the Server for Development
|
||||
## Choose Your Setup
|
||||
|
||||
### 1. Requirements
|
||||
Select your operating system to see the specific setup instructions:
|
||||
|
||||
- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
|
||||
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
|
||||
- [Python](https://www.python.org/downloads/) = 3.12
|
||||
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
|
||||
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
|
||||
- OS-specific dependencies:
|
||||
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
|
||||
- WSL: netcat => `sudo apt-get install netcat`
|
||||
- [macOS](#macos-setup)
|
||||
- [Linux](#linux-setup)
|
||||
- [Windows WSL](#windows-wsl-setup)
|
||||
- [Dev Container](#dev-container)
|
||||
- [Developing in Docker](#developing-in-docker)
|
||||
- [No sudo access?](#develop-without-sudo-access)
|
||||
|
||||
Make sure you have all these dependencies installed before moving on to `make build`.
|
||||
---
|
||||
|
||||
#### Dev container
|
||||
## macOS Setup
|
||||
|
||||
### 1. Install Prerequisites
|
||||
|
||||
You'll need the following installed:
|
||||
|
||||
- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this).
|
||||
- **Node.js >= 22** — `brew install node`
|
||||
- **Poetry >= 1.8** — `brew install poetry`
|
||||
- **Docker Desktop** — `brew install --cask docker`
|
||||
- After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"**
|
||||
|
||||
### 2. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 3. Configure the Language Model
|
||||
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
|
||||
For the V1 web app, start OpenHands and configure your model and API key in the Settings UI.
|
||||
|
||||
If you are running headless or CLI workflows, you can prepare local defaults with:
|
||||
|
||||
```bash
|
||||
make setup-config
|
||||
```
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`.
|
||||
|
||||
---
|
||||
|
||||
## Linux Setup
|
||||
|
||||
This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly.
|
||||
|
||||
### 1. Install Prerequisites
|
||||
|
||||
```bash
|
||||
# Update package list
|
||||
sudo apt update
|
||||
|
||||
# Install system dependencies
|
||||
sudo apt install -y build-essential curl netcat software-properties-common
|
||||
|
||||
# Install Python 3.12
|
||||
# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if
|
||||
# python3.12 --version already works on your system.
|
||||
# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older:
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
sudo apt update
|
||||
sudo apt install -y python3.12 python3.12-dev python3.12-venv
|
||||
|
||||
# Install Node.js 22.x
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
# Add Poetry to your PATH
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Install Docker
|
||||
# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/
|
||||
# Quick version:
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for Docker group changes to take effect
|
||||
```
|
||||
|
||||
### 2. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 3. Configure the Language Model
|
||||
|
||||
See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI.
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Windows WSL Setup
|
||||
|
||||
WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations.
|
||||
|
||||
### 1. Install WSL2
|
||||
|
||||
**Option A: Windows 11 (Microsoft Store)**
|
||||
The easiest way on Windows 11:
|
||||
1. Open the **Microsoft Store** app
|
||||
2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"**
|
||||
3. Click **Install**
|
||||
4. Launch Ubuntu from the Start menu
|
||||
|
||||
**Option B: PowerShell**
|
||||
```powershell
|
||||
# Run this in PowerShell as Administrator
|
||||
wsl --install -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
After installation, restart your computer and open Ubuntu.
|
||||
|
||||
### 2. Install Prerequisites (in WSL Ubuntu)
|
||||
|
||||
Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below.
|
||||
|
||||
### 3. Configure Docker for WSL2
|
||||
|
||||
1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
|
||||
2. Open Docker Desktop > Settings > General
|
||||
3. Enable: "Use the WSL 2 based engine"
|
||||
4. Go to Settings > Resources > WSL Integration
|
||||
5. Enable integration with your Ubuntu distribution
|
||||
|
||||
**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower.
|
||||
|
||||
### 4. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 5. Configure the Language Model
|
||||
|
||||
See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows.
|
||||
|
||||
### 6. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
Access the frontend at `http://localhost:3001` from your Windows browser.
|
||||
|
||||
---
|
||||
|
||||
## Dev Container
|
||||
|
||||
There is a [dev container](https://containers.dev/) available which provides a
|
||||
pre-configured environment with all the necessary dependencies installed if you
|
||||
@@ -32,7 +206,38 @@ extension installed, you can open the project in a dev container by using the
|
||||
_Dev Container: Reopen in Container_ command from the Command Palette
|
||||
(Ctrl+Shift+P).
|
||||
|
||||
#### Develop without sudo access
|
||||
---
|
||||
|
||||
## Developing in Docker
|
||||
|
||||
If you don't want to install dependencies on your host machine, you can develop inside a Docker container.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
For more details, see the [dev container documentation](./containers/dev/README.md).
|
||||
|
||||
### Alternative: Docker Run
|
||||
|
||||
If you just want to run OpenHands without setting up a dev environment:
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
If you don't have `make` installed, run:
|
||||
|
||||
```bash
|
||||
cd ./containers/dev
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Develop without sudo access
|
||||
|
||||
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
|
||||
`conda` or `mamba` to manage the packages for you:
|
||||
@@ -48,159 +253,90 @@ mamba install conda-forge::nodejs
|
||||
mamba install conda-forge::poetry
|
||||
```
|
||||
|
||||
### 2. Build and Setup The Environment
|
||||
---
|
||||
|
||||
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
|
||||
that OpenHands is ready to run on your system:
|
||||
## Running OpenHands with OpenHands
|
||||
|
||||
You can use OpenHands to develop and improve OpenHands itself!
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
make build
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
```
|
||||
|
||||
### 3. Configuring the Language Model
|
||||
Access the interface at:
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
For external access:
|
||||
```bash
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
To configure the LM of your choice, run:
|
||||
---
|
||||
|
||||
## LLM Debugging
|
||||
|
||||
If you encounter issues with the Language Model, enable debug logging:
|
||||
|
||||
```bash
|
||||
make setup-config
|
||||
export DEBUG=1
|
||||
# Restart the backend
|
||||
make start-backend
|
||||
```
|
||||
|
||||
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
|
||||
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
|
||||
please set the model in the UI.
|
||||
Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting.
|
||||
|
||||
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
|
||||
variables in your terminal. The final configurations are set from highest to lowest priority:
|
||||
Environment variables > config.toml variables > default variables
|
||||
---
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
## Testing
|
||||
|
||||
### 4. Running the application
|
||||
|
||||
#### Option A: Run the Full Application
|
||||
|
||||
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
#### Option B: Individual Server Startup
|
||||
|
||||
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
|
||||
backend-related tasks or configurations.
|
||||
|
||||
```bash
|
||||
make start-backend
|
||||
```
|
||||
|
||||
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
|
||||
components or interface enhancements.
|
||||
```bash
|
||||
make start-frontend
|
||||
```
|
||||
|
||||
### 5. Running OpenHands with OpenHands
|
||||
|
||||
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
1. **Build and run OpenHands:**
|
||||
|
||||
```bash
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
```
|
||||
|
||||
2. **Access the interface:**
|
||||
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
|
||||
3. **Configure for external access (if needed):**
|
||||
```bash
|
||||
# For external access (e.g., cloud environments)
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
### 6. LLM Debugging
|
||||
|
||||
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
|
||||
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
|
||||
|
||||
### 7. Help
|
||||
|
||||
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
### 8. Testing
|
||||
|
||||
To run tests, refer to the following:
|
||||
|
||||
#### Unit tests
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
poetry run pytest ./tests/unit/test_*.py
|
||||
```
|
||||
|
||||
### 9. Add or update dependency
|
||||
---
|
||||
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`.
|
||||
## Adding Dependencies
|
||||
|
||||
### 10. Use existing Docker image
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
|
||||
2. Update the lock file: `poetry lock --no-update`
|
||||
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
---
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
|
||||
## Using Existing Docker Images
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
TL;DR
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
|
||||
```bash
|
||||
make docker-dev
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
|
||||
See more details [here](./containers/dev/README.md).
|
||||
---
|
||||
|
||||
If you are just interested in running `OpenHands` without installing all the required tools on your host.
|
||||
## Help
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
make help
|
||||
```
|
||||
|
||||
If you do not have `make` on your host, run:
|
||||
|
||||
```bash
|
||||
cd ./containers/dev
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though.
|
||||
---
|
||||
|
||||
## Key Documentation Resources
|
||||
|
||||
Here's a guide to the important documentation files in the repository:
|
||||
|
||||
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
|
||||
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
|
||||
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
|
||||
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
|
||||
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
|
||||
- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules
|
||||
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
|
||||
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
@@ -12,8 +12,8 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/runtime}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.2-nikolaik}
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-31536c8-python}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -51,6 +51,6 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
|
||||
## User ID vs User Token
|
||||
|
||||
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completely ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
|
||||
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.
|
||||
|
||||
13
enterprise/doc/architecture/README.md
Normal file
13
enterprise/doc/architecture/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Enterprise Architecture Documentation
|
||||
|
||||
Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment
|
||||
- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations
|
||||
|
||||
## Related Documentation
|
||||
|
||||
For core OpenHands architecture (applicable to all deployments), see:
|
||||
- [Core Architecture Documentation](../../../openhands/architecture/README.md)
|
||||
58
enterprise/doc/architecture/authentication.md
Normal file
58
enterprise/doc/architecture/authentication.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Authentication Flow (SaaS Deployment)
|
||||
|
||||
OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as User (Browser)
|
||||
participant App as App Server
|
||||
participant KC as Keycloak
|
||||
participant IdP as Identity Provider<br/>(GitHub, Google, etc.)
|
||||
participant DB as User Database
|
||||
|
||||
Note over User,DB: OAuth 2.0 / OIDC Authentication Flow
|
||||
|
||||
User->>App: Access OpenHands
|
||||
App->>User: Redirect to Keycloak
|
||||
User->>KC: Login request
|
||||
KC->>User: Show login options
|
||||
User->>KC: Select provider (e.g., GitHub)
|
||||
KC->>IdP: OAuth redirect
|
||||
User->>IdP: Authenticate
|
||||
IdP-->>KC: OAuth callback + tokens
|
||||
Note over KC: Create/update user session
|
||||
KC-->>User: Redirect with auth code
|
||||
User->>App: Auth code
|
||||
App->>KC: Exchange code for tokens
|
||||
KC-->>App: Access token + Refresh token
|
||||
Note over App: Create signed JWT cookie
|
||||
App->>DB: Store/update user record
|
||||
App-->>User: Set keycloak_auth cookie
|
||||
|
||||
Note over User,DB: Subsequent Requests
|
||||
|
||||
User->>App: Request with cookie
|
||||
Note over App: Verify JWT signature
|
||||
App->>KC: Validate token (if needed)
|
||||
KC-->>App: Token valid
|
||||
Note over App: Extract user context
|
||||
App-->>User: Authorized response
|
||||
```
|
||||
|
||||
### Authentication Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Keycloak** | Identity provider, SSO, token management | External service |
|
||||
| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` |
|
||||
| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` |
|
||||
| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` |
|
||||
| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` |
|
||||
|
||||
### Token Flow
|
||||
|
||||
1. **Keycloak Access Token**: Short-lived token for API access
|
||||
2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens
|
||||
3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens
|
||||
4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations)
|
||||
88
enterprise/doc/architecture/external-integrations.md
Normal file
88
enterprise/doc/architecture/external-integrations.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# External Integrations
|
||||
|
||||
OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Ext as External Service<br/>(GitHub/Slack/Jira)
|
||||
participant App as App Server
|
||||
participant IntRouter as Integration Router
|
||||
participant Manager as Integration Manager
|
||||
participant Conv as Conversation Service
|
||||
participant Sandbox as Sandbox
|
||||
|
||||
Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created)
|
||||
|
||||
Ext->>App: POST /api/integration/{service}/events
|
||||
App->>IntRouter: Route to service handler
|
||||
Note over IntRouter: Verify signature (HMAC)
|
||||
|
||||
IntRouter->>Manager: Parse event payload
|
||||
Note over Manager: Extract context (repo, issue, user)
|
||||
Note over Manager: Map external user → OpenHands user
|
||||
|
||||
Manager->>Conv: Create conversation (with issue context)
|
||||
Conv->>Sandbox: Provision sandbox
|
||||
Sandbox-->>Conv: Ready
|
||||
|
||||
Manager->>Sandbox: Start agent with task
|
||||
|
||||
Note over Ext,Sandbox: Agent Works on Task...
|
||||
|
||||
Sandbox-->>Manager: Task complete
|
||||
Manager->>Ext: POST result<br/>(PR, comment, etc.)
|
||||
|
||||
Note over Ext,Sandbox: Callback Flow (Agent → External Service)
|
||||
|
||||
Sandbox->>App: Webhook callback<br/>/api/v1/webhooks
|
||||
App->>Manager: Process callback
|
||||
Manager->>Ext: Update external service
|
||||
```
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
| Integration | Trigger Events | Agent Actions |
|
||||
|-------------|----------------|---------------|
|
||||
| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits |
|
||||
| **GitLab** | Issue created, MR opened | Create MR, comment, push commits |
|
||||
| **Slack** | @mention in channel | Reply in thread, create tasks |
|
||||
| **Jira** | Issue created/updated | Update ticket, add comments |
|
||||
| **Linear** | Issue created | Update status, add comments |
|
||||
|
||||
### Integration Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` |
|
||||
| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` |
|
||||
| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` |
|
||||
| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` |
|
||||
|
||||
### Integration Authentication
|
||||
|
||||
```
|
||||
External Service (e.g., GitHub)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ GitHub App Installation │
|
||||
│ - Webhook secret for signature │
|
||||
│ - App private key for API calls │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ User Account Linking │
|
||||
│ - Keycloak user ID │
|
||||
│ - GitHub user ID │
|
||||
│ - Stored OAuth tokens │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Agent Execution │
|
||||
│ - Uses linked tokens for API │
|
||||
│ - Can push, create PRs, comment │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
@@ -60,7 +60,9 @@ class ResolverUserContext(UserContext):
|
||||
return provider_token.token.get_secret_value()
|
||||
return None
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
async def get_provider_tokens(
|
||||
self, as_env_vars: bool = False
|
||||
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
|
||||
return await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
|
||||
128
enterprise/integrations/slack/slack_errors.py
Normal file
128
enterprise/integrations/slack/slack_errors.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Centralized error handling for Slack integration.
|
||||
|
||||
This module provides:
|
||||
- SlackErrorCode: Unique error codes for traceability
|
||||
- SlackError: Exception class for user-facing errors
|
||||
- get_user_message(): Function to get user-facing messages for error codes
|
||||
"""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from integrations.utils import HOST_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackErrorCode(Enum):
|
||||
"""Unique error codes for traceability in logs and user messages."""
|
||||
|
||||
SESSION_EXPIRED = 'SLACK_ERR_001'
|
||||
REDIS_STORE_FAILED = 'SLACK_ERR_002'
|
||||
REDIS_RETRIEVE_FAILED = 'SLACK_ERR_003'
|
||||
USER_NOT_AUTHENTICATED = 'SLACK_ERR_004'
|
||||
PROVIDER_TIMEOUT = 'SLACK_ERR_005'
|
||||
PROVIDER_AUTH_FAILED = 'SLACK_ERR_006'
|
||||
LLM_AUTH_FAILED = 'SLACK_ERR_007'
|
||||
MISSING_SETTINGS = 'SLACK_ERR_008'
|
||||
UNEXPECTED_ERROR = 'SLACK_ERR_999'
|
||||
|
||||
|
||||
class SlackError(Exception):
|
||||
"""Exception for errors that should be communicated to the Slack user.
|
||||
|
||||
This exception is caught by the centralized error handler in SlackManager,
|
||||
which logs the error and sends an appropriate message to the user.
|
||||
|
||||
Usage:
|
||||
raise SlackError(SlackErrorCode.USER_NOT_AUTHENTICATED,
|
||||
message_kwargs={'login_link': link})
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: SlackErrorCode,
|
||||
message_kwargs: dict[str, Any] | None = None,
|
||||
log_context: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Initialize a SlackError.
|
||||
|
||||
Args:
|
||||
code: The error code identifying the type of error
|
||||
message_kwargs: Kwargs for formatting the user message
|
||||
(e.g., {'login_link': '...'})
|
||||
log_context: Additional context for structured logging
|
||||
"""
|
||||
self.code = code
|
||||
self.message_kwargs = message_kwargs or {}
|
||||
self.log_context = log_context or {}
|
||||
super().__init__(f'{code.value}: {code.name}')
|
||||
|
||||
def get_user_message(self) -> str:
|
||||
"""Get the user-facing message for this error."""
|
||||
return get_user_message(self.code, **self.message_kwargs)
|
||||
|
||||
|
||||
# Centralized user-facing messages
|
||||
_USER_MESSAGES: dict[SlackErrorCode, str] = {
|
||||
SlackErrorCode.SESSION_EXPIRED: (
|
||||
'⏰ Your session has expired. '
|
||||
'Please mention me again with your request to start a new conversation.'
|
||||
),
|
||||
SlackErrorCode.REDIS_STORE_FAILED: (
|
||||
'⚠️ Something went wrong on our end (ref: {code}). '
|
||||
'Please try again in a few moments.'
|
||||
),
|
||||
SlackErrorCode.REDIS_RETRIEVE_FAILED: (
|
||||
'⚠️ Something went wrong on our end (ref: {code}). '
|
||||
'Please try again in a few moments.'
|
||||
),
|
||||
SlackErrorCode.USER_NOT_AUTHENTICATED: (
|
||||
'🔐 Please link your Slack account to OpenHands: '
|
||||
'[Click here to Login]({login_link})'
|
||||
),
|
||||
SlackErrorCode.PROVIDER_TIMEOUT: (
|
||||
'⏱️ The request timed out while connecting to your git provider. '
|
||||
'Please try again.'
|
||||
),
|
||||
SlackErrorCode.PROVIDER_AUTH_FAILED: (
|
||||
'🔐 Authentication with your git provider failed. '
|
||||
f'Please re-login at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
),
|
||||
SlackErrorCode.LLM_AUTH_FAILED: (
|
||||
'@{username} please set a valid LLM API key in '
|
||||
f'[OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
),
|
||||
SlackErrorCode.MISSING_SETTINGS: (
|
||||
'{username} please re-login into '
|
||||
f'[OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
),
|
||||
SlackErrorCode.UNEXPECTED_ERROR: (
|
||||
'Uh oh! There was an unexpected error (ref: {code}). Please try again later.'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_user_message(error_code: SlackErrorCode, **kwargs) -> str:
|
||||
"""Get a user-facing message for a given error code.
|
||||
|
||||
Args:
|
||||
error_code: The error code to get a message for
|
||||
**kwargs: Additional formatting arguments (e.g., username, login_link)
|
||||
|
||||
Returns:
|
||||
Formatted user-facing message string
|
||||
"""
|
||||
msg = _USER_MESSAGES.get(
|
||||
error_code, _USER_MESSAGES[SlackErrorCode.UNEXPECTED_ERROR]
|
||||
)
|
||||
try:
|
||||
return msg.format(code=error_code.value, **kwargs)
|
||||
except KeyError as e:
|
||||
logger.warning(
|
||||
f'Missing format key {e} in error message',
|
||||
extra={'error_code': error_code.value},
|
||||
)
|
||||
# Return a generic error message with the code for debugging
|
||||
return f'An error occurred (ref: {error_code.value}). Please try again later.'
|
||||
@@ -1,9 +1,9 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from integrations.manager import Manager
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.slack.slack_errors import SlackError, SlackErrorCode
|
||||
from integrations.slack.slack_types import (
|
||||
SlackMessageView,
|
||||
SlackViewInterface,
|
||||
@@ -13,13 +13,13 @@ from integrations.slack.slack_view import (
|
||||
SlackFactory,
|
||||
SlackNewConversationFromRepoFormView,
|
||||
SlackNewConversationView,
|
||||
SlackUnkownUserView,
|
||||
SlackUpdateExistingConversationView,
|
||||
)
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
infer_repo_from_message,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
@@ -33,8 +33,12 @@ from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderTimeoutError, Repository
|
||||
from openhands.server.shared import config, server_config
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
ProviderTimeoutError,
|
||||
Repository,
|
||||
)
|
||||
from openhands.server.shared import config, server_config, sio
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
@@ -48,6 +52,12 @@ authorize_url_generator = AuthorizeUrlGenerator(
|
||||
user_scopes=['search:read'],
|
||||
)
|
||||
|
||||
# Key prefix for storing user messages in Redis during repo selection flow
|
||||
SLACK_USER_MSG_KEY_PREFIX = 'slack_user_msg'
|
||||
# Expiration time for stored user messages (5 minutes)
|
||||
# Arbitrary timeout based on typical user attention span; may be tuned based on feedback
|
||||
SLACK_USER_MSG_EXPIRATION = 300
|
||||
|
||||
|
||||
class SlackManager(Manager[SlackViewInterface]):
|
||||
def __init__(self, token_manager):
|
||||
@@ -86,18 +96,126 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
|
||||
return slack_user, saas_user_auth
|
||||
|
||||
def _infer_repo_from_message(self, user_msg: str) -> str | None:
|
||||
# Regular expression to match patterns like "OpenHands/OpenHands" or "deploy repo"
|
||||
pattern = r'([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)|([a-zA-Z0-9_-]+)(?=\s+repo)'
|
||||
match = re.search(pattern, user_msg)
|
||||
async def _store_user_msg_for_form(
|
||||
self, message_ts: str, thread_ts: str | None, user_msg: str
|
||||
) -> None:
|
||||
"""Store user message in Redis for later retrieval when form is submitted.
|
||||
|
||||
if match:
|
||||
repo = match.group(1) if match.group(1) else match.group(2)
|
||||
return repo
|
||||
This is needed because when a user selects a repo from the external_select
|
||||
dropdown, Slack sends a separate interaction payload that doesn't include
|
||||
the original user message.
|
||||
|
||||
return None
|
||||
Args:
|
||||
message_ts: The message timestamp (unique identifier)
|
||||
thread_ts: The thread timestamp (if in a thread)
|
||||
user_msg: The original user message to store
|
||||
|
||||
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
|
||||
Raises:
|
||||
SlackError: If storage fails (REDIS_STORE_FAILED)
|
||||
"""
|
||||
key = f'{SLACK_USER_MSG_KEY_PREFIX}:{message_ts}:{thread_ts}'
|
||||
try:
|
||||
redis = sio.manager.redis
|
||||
await redis.set(key, user_msg, ex=SLACK_USER_MSG_EXPIRATION)
|
||||
logger.info(
|
||||
'slack_stored_user_msg',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'slack_store_user_msg_failed',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise SlackError(
|
||||
SlackErrorCode.REDIS_STORE_FAILED,
|
||||
log_context={'message_ts': message_ts, 'thread_ts': thread_ts},
|
||||
)
|
||||
|
||||
async def _retrieve_user_msg_for_form(
|
||||
self, message_ts: str, thread_ts: str | None
|
||||
) -> str:
|
||||
"""Retrieve stored user message from Redis.
|
||||
|
||||
Args:
|
||||
message_ts: The message timestamp
|
||||
thread_ts: The thread timestamp (if in a thread)
|
||||
|
||||
Returns:
|
||||
The stored user message
|
||||
|
||||
Raises:
|
||||
SlackError: If retrieval fails (REDIS_RETRIEVE_FAILED) or message
|
||||
not found (SESSION_EXPIRED)
|
||||
"""
|
||||
key = f'{SLACK_USER_MSG_KEY_PREFIX}:{message_ts}:{thread_ts}'
|
||||
try:
|
||||
redis = sio.manager.redis
|
||||
user_msg = await redis.get(key)
|
||||
if user_msg:
|
||||
# Redis returns bytes, decode to string
|
||||
if isinstance(user_msg, bytes):
|
||||
user_msg = user_msg.decode('utf-8')
|
||||
logger.info(
|
||||
'slack_retrieved_user_msg',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
},
|
||||
)
|
||||
return user_msg
|
||||
else:
|
||||
logger.warning(
|
||||
'slack_user_msg_not_found',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
},
|
||||
)
|
||||
raise SlackError(
|
||||
SlackErrorCode.SESSION_EXPIRED,
|
||||
log_context={'message_ts': message_ts, 'thread_ts': thread_ts},
|
||||
)
|
||||
except SlackError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'slack_retrieve_user_msg_failed',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise SlackError(
|
||||
SlackErrorCode.REDIS_RETRIEVE_FAILED,
|
||||
log_context={'message_ts': message_ts, 'thread_ts': thread_ts},
|
||||
)
|
||||
|
||||
async def _search_repositories(
|
||||
self, user_auth: UserAuth, query: str = '', per_page: int = 100
|
||||
) -> list[Repository]:
|
||||
"""Search repositories for a user with optional query filtering.
|
||||
|
||||
Args:
|
||||
user_auth: The user's authentication context
|
||||
query: Search query to filter repositories (empty string returns all)
|
||||
per_page: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching Repository objects
|
||||
"""
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
return []
|
||||
@@ -108,31 +226,33 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
repos: list[Repository] = await client.get_repositories(
|
||||
'pushed', server_config.app_mode, None, None, None, None
|
||||
repos: list[Repository] = await client.search_repositories(
|
||||
selected_provider=None,
|
||||
query=query,
|
||||
per_page=per_page,
|
||||
sort='pushed',
|
||||
order='desc',
|
||||
app_mode=server_config.app_mode,
|
||||
)
|
||||
return repos
|
||||
|
||||
def _generate_repo_selection_form(
|
||||
self, repo_list: list[Repository], message_ts: str, thread_ts: str | None
|
||||
):
|
||||
options = [
|
||||
{
|
||||
'text': {'type': 'plain_text', 'text': 'No Repository'},
|
||||
'value': '-',
|
||||
}
|
||||
]
|
||||
options.extend(
|
||||
{
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': repo.full_name,
|
||||
},
|
||||
'value': repo.full_name,
|
||||
}
|
||||
for repo in repo_list
|
||||
)
|
||||
self, message_ts: str, thread_ts: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Generate a repo selection form using external_select for dynamic loading.
|
||||
|
||||
This uses Slack's external_select element which allows:
|
||||
- Type-ahead search for repositories
|
||||
- Dynamic loading of options from an external endpoint
|
||||
- Support for users with many repositories (no 100 option limit)
|
||||
|
||||
Args:
|
||||
message_ts: The message timestamp for tracking
|
||||
thread_ts: The thread timestamp if in a thread
|
||||
|
||||
Returns:
|
||||
List of Slack Block Kit blocks for the selection form
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'type': 'header',
|
||||
@@ -142,78 +262,250 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'emoji': True,
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'section',
|
||||
'text': {
|
||||
'type': 'mrkdwn',
|
||||
'text': 'Type to search your repositories:',
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'actions',
|
||||
'elements': [
|
||||
{
|
||||
'type': 'static_select',
|
||||
'type': 'external_select',
|
||||
'action_id': f'repository_select:{message_ts}:{thread_ts}',
|
||||
'options': options,
|
||||
'placeholder': {
|
||||
'type': 'plain_text',
|
||||
'text': 'Search repositories...',
|
||||
},
|
||||
'min_query_length': 0, # Load initial options immediately
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
def filter_potential_repos_by_user_msg(
|
||||
self, user_msg: str, user_repos: list[Repository]
|
||||
) -> tuple[bool, list[Repository]]:
|
||||
inferred_repo = self._infer_repo_from_message(user_msg)
|
||||
if not inferred_repo:
|
||||
return False, user_repos[0:99]
|
||||
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
|
||||
"""Build Slack options list from repositories.
|
||||
|
||||
final_repos = []
|
||||
for repo in user_repos:
|
||||
if inferred_repo.lower() in repo.full_name.lower():
|
||||
final_repos.append(repo)
|
||||
Always includes a "No Repository" option at the top, followed by up to 99
|
||||
repositories (Slack has a 100 option limit for external_select).
|
||||
|
||||
# no repos matched, return original list
|
||||
if len(final_repos) == 0:
|
||||
return False, user_repos[0:99]
|
||||
Args:
|
||||
repos: List of Repository objects
|
||||
|
||||
# Found exact match
|
||||
elif len(final_repos) == 1:
|
||||
return True, final_repos
|
||||
Returns:
|
||||
List of Slack option objects
|
||||
"""
|
||||
options: list[dict[str, Any]] = [
|
||||
{
|
||||
'text': {'type': 'plain_text', 'text': 'No Repository'},
|
||||
'value': '-',
|
||||
}
|
||||
]
|
||||
options.extend(
|
||||
{
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': repo.full_name[:75], # Slack has 75 char limit for text
|
||||
},
|
||||
'value': repo.full_name,
|
||||
}
|
||||
for repo in repos[:99] # Leave room for "No Repository" option
|
||||
)
|
||||
return options
|
||||
|
||||
# Found partial matches
|
||||
return False, final_repos[0:99]
|
||||
async def search_repos_for_slack(
|
||||
self, user_auth: UserAuth, query: str, per_page: int = 20
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Public API for repository search with formatted Slack options.
|
||||
|
||||
This method searches for repositories and formats the results as Slack
|
||||
external_select options.
|
||||
|
||||
Args:
|
||||
user_auth: The user's authentication context
|
||||
query: Search query to filter repositories (empty string returns all)
|
||||
per_page: Maximum number of results to return (default: 20)
|
||||
|
||||
Returns:
|
||||
List of Slack option objects ready for external_select response
|
||||
"""
|
||||
repos = await self._search_repositories(
|
||||
user_auth, query=query, per_page=per_page
|
||||
)
|
||||
return self._build_repo_options(repos)
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process an incoming Slack message.
|
||||
|
||||
This is the single entry point for all Slack message processing.
|
||||
All SlackErrors raised during processing are caught and handled here,
|
||||
sending appropriate error messages to the user.
|
||||
"""
|
||||
self._confirm_incoming_source_type(message)
|
||||
|
||||
try:
|
||||
slack_view = await self._process_message(message)
|
||||
if slack_view and await self.is_job_requested(message, slack_view):
|
||||
await self.start_job(slack_view)
|
||||
|
||||
except SlackError as e:
|
||||
await self.handle_slack_error(message.message, e)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'slack_unexpected_error',
|
||||
extra={'error': str(e), **message.message},
|
||||
)
|
||||
await self.handle_slack_error(
|
||||
message.message,
|
||||
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
|
||||
)
|
||||
|
||||
async def receive_form_interaction(self, slack_payload: dict):
|
||||
"""Process a Slack form interaction (repository selection).
|
||||
|
||||
This handles the block_actions payload when a user selects a repository
|
||||
from the dropdown form. It retrieves the original user message from Redis
|
||||
and delegates to receive_message for processing.
|
||||
|
||||
Args:
|
||||
slack_payload: The raw Slack interaction payload
|
||||
"""
|
||||
# Extract fields from the Slack interaction payload
|
||||
selected_repository = slack_payload['actions'][0]['selected_option']['value']
|
||||
if selected_repository == '-':
|
||||
selected_repository = None
|
||||
|
||||
slack_user_id = slack_payload['user']['id']
|
||||
channel_id = slack_payload['container']['channel_id']
|
||||
team_id = slack_payload['team']['id']
|
||||
|
||||
# Get original message_ts and thread_ts from action_id
|
||||
attribs = slack_payload['actions'][0]['action_id'].split('repository_select:')[
|
||||
-1
|
||||
]
|
||||
message_ts, thread_ts = attribs.split(':')
|
||||
thread_ts = None if thread_ts == 'None' else thread_ts
|
||||
|
||||
# Build partial payload for error handling during Redis retrieval
|
||||
payload = {
|
||||
'team_id': team_id,
|
||||
'channel_id': channel_id,
|
||||
'slack_user_id': slack_user_id,
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
}
|
||||
|
||||
# Retrieve the original user message from Redis
|
||||
try:
|
||||
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)
|
||||
except SlackError as e:
|
||||
await self.handle_slack_error(payload, e)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'slack_unexpected_error',
|
||||
extra={'error': str(e), **payload},
|
||||
)
|
||||
await self.handle_slack_error(
|
||||
payload, SlackError(SlackErrorCode.UNEXPECTED_ERROR)
|
||||
)
|
||||
return
|
||||
|
||||
# Complete the payload and delegate to receive_message
|
||||
payload['selected_repo'] = selected_repository
|
||||
payload['user_msg'] = user_msg
|
||||
|
||||
message = Message(source=SourceType.SLACK, message=payload)
|
||||
await self.receive_message(message)
|
||||
|
||||
async def _process_message(self, message: Message) -> SlackViewInterface | None:
|
||||
"""Process message and return view if authenticated, or raise SlackError.
|
||||
|
||||
Returns:
|
||||
SlackViewInterface if user is authenticated and ready to proceed,
|
||||
None if processing should stop (but no error).
|
||||
|
||||
Raises:
|
||||
SlackError: If user is not authenticated or other recoverable error.
|
||||
"""
|
||||
slack_user, saas_user_auth = await self.authenticate_user(
|
||||
slack_user_id=message.message['slack_user_id']
|
||||
)
|
||||
|
||||
try:
|
||||
slack_view = await SlackFactory.create_slack_view_from_payload(
|
||||
message, slack_user, saas_user_auth
|
||||
slack_view = await SlackFactory.create_slack_view_from_payload(
|
||||
message, slack_user, saas_user_auth
|
||||
)
|
||||
|
||||
# Check if this is an unauthenticated user (SlackMessageView but not SlackViewInterface)
|
||||
if not isinstance(slack_view, SlackViewInterface):
|
||||
login_link = self._generate_login_link_with_state(message)
|
||||
raise SlackError(
|
||||
SlackErrorCode.USER_NOT_AUTHENTICATED,
|
||||
message_kwargs={'login_link': login_link},
|
||||
log_context=slack_view.to_log_context(),
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
return slack_view
|
||||
|
||||
def _generate_login_link_with_state(self, message: Message) -> str:
|
||||
"""Generate OAuth login link with message state encoded."""
|
||||
jwt_secret = config.jwt_secret
|
||||
if not jwt_secret:
|
||||
raise ValueError('Must configure jwt_secret')
|
||||
state = jwt.encode(
|
||||
message.message, jwt_secret.get_secret_value(), algorithm='HS256'
|
||||
)
|
||||
return authorize_url_generator.generate(state)
|
||||
|
||||
async def handle_slack_error(self, payload: dict, error: SlackError) -> None:
|
||||
"""Handle a SlackError by logging and sending user message.
|
||||
|
||||
This is the centralized error handler for all SlackErrors, used by both
|
||||
the manager and routes.
|
||||
|
||||
Args:
|
||||
payload: The Slack payload dict containing channel/user info
|
||||
error: The SlackError to handle
|
||||
"""
|
||||
# Create a minimal view for sending the error message
|
||||
view = await SlackMessageView.from_payload(
|
||||
payload, self._get_slack_team_store()
|
||||
)
|
||||
|
||||
if not view:
|
||||
logger.error(
|
||||
f'[Slack]: Failed to create slack view: {e}',
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
'slack_error_no_view',
|
||||
extra={
|
||||
'error_code': error.code.value,
|
||||
**error.log_context,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(slack_view, SlackUnkownUserView):
|
||||
jwt_secret = config.jwt_secret
|
||||
if not jwt_secret:
|
||||
raise ValueError('Must configure jwt_secret')
|
||||
state = jwt.encode(
|
||||
message.message, jwt_secret.get_secret_value(), algorithm='HS256'
|
||||
)
|
||||
link = authorize_url_generator.generate(state)
|
||||
msg = self.login_link.format(link)
|
||||
# Log the error
|
||||
log_level = (
|
||||
'exception' if error.code == SlackErrorCode.UNEXPECTED_ERROR else 'warning'
|
||||
)
|
||||
log_data = {
|
||||
'error_code': error.code.value,
|
||||
**view.to_log_context(),
|
||||
**error.log_context,
|
||||
}
|
||||
getattr(logger, log_level)(
|
||||
f'slack_error_{error.code.name.lower()}', extra=log_data
|
||||
)
|
||||
|
||||
logger.info('slack_not_yet_authenticated')
|
||||
await self.send_message(msg, slack_view, ephemeral=True)
|
||||
return
|
||||
# Send user-facing message
|
||||
await self.send_message(error.get_user_message(), view, ephemeral=True)
|
||||
|
||||
if not await self.is_job_requested(message, slack_view):
|
||||
return
|
||||
def _get_slack_team_store(self):
|
||||
"""Get the SlackTeamStore instance (lazy import to avoid circular deps)."""
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
|
||||
await self.start_job(slack_view)
|
||||
return SlackTeamStore.get_instance()
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -254,76 +546,109 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
thread_ts=slack_view.message_ts,
|
||||
)
|
||||
|
||||
async def _try_verify_inferred_repo(
|
||||
self, slack_view: SlackNewConversationView
|
||||
) -> bool:
|
||||
"""Try to infer and verify a repository from the user's message.
|
||||
|
||||
Returns:
|
||||
True if a valid repo was found and verified, False otherwise
|
||||
"""
|
||||
user = slack_view.slack_to_openhands_user
|
||||
inferred_repos = infer_repo_from_message(slack_view.user_msg)
|
||||
|
||||
if len(inferred_repos) != 1:
|
||||
return False
|
||||
|
||||
inferred_repo = inferred_repos[0]
|
||||
logger.info(
|
||||
f'[Slack] Verifying inferred repo "{inferred_repo}" '
|
||||
f'for user {user.slack_display_name} (id={slack_view.saas_user_auth.get_user_id()})'
|
||||
)
|
||||
|
||||
try:
|
||||
provider_tokens = await slack_view.saas_user_auth.get_provider_tokens()
|
||||
if not provider_tokens:
|
||||
return False
|
||||
|
||||
access_token = await slack_view.saas_user_auth.get_access_token()
|
||||
user_id = await slack_view.saas_user_auth.get_user_id()
|
||||
provider_handler = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
repo = await provider_handler.verify_repo_provider(inferred_repo)
|
||||
slack_view.selected_repo = repo.full_name
|
||||
return True
|
||||
except (AuthenticationError, ProviderTimeoutError) as e:
|
||||
logger.info(
|
||||
f'[Slack] Could not verify repo "{inferred_repo}": {e}. '
|
||||
f'Showing repository selector.'
|
||||
)
|
||||
return False
|
||||
|
||||
async def _show_repo_selection_form(
|
||||
self, slack_view: SlackNewConversationView
|
||||
) -> None:
|
||||
"""Display the repository selection form to the user.
|
||||
|
||||
Raises:
|
||||
SlackError: If storing the user message fails (REDIS_STORE_FAILED)
|
||||
"""
|
||||
user = slack_view.slack_to_openhands_user
|
||||
logger.info(
|
||||
'render_repository_selector',
|
||||
extra={
|
||||
'slack_user_id': user.slack_user_id,
|
||||
'keycloak_user_id': user.keycloak_user_id,
|
||||
'message_ts': slack_view.message_ts,
|
||||
'thread_ts': slack_view.thread_ts,
|
||||
},
|
||||
)
|
||||
|
||||
# Store the user message for later retrieval - raises SlackError on failure
|
||||
await self._store_user_msg_for_form(
|
||||
slack_view.message_ts, slack_view.thread_ts, slack_view.user_msg
|
||||
)
|
||||
|
||||
repo_selection_msg = {
|
||||
'text': 'Choose a Repository:',
|
||||
'blocks': self._generate_repo_selection_form(
|
||||
slack_view.message_ts, slack_view.thread_ts
|
||||
),
|
||||
}
|
||||
await self.send_message(repo_selection_msg, slack_view, ephemeral=True)
|
||||
|
||||
async def is_job_requested(
|
||||
self, message: Message, slack_view: SlackViewInterface
|
||||
) -> bool:
|
||||
"""A job is always request we only receive webhooks for events associated with the slack bot
|
||||
This method really just checks
|
||||
1. Is the user is authenticated
|
||||
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
|
||||
"""Determine if a job should be started based on the current context.
|
||||
|
||||
This method checks:
|
||||
1. If the view type allows immediate job start
|
||||
2. If a repo can be inferred and verified from the message
|
||||
3. Otherwise shows the repo selection form
|
||||
|
||||
Args:
|
||||
slack_view: Must be a SlackViewType (authenticated view that can start jobs)
|
||||
|
||||
Returns:
|
||||
True if job should start, False if waiting for user input
|
||||
"""
|
||||
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
|
||||
# Check if view type allows immediate start
|
||||
if isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
return True
|
||||
elif isinstance(slack_view, SlackNewConversationFromRepoFormView):
|
||||
if isinstance(slack_view, SlackNewConversationFromRepoFormView):
|
||||
return True
|
||||
elif isinstance(slack_view, SlackNewConversationView):
|
||||
user = slack_view.slack_to_openhands_user
|
||||
|
||||
# Fetch repositories, handling timeout errors from the provider
|
||||
logger.info(
|
||||
f'[Slack] Fetching repositories for user {user.slack_display_name} (id={slack_view.saas_user_auth.get_user_id()})'
|
||||
)
|
||||
try:
|
||||
user_repos: list[Repository] = await self._get_repositories(
|
||||
slack_view.saas_user_auth
|
||||
)
|
||||
except ProviderTimeoutError:
|
||||
logger.warning(
|
||||
'repo_query_timeout',
|
||||
extra={
|
||||
'slack_user_id': user.slack_user_id,
|
||||
'keycloak_user_id': user.keycloak_user_id,
|
||||
},
|
||||
)
|
||||
timeout_msg = (
|
||||
'The repository selection timed out while fetching your repository list. '
|
||||
'Please re-send your message with a more specific repository name '
|
||||
'(e.g., "owner/repo-name") to help me find it faster.'
|
||||
)
|
||||
await self.send_message(timeout_msg, slack_view, ephemeral=True)
|
||||
return False
|
||||
|
||||
match, repos = self.filter_potential_repos_by_user_msg(
|
||||
slack_view.user_msg, user_repos
|
||||
)
|
||||
|
||||
# User mentioned a matching repo is their message, start job without repo selection form
|
||||
if match:
|
||||
slack_view.selected_repo = repos[0].full_name
|
||||
# For new conversations, try to infer/verify repo or show selection form
|
||||
if isinstance(slack_view, SlackNewConversationView):
|
||||
if await self._try_verify_inferred_repo(slack_view):
|
||||
return True
|
||||
await self._show_repo_selection_form(slack_view)
|
||||
|
||||
logger.info(
|
||||
'render_repository_selector',
|
||||
extra={
|
||||
'slack_user_id': user,
|
||||
'keycloak_user_id': user.keycloak_user_id,
|
||||
'message_ts': slack_view.message_ts,
|
||||
'thread_ts': slack_view.thread_ts,
|
||||
},
|
||||
)
|
||||
|
||||
repo_selection_msg = {
|
||||
'text': 'Choose a Repository:',
|
||||
'blocks': self._generate_repo_selection_form(
|
||||
repos, slack_view.message_ts, slack_view.thread_ts
|
||||
),
|
||||
}
|
||||
await self.send_message(repo_selection_msg, slack_view, ephemeral=True)
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
async def start_job(self, slack_view: SlackViewInterface) -> None:
|
||||
# Importing here prevents circular import
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from integrations.types import SummaryExtractionTracker
|
||||
from jinja2 import Environment
|
||||
@@ -7,12 +8,13 @@ from storage.slack_user import SlackUser
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class SlackMessageView(ABC):
|
||||
"""Minimal interface for sending messages to Slack.
|
||||
@dataclass
|
||||
class SlackMessageView:
|
||||
"""Minimal view for sending messages to Slack.
|
||||
|
||||
This base class contains only the fields needed to send messages,
|
||||
without requiring user authentication. Used by both authenticated
|
||||
and unauthenticated Slack views.
|
||||
This class contains only the fields needed to send messages,
|
||||
without requiring user authentication. Can be used directly for
|
||||
simple message operations or as a base class for more complex views.
|
||||
"""
|
||||
|
||||
bot_access_token: str
|
||||
@@ -20,6 +22,77 @@ class SlackMessageView(ABC):
|
||||
channel_id: str
|
||||
message_ts: str
|
||||
thread_ts: str | None
|
||||
team_id: str
|
||||
|
||||
def to_log_context(self) -> dict:
|
||||
"""Return dict suitable for structured logging."""
|
||||
return {
|
||||
'slack_channel_id': self.channel_id,
|
||||
'slack_user_id': self.slack_user_id,
|
||||
'slack_team_id': self.team_id,
|
||||
'slack_thread_ts': self.thread_ts,
|
||||
'slack_message_ts': self.message_ts,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def from_payload(
|
||||
cls,
|
||||
payload: dict,
|
||||
slack_team_store,
|
||||
) -> 'SlackMessageView | None':
|
||||
"""Create a view from a raw Slack payload.
|
||||
|
||||
This factory method handles the various payload formats from different
|
||||
Slack interactions (events, form submissions, block suggestions).
|
||||
|
||||
Args:
|
||||
payload: Raw Slack payload dictionary
|
||||
slack_team_store: Store for retrieving bot tokens
|
||||
|
||||
Returns:
|
||||
SlackMessageView if all required fields are available,
|
||||
None if required fields are missing or bot token unavailable.
|
||||
"""
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
team_id = payload.get('team', {}).get('id') or payload.get('team_id')
|
||||
channel_id = (
|
||||
payload.get('container', {}).get('channel_id')
|
||||
or payload.get('channel', {}).get('id')
|
||||
or payload.get('channel_id')
|
||||
)
|
||||
user_id = payload.get('user', {}).get('id') or payload.get('slack_user_id')
|
||||
message_ts = payload.get('message_ts', '')
|
||||
thread_ts = payload.get('thread_ts')
|
||||
|
||||
if not team_id or not channel_id or not user_id:
|
||||
logger.warning(
|
||||
'slack_message_view_from_payload_missing_fields',
|
||||
extra={
|
||||
'has_team_id': bool(team_id),
|
||||
'has_channel_id': bool(channel_id),
|
||||
'has_user_id': bool(user_id),
|
||||
'payload_keys': list(payload.keys()),
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
bot_token = await slack_team_store.get_team_bot_token(team_id)
|
||||
if not bot_token:
|
||||
logger.warning(
|
||||
'slack_message_view_from_payload_no_bot_token',
|
||||
extra={'team_id': team_id},
|
||||
)
|
||||
return None
|
||||
|
||||
return cls(
|
||||
bot_access_token=bot_token,
|
||||
slack_user_id=user_id,
|
||||
channel_id=channel_id,
|
||||
message_ts=message_ts,
|
||||
thread_ts=thread_ts,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
|
||||
class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
@@ -27,6 +100,9 @@ class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
|
||||
All fields are required (non-None) because this interface is only used
|
||||
for users who have linked their Slack account to OpenHands.
|
||||
|
||||
Inherits from SlackMessageView:
|
||||
bot_access_token, slack_user_id, channel_id, message_ts, thread_ts, team_id
|
||||
"""
|
||||
|
||||
user_msg: str
|
||||
@@ -36,7 +112,6 @@ class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
should_extract: bool
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
@abstractmethod
|
||||
@@ -55,4 +130,4 @@ class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
|
||||
|
||||
class StartingConvoException(Exception):
|
||||
"""Raised when trying to send message to a conversation that's is still starting up"""
|
||||
"""Raised when trying to send message to a conversation that is still starting up."""
|
||||
|
||||
@@ -63,22 +63,6 @@ async def is_v1_enabled_for_slack_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_SLACK_RESOLVER
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackUnkownUserView(SlackMessageView):
|
||||
"""View for unauthenticated Slack users who haven't linked their account.
|
||||
|
||||
This view only contains the minimal fields needed to send a login link
|
||||
message back to the user. It does not implement SlackViewInterface
|
||||
because it cannot create conversations without user authentication.
|
||||
"""
|
||||
|
||||
bot_access_token: str
|
||||
slack_user_id: str
|
||||
channel_id: str
|
||||
message_ts: str
|
||||
thread_ts: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackNewConversationView(SlackViewInterface):
|
||||
bot_access_token: str
|
||||
@@ -478,7 +462,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
)
|
||||
|
||||
# 6. Send the message to the agent server
|
||||
url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events'
|
||||
url = f"{agent_server_url.rstrip('/')}/api/conversations/{UUID(self.conversation_id)}/events"
|
||||
|
||||
headers = {'X-Session-API-Key': running_sandbox.session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
@@ -576,13 +560,15 @@ class SlackFactory:
|
||||
raise Exception('Did not find slack team')
|
||||
|
||||
# Determine if this is a known slack user by openhands
|
||||
# Return SlackMessageView (not SlackViewInterface) for unauthenticated users
|
||||
if not slack_user or not saas_user_auth or not channel_id or not message_ts:
|
||||
return SlackUnkownUserView(
|
||||
return SlackMessageView(
|
||||
bot_access_token=bot_access_token,
|
||||
slack_user_id=slack_user_id,
|
||||
channel_id=channel_id or '',
|
||||
message_ts=message_ts or '',
|
||||
thread_ts=thread_ts,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
# At this point, we've verified slack_user, saas_user_auth, channel_id, and message_ts are set
|
||||
@@ -657,3 +643,11 @@ class SlackFactory:
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
# Type alias for all authenticated Slack view types that can start conversations
|
||||
SlackViewType = (
|
||||
SlackNewConversationView
|
||||
| SlackNewConversationFromRepoFormView
|
||||
| SlackUpdateExistingConversationView
|
||||
)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add sandbox_grouping_strategy column to user, org, and user_settings tables.
|
||||
|
||||
Revision ID: 100
|
||||
Revises: 099
|
||||
Create Date: 2025-03-12
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '100'
|
||||
down_revision = '099'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user_settings', 'sandbox_grouping_strategy')
|
||||
op.drop_column('org', 'sandbox_grouping_strategy')
|
||||
op.drop_column('user', 'sandbox_grouping_strategy')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Add pending_messages table for server-side message queuing
|
||||
|
||||
Revision ID: 101
|
||||
Revises: 100
|
||||
Create Date: 2025-03-15 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '101'
|
||||
down_revision: Union[str, None] = '100'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create pending_messages table for storing messages before conversation is ready.
|
||||
|
||||
Messages are stored temporarily until the conversation becomes ready, then
|
||||
delivered and deleted regardless of success or failure.
|
||||
"""
|
||||
op.create_table(
|
||||
'pending_messages',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('conversation_id', sa.String(), nullable=False, index=True),
|
||||
sa.Column('role', sa.String(20), nullable=False, server_default='user'),
|
||||
sa.Column('content', sa.JSON, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove pending_messages table."""
|
||||
op.drop_table('pending_messages')
|
||||
622
enterprise/poetry.lock
generated
622
enterprise/poetry.lock
generated
@@ -1,5 +1,38 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.8.1"
|
||||
description = "A Python implement of Agent Client Protocol (ACP, by Zed Industries)"
|
||||
optional = false
|
||||
python-versions = "<3.15,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "agent_client_protocol-0.8.1-py3-none-any.whl", hash = "sha256:9421a11fd435b4831660272d169c3812d553bb7247049c138c3ca127e4b8af8e"},
|
||||
{file = "agent_client_protocol-0.8.1.tar.gz", hash = "sha256:1bbf15663bf51f64942597f638e32a6284c5da918055d9672d3510e965143dbd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pydantic = ">=2.7"
|
||||
|
||||
[package.extras]
|
||||
logfire = ["logfire (>=0.14)", "opentelemetry-sdk (>=1.28.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "aiofile"
|
||||
version = "3.9.0"
|
||||
description = "Asynchronous file operations."
|
||||
optional = false
|
||||
python-versions = "<4,>=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa"},
|
||||
{file = "aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
caio = ">=0.9.0,<0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "25.1.0"
|
||||
@@ -569,14 +602,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.7"
|
||||
version = "1.6.9"
|
||||
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"},
|
||||
{file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"},
|
||||
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
|
||||
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -923,6 +956,41 @@ files = [
|
||||
{file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caio"
|
||||
version = "0.9.25"
|
||||
description = "Asynchronous file IO for Linux MacOS or Windows."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619"},
|
||||
{file = "caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241"},
|
||||
{file = "caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b"},
|
||||
{file = "caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae"},
|
||||
{file = "caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965"},
|
||||
{file = "caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478"},
|
||||
{file = "caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c"},
|
||||
{file = "caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc"},
|
||||
{file = "caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044"},
|
||||
{file = "caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64"},
|
||||
{file = "caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb"},
|
||||
{file = "caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69"},
|
||||
{file = "caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451"},
|
||||
{file = "caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6"},
|
||||
{file = "caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f"},
|
||||
{file = "caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7"},
|
||||
{file = "caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db"},
|
||||
{file = "caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77"},
|
||||
{file = "caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79"},
|
||||
{file = "caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7"},
|
||||
{file = "caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40"},
|
||||
{file = "caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
develop = ["aiomisc-pytest", "coveralls", "pylama[toml]", "pytest", "pytest-cov", "setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "cdp-use"
|
||||
version = "1.4.5"
|
||||
@@ -1938,18 +2006,6 @@ files = [
|
||||
[package.dependencies]
|
||||
scantree = ">=0.0.4"
|
||||
|
||||
[[package]]
|
||||
name = "diskcache"
|
||||
version = "5.6.3"
|
||||
description = "Disk Cache -- Disk and file backed persistent cache."
|
||||
optional = false
|
||||
python-versions = ">=3"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19"},
|
||||
{file = "diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
@@ -2282,37 +2338,47 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "2.14.3"
|
||||
version = "3.1.0"
|
||||
description = "The fast, Pythonic way to build MCP servers and clients."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "fastmcp-2.14.3-py3-none-any.whl", hash = "sha256:103c6b4c6e97a9acc251c81d303f110fe4f2bdba31353df515d66272bf1b9414"},
|
||||
{file = "fastmcp-2.14.3.tar.gz", hash = "sha256:abc9113d5fcf79dfb4c060a1e1c55fccb0d4bce4a2e3eab15ca352341eec8dd6"},
|
||||
{file = "fastmcp-3.1.0-py3-none-any.whl", hash = "sha256:b1f73b56fd3b0cb2bd9e2a144fc650d5cc31587ed129d996db7710e464ae8010"},
|
||||
{file = "fastmcp-3.1.0.tar.gz", hash = "sha256:e25264794c734b9977502a51466961eeecff92a0c2f3b49c40c070993628d6d0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
authlib = ">=1.6.5"
|
||||
cyclopts = ">=4.0.0"
|
||||
exceptiongroup = ">=1.2.2"
|
||||
httpx = ">=0.28.1"
|
||||
httpx = ">=0.28.1,<1.0"
|
||||
jsonref = ">=1.1.0"
|
||||
jsonschema-path = ">=0.3.4"
|
||||
mcp = ">=1.24.0,<2.0"
|
||||
openapi-pydantic = ">=0.5.1"
|
||||
opentelemetry-api = ">=1.20.0"
|
||||
packaging = ">=24.0"
|
||||
platformdirs = ">=4.0.0"
|
||||
py-key-value-aio = {version = ">=0.3.0,<0.4.0", extras = ["disk", "keyring", "memory"]}
|
||||
py-key-value-aio = {version = ">=0.4.4,<0.5.0", extras = ["filetree", "keyring", "memory"]}
|
||||
pydantic = {version = ">=2.11.7", extras = ["email"]}
|
||||
pydocket = ">=0.16.6"
|
||||
pyperclip = ">=1.9.0"
|
||||
python-dotenv = ">=1.1.0"
|
||||
pyyaml = ">=6.0,<7.0"
|
||||
rich = ">=13.9.4"
|
||||
uncalled-for = ">=0.2.0"
|
||||
uvicorn = ">=0.35"
|
||||
watchfiles = ">=1.0.0"
|
||||
websockets = ">=15.0.1"
|
||||
|
||||
[package.extras]
|
||||
anthropic = ["anthropic (>=0.40.0)"]
|
||||
apps = ["prefab-ui (>=0.6.0)"]
|
||||
azure = ["azure-identity (>=1.16.0)"]
|
||||
code-mode = ["pydantic-monty (>=0.0.7)"]
|
||||
gemini = ["google-genai (>=1.18.0)"]
|
||||
openai = ["openai (>=1.102.0)"]
|
||||
tasks = ["pydocket (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "fastuuid"
|
||||
@@ -4162,6 +4228,18 @@ files = [
|
||||
{file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonref"
|
||||
version = "1.1.0"
|
||||
description = "jsonref is a library for automatic dereferencing of JSON Reference objects for Python."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9"},
|
||||
{file = "jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
@@ -5365,14 +5443,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.25.0"
|
||||
version = "1.26.0"
|
||||
description = "Model Context Protocol SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
|
||||
{file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
|
||||
{file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"},
|
||||
{file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6112,14 +6190,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.11.5"
|
||||
version = "1.14.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.11.5-py3-none-any.whl", hash = "sha256:8bae7063f232791d58a5c31919f58b557f7cce60e6295773985c7dadc556cb9e"},
|
||||
{file = "openhands_agent_server-1.11.5.tar.gz", hash = "sha256:b61366d727c61ab9b7fcd66faab53f230f8ef0928c1177a388d2c5c4be6ebbd0"},
|
||||
{file = "openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1"},
|
||||
{file = "openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6136,7 +6214,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -6158,7 +6236,7 @@ deprecation = ">=2.1"
|
||||
dirhash = "*"
|
||||
docker = "*"
|
||||
fastapi = "*"
|
||||
fastmcp = ">=2.12.4"
|
||||
fastmcp = ">=3,<4"
|
||||
google-api-python-client = ">=2.164"
|
||||
google-auth-httplib2 = "*"
|
||||
google-auth-oauthlib = "*"
|
||||
@@ -6176,15 +6254,17 @@ kubernetes = ">=33.1"
|
||||
libtmux = ">=0.46.2"
|
||||
litellm = ">=1.74.3"
|
||||
lmnr = ">=0.7.20"
|
||||
mcp = ">=1.25"
|
||||
memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.11.5"
|
||||
openhands-sdk = "1.11.5"
|
||||
openhands-tools = "1.11.5"
|
||||
openhands-agent-server = "1.14"
|
||||
openhands-sdk = "1.14"
|
||||
openhands-tools = "1.14"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
orjson = ">=3.11.6"
|
||||
pathspec = ">=0.12.1"
|
||||
pexpect = "*"
|
||||
pg8000 = ">=1.31.5"
|
||||
@@ -6196,7 +6276,7 @@ protobuf = ">=5.29.6,<6"
|
||||
psutil = "*"
|
||||
pybase62 = ">=1"
|
||||
pygithub = ">=2.5"
|
||||
pyjwt = ">=2.9"
|
||||
pyjwt = ">=2.12.0"
|
||||
pylatexenc = "*"
|
||||
pypdf = ">=6.7.2"
|
||||
python-docx = "*"
|
||||
@@ -6236,19 +6316,21 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.11.5"
|
||||
version = "1.14.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.11.5-py3-none-any.whl", hash = "sha256:f949cd540cbecc339d90fb0cca2a5f29e1b62566b82b5aee82ef40f259d14e60"},
|
||||
{file = "openhands_sdk-1.11.5.tar.gz", hash = "sha256:dd6225876b7b8dbb6c608559f2718c3d0bf44d0bb741e990b185c6cdc5150c5a"},
|
||||
{file = "openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090"},
|
||||
{file = "openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
agent-client-protocol = ">=0.8.1"
|
||||
deprecation = ">=2.1.0"
|
||||
fastmcp = ">=2.11.3"
|
||||
fakeredis = {version = ">=2.32.1", extras = ["lua"]}
|
||||
fastmcp = ">=3.0.0"
|
||||
filelock = ">=3.20.1"
|
||||
httpx = ">=0.27.0"
|
||||
litellm = ">=1.80.10"
|
||||
@@ -6264,14 +6346,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.11.5"
|
||||
version = "1.14.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.11.5-py3-none-any.whl", hash = "sha256:1e981e1e7f3544184fe946cee8eb6bd287010cdef77d83ebac945c9f42df3baf"},
|
||||
{file = "openhands_tools-1.11.5.tar.gz", hash = "sha256:d7b1163f6505a51b07147e7d8972062c129ecc46571a71f28d5470355e06650e"},
|
||||
{file = "openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615"},
|
||||
{file = "openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6382,23 +6464,6 @@ typing-extensions = ">=4.5.0"
|
||||
[package.extras]
|
||||
gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-prometheus"
|
||||
version = "0.60b1"
|
||||
description = "Prometheus Metric Exporter for OpenTelemetry"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd"},
|
||||
{file = "opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = ">=1.12,<2.0"
|
||||
opentelemetry-sdk = ">=1.39.1,<1.40.0"
|
||||
prometheus-client = ">=0.5.0,<1.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation"
|
||||
version = "0.60b1"
|
||||
@@ -6496,99 +6561,86 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.5"
|
||||
version = "3.11.7"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30"},
|
||||
{file = "orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d"},
|
||||
{file = "orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6757,23 +6809,6 @@ optional = ["typing-extensions (>=4)"]
|
||||
re2 = ["google-re2 (>=1.1)"]
|
||||
tests = ["pytest (>=9)", "typing-extensions (>=4.15)"]
|
||||
|
||||
[[package]]
|
||||
name = "pathvalidate"
|
||||
version = "3.3.1"
|
||||
description = "pathvalidate is a Python library to sanitize/validate a string such as filenames/file-paths/etc."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f"},
|
||||
{file = "pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=2.4)", "sphinx_rtd_theme (>=1.2.2)", "urllib3 (<2)"]
|
||||
readme = ["path (>=13,<18)", "readmemaker (>=1.2.0)"]
|
||||
test = ["Faker (>=1.0.8)", "allpairspy (>=2)", "click (>=6.2)", "pytest (>=6.0.1)", "pytest-md-report (>=0.6.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "pbs-installer"
|
||||
version = "2025.12.17"
|
||||
@@ -7518,69 +7553,58 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-aio"
|
||||
version = "0.3.0"
|
||||
description = "Async Key-Value"
|
||||
version = "0.4.4"
|
||||
description = "Async Key-Value Store - A pluggable interface for KV Stores"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64"},
|
||||
{file = "py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155"},
|
||||
{file = "py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d"},
|
||||
{file = "py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiofile = {version = ">=3.5.0", optional = true, markers = "extra == \"filetree\""}
|
||||
anyio = {version = ">=4.4.0", optional = true, markers = "extra == \"filetree\""}
|
||||
beartype = ">=0.20.0"
|
||||
cachetools = {version = ">=5.0.0", optional = true, markers = "extra == \"memory\""}
|
||||
diskcache = {version = ">=5.0.0", optional = true, markers = "extra == \"disk\""}
|
||||
keyring = {version = ">=25.6.0", optional = true, markers = "extra == \"keyring\""}
|
||||
pathvalidate = {version = ">=3.3.1", optional = true, markers = "extra == \"disk\""}
|
||||
py-key-value-shared = "0.3.0"
|
||||
redis = {version = ">=4.3.0", optional = true, markers = "extra == \"redis\""}
|
||||
typing-extensions = ">=4.15.0"
|
||||
|
||||
[package.extras]
|
||||
aerospike = ["aerospike (>=16.0.0)"]
|
||||
disk = ["diskcache (>=5.0.0)", "pathvalidate (>=3.3.1)"]
|
||||
docs = ["mkdocs (>=1.6.0)", "mkdocs-material (>=9.5.0)", "mkdocstrings-python (>=1.10.0)", "mkdocstrings[python] (>=0.30.0)"]
|
||||
duckdb = ["duckdb (>=1.1.1)", "pytz (>=2025.2)"]
|
||||
dynamodb = ["aioboto3 (>=13.3.0)", "types-aiobotocore-dynamodb (>=2.16.0)"]
|
||||
elasticsearch = ["aiohttp (>=3.12)", "elasticsearch (>=8.0.0)"]
|
||||
filetree = ["aiofile (>=3.5.0)", "anyio (>=4.4.0)"]
|
||||
firestore = ["google-auth (>=2.24.0)", "google-cloud-firestore (>=2.13.0)"]
|
||||
keyring = ["keyring (>=25.6.0)"]
|
||||
keyring-linux = ["dbus-python (>=1.4.0)", "keyring (>=25.6.0)"]
|
||||
memcached = ["aiomcache (>=0.8.0)"]
|
||||
memory = ["cachetools (>=5.0.0)"]
|
||||
mongodb = ["pymongo (>=4.0.0)"]
|
||||
opensearch = ["opensearch-py[async] (>=2.0.0)"]
|
||||
postgresql = ["asyncpg (>=0.30.0)"]
|
||||
pydantic = ["pydantic (>=2.11.9)"]
|
||||
redis = ["redis (>=4.3.0)"]
|
||||
rocksdb = ["rocksdict (>=0.3.2) ; python_full_version < \"3.12.0\"", "rocksdict (>=0.3.24) ; python_full_version >= \"3.12.0\""]
|
||||
s3 = ["aioboto3 (>=13.3.0)", "types-aiobotocore-s3 (>=2.16.0)"]
|
||||
valkey = ["valkey-glide (>=2.1.0)"]
|
||||
vault = ["hvac (>=2.3.0)", "types-hvac (>=2.3.0)"]
|
||||
wrappers-encryption = ["cryptography (>=45.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-shared"
|
||||
version = "0.3.0"
|
||||
description = "Shared Key-Value"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298"},
|
||||
{file = "py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
beartype = ">=0.20.0"
|
||||
typing-extensions = ">=4.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
|
||||
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
|
||||
{file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
|
||||
{file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7815,32 +7839,6 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
|
||||
toml = ["tomli (>=2.0.1)"]
|
||||
yaml = ["pyyaml (>=6.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pydocket"
|
||||
version = "0.16.6"
|
||||
description = "A distributed background task system for Python functions"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183"},
|
||||
{file = "pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cloudpickle = ">=3.1.1"
|
||||
fakeredis = {version = ">=2.32.1", extras = ["lua"]}
|
||||
opentelemetry-api = ">=1.33.0"
|
||||
opentelemetry-exporter-prometheus = ">=0.60b0"
|
||||
opentelemetry-instrumentation = ">=0.60b0"
|
||||
prometheus-client = ">=0.21.1"
|
||||
py-key-value-aio = {version = ">=0.3.0", extras = ["memory", "redis"]}
|
||||
python-json-logger = ">=2.0.7"
|
||||
redis = ">=5"
|
||||
rich = ">=13.9.4"
|
||||
typer = ">=0.15.1"
|
||||
typing-extensions = ">=4.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "pydub"
|
||||
version = "0.25.1"
|
||||
@@ -7919,14 +7917,14 @@ windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
version = "2.12.1"
|
||||
description = "JSON Web Token implementation in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
|
||||
{file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
|
||||
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
|
||||
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7934,9 +7932,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp
|
||||
|
||||
[package.extras]
|
||||
crypto = ["cryptography (>=3.4.0)"]
|
||||
dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
|
||||
tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pylatexenc"
|
||||
@@ -11589,14 +11587,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.7.5"
|
||||
version = "6.8.0"
|
||||
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"},
|
||||
{file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"},
|
||||
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
|
||||
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -13761,24 +13759,22 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.4"
|
||||
version = "6.5.5"
|
||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
|
||||
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
|
||||
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13960,24 +13956,6 @@ files = [
|
||||
{file = "trove_classifiers-2026.1.14.14.tar.gz", hash = "sha256:00492545a1402b09d4858605ba190ea33243d361e2b01c9c296ce06b5c3325f3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.21.1"
|
||||
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01"},
|
||||
{file = "typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=8.0.0"
|
||||
rich = ">=10.11.0"
|
||||
shellingham = ">=1.3.0"
|
||||
typing-extensions = ">=3.7.4.3"
|
||||
|
||||
[[package]]
|
||||
name = "typer-slim"
|
||||
version = "0.21.1"
|
||||
@@ -14063,6 +14041,18 @@ files = [
|
||||
{file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uncalled-for"
|
||||
version = "0.2.0"
|
||||
description = "Async dependency injection for Python functions"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f"},
|
||||
{file = "uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uri-template"
|
||||
version = "1.3.0"
|
||||
@@ -14160,6 +14150,128 @@ platformdirs = ">=3.9.1,<5"
|
||||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
description = "Simple, modern and high performance file watching and code reload in python."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844"},
|
||||
{file = "watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4"},
|
||||
{file = "watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9"},
|
||||
{file = "watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef"},
|
||||
{file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099"},
|
||||
{file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-win32.whl", hash = "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b"},
|
||||
{file = "watchfiles-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957"},
|
||||
{file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3"},
|
||||
{file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2"},
|
||||
{file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d"},
|
||||
{file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b"},
|
||||
{file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88"},
|
||||
{file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336"},
|
||||
{file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24"},
|
||||
{file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49"},
|
||||
{file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f"},
|
||||
{file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34"},
|
||||
{file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc"},
|
||||
{file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e"},
|
||||
{file = "watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.14"
|
||||
|
||||
@@ -21,11 +21,12 @@ async def main():
|
||||
|
||||
|
||||
def set_stale_task_error():
|
||||
# started_at is naive UTC; strip tzinfo before comparing.
|
||||
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1)
|
||||
with session_maker() as session:
|
||||
session.query(MaintenanceTask).filter(
|
||||
MaintenanceTask.status == MaintenanceTaskStatus.WORKING,
|
||||
MaintenanceTask.started_at
|
||||
< datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
MaintenanceTask.started_at < cutoff,
|
||||
).update({MaintenanceTask.status: MaintenanceTaskStatus.ERROR})
|
||||
session.commit()
|
||||
|
||||
@@ -37,9 +38,10 @@ async def run_tasks():
|
||||
if not task:
|
||||
return
|
||||
|
||||
# Update the status
|
||||
# started_at/updated_at are naive UTC; strip tzinfo.
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
task.status = MaintenanceTaskStatus.WORKING
|
||||
task.updated_at = task.started_at = datetime.now(timezone.utc)
|
||||
task.updated_at = task.started_at = now_utc
|
||||
session.commit()
|
||||
|
||||
try:
|
||||
|
||||
@@ -46,6 +46,7 @@ from server.routes.org_invitations import ( # noqa: E402
|
||||
)
|
||||
from server.routes.orgs import org_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.service import service_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
|
||||
from server.sharing.shared_conversation_router import ( # noqa: E402
|
||||
@@ -112,6 +113,7 @@ if GITLAB_APP_CLIENT_ID:
|
||||
base_app.include_router(gitlab_integration_router)
|
||||
|
||||
base_app.include_router(api_keys_router) # Add routes for API key management
|
||||
base_app.include_router(service_router) # Add routes for internal service API
|
||||
base_app.include_router(org_router) # Add routes for organization management
|
||||
base_app.include_router(
|
||||
verified_models_router
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
@@ -59,6 +60,10 @@ class SaasUserAuth(UserAuth):
|
||||
_secrets: Secrets | None = None
|
||||
accepted_tos: bool | None = None
|
||||
auth_type: AuthType = AuthType.COOKIE
|
||||
# API key context fields - populated when authenticated via API key
|
||||
api_key_org_id: UUID | None = None
|
||||
api_key_id: int | None = None
|
||||
api_key_name: str | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_id
|
||||
@@ -283,14 +288,19 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
return None
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
user_id = await api_key_store.validate_api_key(api_key)
|
||||
if not user_id:
|
||||
validation_result = await api_key_store.validate_api_key(api_key)
|
||||
if not validation_result:
|
||||
return None
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
offline_token = await token_manager.load_offline_token(
|
||||
validation_result.user_id
|
||||
)
|
||||
saas_user_auth = SaasUserAuth(
|
||||
user_id=user_id,
|
||||
user_id=validation_result.user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=validation_result.org_id,
|
||||
api_key_id=validation_result.key_id,
|
||||
api_key_name=validation_result.key_name,
|
||||
)
|
||||
await saas_user_auth.refresh()
|
||||
return saas_user_auth
|
||||
|
||||
@@ -77,6 +77,9 @@ PERMITTED_CORS_ORIGINS = [
|
||||
)
|
||||
]
|
||||
|
||||
# Controls whether new orgs/users default to V1 API (env: DEFAULT_V1_ENABLED)
|
||||
DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true')
|
||||
|
||||
|
||||
def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
"""Build the LiteLLM proxy model path based on model name.
|
||||
|
||||
@@ -12,11 +12,8 @@ from server.auth.auth_error import (
|
||||
)
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.saas_user_auth import SaasUserAuth, token_manager
|
||||
from server.routes.auth import (
|
||||
get_cookie_domain,
|
||||
get_cookie_samesite,
|
||||
set_response_cookie,
|
||||
)
|
||||
from server.routes.auth import set_response_cookie
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
|
||||
@@ -93,8 +90,8 @@ class SetAuthCookieMiddleware:
|
||||
if keycloak_auth_cookie:
|
||||
response.delete_cookie(
|
||||
key='keycloak_auth',
|
||||
domain=get_cookie_domain(request),
|
||||
samesite=get_cookie_samesite(request),
|
||||
domain=get_cookie_domain(),
|
||||
samesite=get_cookie_samesite(),
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -185,6 +182,10 @@ class SetAuthCookieMiddleware:
|
||||
if path.startswith('/api/v1/webhooks/'):
|
||||
return False
|
||||
|
||||
# Service API uses its own authentication (X-Service-API-Key header)
|
||||
if path.startswith('/api/service/'):
|
||||
return False
|
||||
|
||||
is_mcp = path.startswith('/mcp')
|
||||
is_api_route = path.startswith('/api')
|
||||
return is_api_route or is_mcp
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
@@ -11,7 +13,8 @@ from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
# Helper functions for BYOR API key management
|
||||
@@ -150,6 +153,16 @@ class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class CurrentApiKeyResponse(BaseModel):
|
||||
"""Response model for the current API key endpoint."""
|
||||
|
||||
id: int
|
||||
name: str | None
|
||||
org_id: str
|
||||
user_id: str
|
||||
auth_type: str
|
||||
|
||||
|
||||
def api_key_to_response(key: ApiKey) -> ApiKeyResponse:
|
||||
"""Convert an ApiKey model to an ApiKeyResponse."""
|
||||
return ApiKeyResponse(
|
||||
@@ -262,6 +275,46 @@ async def delete_api_key(
|
||||
)
|
||||
|
||||
|
||||
@api_router.get('/current', tags=['Keys'])
|
||||
async def get_current_api_key(
|
||||
request: Request,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CurrentApiKeyResponse:
|
||||
"""Get information about the currently authenticated API key.
|
||||
|
||||
This endpoint returns metadata about the API key used for the current request,
|
||||
including the org_id associated with the key. This is useful for API key
|
||||
callers who need to know which organization context their key operates in.
|
||||
|
||||
Returns 400 if not authenticated via API key (e.g., using cookie auth).
|
||||
"""
|
||||
user_auth = await get_user_auth(request)
|
||||
|
||||
# Check if authenticated via API key
|
||||
if user_auth.get_auth_type() != AuthType.BEARER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This endpoint requires API key authentication. Not available for cookie-based auth.',
|
||||
)
|
||||
|
||||
# In SaaS context, bearer auth always produces SaasUserAuth
|
||||
saas_user_auth = cast(SaasUserAuth, user_auth)
|
||||
|
||||
if saas_user_auth.api_key_org_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This API key was created before organization support. Please regenerate your API key to use this endpoint.',
|
||||
)
|
||||
|
||||
return CurrentApiKeyResponse(
|
||||
id=saas_user_auth.api_key_id,
|
||||
name=saas_user_auth.api_key_name,
|
||||
org_id=str(saas_user_auth.api_key_org_id),
|
||||
user_id=user_id,
|
||||
auth_type=saas_user_auth.auth_type.value,
|
||||
)
|
||||
|
||||
|
||||
@api_router.get('/llm/byor', tags=['Keys'])
|
||||
async def get_llm_api_key_for_byor(
|
||||
user_id: str = Depends(get_user_id),
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import uuid
|
||||
import warnings
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Literal, Optional, cast
|
||||
from typing import Annotated, Optional, cast
|
||||
from urllib.parse import quote, urlencode
|
||||
from uuid import UUID as parse_uuid
|
||||
|
||||
@@ -27,7 +27,7 @@ from server.auth.user.user_authorizer import (
|
||||
depends_user_authorizer,
|
||||
)
|
||||
from server.config import sign_token
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
|
||||
from server.routes.event_webhook import _get_session_api_key, _get_user_id
|
||||
from server.services.org_invitation_service import (
|
||||
EmailMismatchError,
|
||||
@@ -37,12 +37,12 @@ from server.services.org_invitation_service import (
|
||||
UserAlreadyMemberError,
|
||||
)
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite, get_web_url
|
||||
from sqlalchemy import select
|
||||
from storage.database import a_session_maker
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.config import get_global_config
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType, TokenResponse
|
||||
@@ -77,7 +77,7 @@ def set_response_cookie(
|
||||
signed_token = sign_token(cookie_data, config.jwt_secret.get_secret_value()) # type: ignore
|
||||
|
||||
# Set secure cookie with signed token
|
||||
domain = get_cookie_domain(request)
|
||||
domain = get_cookie_domain()
|
||||
if domain:
|
||||
response.set_cookie(
|
||||
key='keycloak_auth',
|
||||
@@ -85,7 +85,7 @@ def set_response_cookie(
|
||||
domain=domain,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite=get_cookie_samesite(request),
|
||||
samesite=get_cookie_samesite(),
|
||||
)
|
||||
else:
|
||||
response.set_cookie(
|
||||
@@ -93,30 +93,10 @@ def set_response_cookie(
|
||||
value=signed_token,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite=get_cookie_samesite(request),
|
||||
samesite=get_cookie_samesite(),
|
||||
)
|
||||
|
||||
|
||||
def get_cookie_domain(request: Request) -> str | None:
|
||||
# for now just use the full hostname except for staging stacks.
|
||||
return (
|
||||
None
|
||||
if not request.url.hostname
|
||||
or request.url.hostname.endswith('staging.all-hands.dev')
|
||||
else request.url.hostname
|
||||
)
|
||||
|
||||
|
||||
def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
|
||||
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
|
||||
return (
|
||||
'lax'
|
||||
if request.url.hostname == 'localhost'
|
||||
or (request.url.hostname or '').endswith('staging.all-hands.dev')
|
||||
else 'strict'
|
||||
)
|
||||
|
||||
|
||||
def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None]:
|
||||
"""Extract redirect URL, reCAPTCHA token, and invitation token from OAuth state.
|
||||
|
||||
@@ -140,19 +120,6 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
|
||||
return state, None, None
|
||||
|
||||
|
||||
# Keep alias for backward compatibility
|
||||
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
|
||||
"""Extract redirect URL and reCAPTCHA token from OAuth state.
|
||||
|
||||
Deprecated: Use _extract_oauth_state instead.
|
||||
|
||||
Returns:
|
||||
Tuple of (redirect_url, recaptcha_token). Token may be None.
|
||||
"""
|
||||
redirect_url, recaptcha_token, _ = _extract_oauth_state(state)
|
||||
return redirect_url, recaptcha_token
|
||||
|
||||
|
||||
@oauth_router.get('/keycloak/callback')
|
||||
async def keycloak_callback(
|
||||
request: Request,
|
||||
@@ -183,10 +150,7 @@ async def keycloak_callback(
|
||||
detail='Missing code in request params',
|
||||
)
|
||||
|
||||
web_url = get_global_config().web_url
|
||||
if not web_url:
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
web_url = f'{scheme}://{request.url.netloc}'
|
||||
web_url = get_web_url(request)
|
||||
redirect_uri = web_url + request.url.path
|
||||
|
||||
(
|
||||
@@ -313,7 +277,9 @@ async def keycloak_callback(
|
||||
else:
|
||||
raise
|
||||
|
||||
verification_redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
|
||||
verification_redirect_url = (
|
||||
f'{web_url}/login?email_verification_required=true&user_id={user_id}'
|
||||
)
|
||||
if rate_limited:
|
||||
verification_redirect_url = f'{verification_redirect_url}&rate_limited=true'
|
||||
|
||||
@@ -474,9 +440,7 @@ async def keycloak_callback(
|
||||
# If the user hasn't accepted the TOS, redirect to the TOS page
|
||||
if not has_accepted_tos:
|
||||
encoded_redirect_url = quote(redirect_url, safe='')
|
||||
tos_redirect_url = (
|
||||
f'{request.base_url}accept-tos?redirect_url={encoded_redirect_url}'
|
||||
)
|
||||
tos_redirect_url = f'{web_url}/accept-tos?redirect_url={encoded_redirect_url}'
|
||||
if invitation_token:
|
||||
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
|
||||
response = RedirectResponse(tos_redirect_url, status_code=302)
|
||||
@@ -508,10 +472,9 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'error': 'Missing code in request params'},
|
||||
)
|
||||
scheme = 'https'
|
||||
if request.url.hostname == 'localhost':
|
||||
scheme = 'http'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}{request.url.path}'
|
||||
|
||||
web_url = get_web_url(request)
|
||||
redirect_uri = web_url + request.url.path
|
||||
logger.debug(f'code: {code}, redirect_uri: {redirect_uri}')
|
||||
|
||||
(
|
||||
@@ -533,15 +496,14 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
|
||||
)
|
||||
|
||||
redirect_url, _, _ = _extract_oauth_state(state)
|
||||
return RedirectResponse(
|
||||
redirect_url if redirect_url else request.base_url, status_code=302
|
||||
)
|
||||
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
|
||||
|
||||
|
||||
@oauth_router.get('/github/callback')
|
||||
async def github_dummy_callback(request: Request):
|
||||
"""Callback for GitHub that just forwards the user to the app base URL."""
|
||||
return RedirectResponse(request.base_url, status_code=302)
|
||||
web_url = get_web_url(request)
|
||||
return RedirectResponse(web_url, status_code=302)
|
||||
|
||||
|
||||
@api_router.post('/authenticate')
|
||||
@@ -563,8 +525,8 @@ async def authenticate(request: Request):
|
||||
if keycloak_auth_cookie:
|
||||
response.delete_cookie(
|
||||
key='keycloak_auth',
|
||||
domain=get_cookie_domain(request),
|
||||
samesite=get_cookie_samesite(request),
|
||||
domain=get_cookie_domain(),
|
||||
samesite=get_cookie_samesite(),
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -588,7 +550,8 @@ async def accept_tos(request: Request):
|
||||
|
||||
# Get redirect URL from request body
|
||||
body = await request.json()
|
||||
redirect_url = body.get('redirect_url', str(request.base_url))
|
||||
web_url = get_web_url(request)
|
||||
redirect_url = body.get('redirect_url', str(web_url))
|
||||
|
||||
# Update user settings with TOS acceptance
|
||||
accepted_tos: datetime = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
@@ -618,7 +581,7 @@ async def accept_tos(request: Request):
|
||||
response=response,
|
||||
keycloak_access_token=access_token.get_secret_value(),
|
||||
keycloak_refresh_token=refresh_token.get_secret_value(),
|
||||
secure=False if request.url.hostname == 'localhost' else True,
|
||||
secure=not IS_LOCAL_ENV,
|
||||
accepted_tos=True,
|
||||
)
|
||||
return response
|
||||
@@ -635,8 +598,8 @@ async def logout(request: Request):
|
||||
# Always delete the cookie regardless of what happens
|
||||
response.delete_cookie(
|
||||
key='keycloak_auth',
|
||||
domain=get_cookie_domain(request),
|
||||
samesite=get_cookie_samesite(request),
|
||||
domain=get_cookie_domain(),
|
||||
samesite=get_cookie_samesite(),
|
||||
)
|
||||
|
||||
# Try to properly logout from Keycloak, but don't fail if it doesn't work
|
||||
|
||||
@@ -11,8 +11,8 @@ from integrations import stripe_service
|
||||
from pydantic import BaseModel
|
||||
from server.constants import STRIPE_API_KEY
|
||||
from server.logger import logger
|
||||
from server.utils.url_utils import get_web_url
|
||||
from sqlalchemy import select
|
||||
from starlette.datastructures import URL
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.database import a_session_maker
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
@@ -151,7 +151,7 @@ async def create_customer_setup_session(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Could not find or create customer for user',
|
||||
)
|
||||
base_url = _get_base_url(request)
|
||||
base_url = get_web_url(request)
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_info['customer_id'],
|
||||
mode='setup',
|
||||
@@ -170,7 +170,7 @@ async def create_checkout_session(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CreateBillingSessionResponse:
|
||||
await validate_billing_enabled()
|
||||
base_url = _get_base_url(request)
|
||||
base_url = get_web_url(request)
|
||||
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
|
||||
if not customer_info:
|
||||
raise HTTPException(
|
||||
@@ -198,8 +198,8 @@ async def create_checkout_session(
|
||||
saved_payment_method_options={
|
||||
'payment_method_save': 'enabled',
|
||||
},
|
||||
success_url=f'{base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
cancel_url=f'{base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
success_url=f'{base_url}/api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
cancel_url=f'{base_url}/api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
)
|
||||
logger.info(
|
||||
'created_stripe_checkout_session',
|
||||
@@ -300,7 +300,7 @@ async def success_callback(session_id: str, request: Request):
|
||||
await session.commit()
|
||||
|
||||
return RedirectResponse(
|
||||
f'{_get_base_url(request)}settings/billing?checkout=success', status_code=302
|
||||
f'{get_web_url(request)}/settings/billing?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
|
||||
@@ -325,17 +325,9 @@ async def cancel_callback(session_id: str, request: Request):
|
||||
)
|
||||
billing_session.status = 'cancelled'
|
||||
billing_session.updated_at = datetime.now(UTC)
|
||||
session.merge(billing_session)
|
||||
await session.merge(billing_session)
|
||||
await session.commit()
|
||||
|
||||
return RedirectResponse(
|
||||
f'{_get_base_url(request)}settings/billing?checkout=cancel', status_code=302
|
||||
f'{get_web_url(request)}/settings/billing?checkout=cancel', status_code=302
|
||||
)
|
||||
|
||||
|
||||
def _get_base_url(request: Request) -> URL:
|
||||
# Never send any part of the credit card process over a non secure connection
|
||||
base_url = request.base_url
|
||||
if base_url.hostname != 'localhost':
|
||||
base_url = base_url.replace(scheme='https')
|
||||
return base_url
|
||||
|
||||
@@ -7,8 +7,10 @@ from pydantic import BaseModel, field_validator
|
||||
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.constants import IS_LOCAL_ENV
|
||||
from server.routes.auth import set_response_cookie
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from server.utils.url_utils import get_web_url
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -87,7 +89,7 @@ async def update_email(
|
||||
response=response,
|
||||
keycloak_access_token=user_auth.access_token.get_secret_value(),
|
||||
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
|
||||
secure=False if request.url.hostname == 'localhost' else True,
|
||||
secure=not IS_LOCAL_ENV,
|
||||
accepted_tos=user_auth.accepted_tos or False,
|
||||
)
|
||||
|
||||
@@ -156,8 +158,8 @@ async def verified_email(request: Request):
|
||||
await user_auth.refresh() # refresh so access token has updated email
|
||||
user_auth.email_verified = True
|
||||
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/settings/user'
|
||||
|
||||
redirect_uri = f'{get_web_url(request)}/settings/user'
|
||||
response = RedirectResponse(redirect_uri, status_code=302)
|
||||
|
||||
# need to set auth cookie to the new tokens
|
||||
@@ -180,11 +182,10 @@ async def verified_email(request: Request):
|
||||
|
||||
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'
|
||||
if is_auth_flow:
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/login?email_verified=true'
|
||||
redirect_uri = f'{get_web_url(request)}/login?email_verified=true'
|
||||
else:
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
|
||||
redirect_uri = f'{get_web_url(request)}/api/email/verified'
|
||||
logger.info(f'Redirect URI: {redirect_uri}')
|
||||
await keycloak_admin.a_send_verify_email(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi.responses import (
|
||||
RedirectResponse,
|
||||
)
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.slack.slack_errors import SlackError, SlackErrorCode
|
||||
from integrations.slack.slack_manager import SlackManager
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
@@ -37,7 +38,7 @@ from storage.slack_team_store import SlackTeamStore
|
||||
from storage.slack_user import SlackUser
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.service_types import ProviderTimeoutError, ProviderType
|
||||
from openhands.server.shared import config, sio
|
||||
|
||||
signature_verifier = SignatureVerifier(signing_secret=SLACK_SIGNING_SECRET)
|
||||
@@ -322,9 +323,129 @@ async def on_event(request: Request, background_tasks: BackgroundTasks):
|
||||
return JSONResponse({'success': True})
|
||||
|
||||
|
||||
@slack_router.post('/on-options-load')
|
||||
async def on_options_load(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Handle external_select options loading (block_suggestion payload).
|
||||
|
||||
This endpoint is called by Slack when a user interacts with an external_select
|
||||
element. It supports dynamic repository search with pagination.
|
||||
|
||||
The endpoint:
|
||||
1. Authenticates the Slack user
|
||||
2. Searches for repositories matching the user's query
|
||||
3. Returns up to 100 options for the dropdown
|
||||
|
||||
Configuration: Set the Options Load URL in Slack App settings to:
|
||||
https://your-domain/slack/on-options-load
|
||||
"""
|
||||
if not SLACK_WEBHOOKS_ENABLED:
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
body = await request.body()
|
||||
form = await request.form()
|
||||
payload_str = form.get('payload')
|
||||
if not payload_str:
|
||||
logger.warning('slack_on_options_load: No payload in request')
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
payload = json.loads(payload_str)
|
||||
|
||||
logger.info('slack_on_options_load', extra={'payload': payload})
|
||||
|
||||
# Verify the signature
|
||||
if not signature_verifier.is_valid(
|
||||
body=body,
|
||||
timestamp=request.headers.get('X-Slack-Request-Timestamp'),
|
||||
signature=request.headers.get('X-Slack-Signature'),
|
||||
):
|
||||
raise HTTPException(status_code=403, detail='invalid_request')
|
||||
|
||||
# Verify this is a block_suggestion payload
|
||||
if payload.get('type') != 'block_suggestion':
|
||||
logger.warning(
|
||||
f"slack_on_options_load: Unexpected payload type: {payload.get('type')}"
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
slack_user_id = payload['user']['id']
|
||||
search_value = payload.get('value', '') # What user typed in the search box
|
||||
|
||||
# Authenticate user
|
||||
slack_user, saas_user_auth = await slack_manager.authenticate_user(slack_user_id)
|
||||
|
||||
if not slack_user or not saas_user_auth:
|
||||
# Send ephemeral message asking user to link their account
|
||||
background_tasks.add_task(
|
||||
slack_manager.handle_slack_error,
|
||||
payload,
|
||||
SlackError(
|
||||
SlackErrorCode.USER_NOT_AUTHENTICATED,
|
||||
message_kwargs={'login_link': _generate_login_link()},
|
||||
log_context={'slack_user_id': slack_user_id},
|
||||
),
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
try:
|
||||
# Search for repositories matching the query
|
||||
# Limit to 20 repos for fast initial load. Users can search for repos
|
||||
# not in this list using the type-ahead search functionality.
|
||||
options = await slack_manager.search_repos_for_slack(
|
||||
saas_user_auth, query=search_value, per_page=20
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'slack_on_options_load_success',
|
||||
extra={
|
||||
'slack_user_id': slack_user_id,
|
||||
'search_value': search_value,
|
||||
'num_options': len(options),
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse({'options': options})
|
||||
|
||||
except ProviderTimeoutError as e:
|
||||
# Handle provider timeout with user notification
|
||||
background_tasks.add_task(
|
||||
slack_manager.handle_slack_error,
|
||||
payload,
|
||||
SlackError(
|
||||
SlackErrorCode.PROVIDER_TIMEOUT,
|
||||
log_context={'slack_user_id': slack_user_id, 'error': str(e)},
|
||||
),
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'slack_options_load_error',
|
||||
extra={
|
||||
'slack_user_id': slack_user_id,
|
||||
'search_value': search_value,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Notify user about the unexpected error with error code
|
||||
background_tasks.add_task(
|
||||
slack_manager.handle_slack_error,
|
||||
payload,
|
||||
SlackError(
|
||||
SlackErrorCode.UNEXPECTED_ERROR,
|
||||
log_context={'slack_user_id': slack_user_id, 'error': str(e)},
|
||||
),
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
|
||||
@slack_router.post('/on-form-interaction')
|
||||
async def on_form_interaction(request: Request, background_tasks: BackgroundTasks):
|
||||
"""We check the nonce to start a conversation"""
|
||||
"""Handle repository selection form submission.
|
||||
|
||||
When a user selects a repository from the external_select dropdown,
|
||||
this endpoint passes the payload to the manager which retrieves the
|
||||
original user message from Redis and starts the conversation.
|
||||
"""
|
||||
if not SLACK_WEBHOOKS_ENABLED:
|
||||
return JSONResponse({'success': 'slack_webhooks_disabled'})
|
||||
|
||||
@@ -334,7 +455,7 @@ async def on_form_interaction(request: Request, background_tasks: BackgroundTask
|
||||
|
||||
logger.info('slack_on_form_interaction', extra={'payload': payload})
|
||||
|
||||
# First verify the signature
|
||||
# Verify the signature
|
||||
if not signature_verifier.is_valid(
|
||||
body=body,
|
||||
timestamp=request.headers.get('X-Slack-Request-Timestamp'),
|
||||
@@ -343,40 +464,16 @@ async def on_form_interaction(request: Request, background_tasks: BackgroundTask
|
||||
raise HTTPException(status_code=403, detail='invalid_request')
|
||||
|
||||
assert payload['type'] == 'block_actions'
|
||||
selected_repository = payload['actions'][0]['selected_option'][
|
||||
'value'
|
||||
] # Get the repository
|
||||
if selected_repository == '-':
|
||||
selected_repository = None
|
||||
slack_user_id = payload['user']['id']
|
||||
channel_id = payload['container']['channel_id']
|
||||
team_id = payload['team']['id']
|
||||
# Hack - get original message_ts from element name
|
||||
attribs = payload['actions'][0]['action_id'].split('repository_select:')[-1]
|
||||
message_ts, thread_ts = attribs.split(':')
|
||||
thread_ts = None if thread_ts == 'None' else thread_ts
|
||||
# Get the original message
|
||||
# Get the text message
|
||||
# Start the conversation
|
||||
|
||||
payload = {
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'channel_id': channel_id,
|
||||
'slack_user_id': slack_user_id,
|
||||
'selected_repo': selected_repository,
|
||||
'team_id': team_id,
|
||||
}
|
||||
|
||||
message = Message(
|
||||
source=SourceType.SLACK,
|
||||
message=payload,
|
||||
)
|
||||
|
||||
background_tasks.add_task(slack_manager.receive_message, message)
|
||||
background_tasks.add_task(slack_manager.receive_form_interaction, payload)
|
||||
return JSONResponse({'success': True})
|
||||
|
||||
|
||||
def _generate_login_link(state: str = '') -> str:
|
||||
"""Generate the OAuth login link for Slack authentication."""
|
||||
return authorize_url_generator.generate(state)
|
||||
|
||||
|
||||
def _html_response(title: str, description: str, status_code: int) -> HTMLResponse:
|
||||
content = (
|
||||
'<style>body{background:#0d0f11;color:#ecedee;font-family:sans-serif;display:flex;justify-content:center;align-items:center;}</style>'
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from server.utils.url_utils import get_web_url
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
@@ -93,7 +94,7 @@ async def device_authorization(
|
||||
expires_in=DEVICE_CODE_EXPIRES_IN,
|
||||
)
|
||||
|
||||
base_url = str(http_request.base_url).rstrip('/')
|
||||
base_url = get_web_url(http_request)
|
||||
verification_uri = f'{base_url}/oauth/device/verify'
|
||||
verification_uri_complete = (
|
||||
f'{verification_uri}?user_code={device_code_entry.user_code}'
|
||||
|
||||
270
enterprise/server/routes/service.py
Normal file
270
enterprise/server/routes/service.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Service API routes for internal service-to-service communication.
|
||||
|
||||
This module provides endpoints for trusted internal services (e.g., automations service)
|
||||
to perform privileged operations like creating API keys on behalf of users.
|
||||
|
||||
Authentication is via a shared secret (X-Service-API-Key header) configured
|
||||
through the AUTOMATIONS_SERVICE_API_KEY environment variable.
|
||||
"""
|
||||
|
||||
import os
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Environment variable for the service API key
|
||||
AUTOMATIONS_SERVICE_API_KEY = os.getenv('AUTOMATIONS_SERVICE_API_KEY', '').strip()
|
||||
|
||||
service_router = APIRouter(prefix='/api/service', tags=['Service'])
|
||||
|
||||
|
||||
class CreateUserApiKeyRequest(BaseModel):
|
||||
"""Request model for creating an API key on behalf of a user."""
|
||||
|
||||
name: str # Required - used to identify the key
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError('name is required and cannot be empty')
|
||||
return v.strip()
|
||||
|
||||
|
||||
class CreateUserApiKeyResponse(BaseModel):
|
||||
"""Response model for created API key."""
|
||||
|
||||
key: str
|
||||
user_id: str
|
||||
org_id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ServiceInfoResponse(BaseModel):
|
||||
"""Response model for service info endpoint."""
|
||||
|
||||
service: str
|
||||
authenticated: bool
|
||||
|
||||
|
||||
async def validate_service_api_key(
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> str:
|
||||
"""
|
||||
Validate the service API key from the request header.
|
||||
|
||||
Args:
|
||||
x_service_api_key: The service API key from the X-Service-API-Key header
|
||||
|
||||
Returns:
|
||||
str: Service identifier for audit logging
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if key is missing or invalid
|
||||
HTTPException: 503 if service auth is not configured
|
||||
"""
|
||||
if not AUTOMATIONS_SERVICE_API_KEY:
|
||||
logger.warning(
|
||||
'Service authentication not configured (AUTOMATIONS_SERVICE_API_KEY not set)'
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Service authentication not configured',
|
||||
)
|
||||
|
||||
if not x_service_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='X-Service-API-Key header is required',
|
||||
)
|
||||
|
||||
if x_service_api_key != AUTOMATIONS_SERVICE_API_KEY:
|
||||
logger.warning('Invalid service API key attempted')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid service API key',
|
||||
)
|
||||
|
||||
return 'automations-service'
|
||||
|
||||
|
||||
@service_router.get('/health')
|
||||
async def service_health() -> dict:
|
||||
"""Health check endpoint for the service API.
|
||||
|
||||
This endpoint does not require authentication and can be used
|
||||
to verify the service routes are accessible.
|
||||
"""
|
||||
return {
|
||||
'status': 'ok',
|
||||
'service_auth_configured': bool(AUTOMATIONS_SERVICE_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
@service_router.post('/users/{user_id}/orgs/{org_id}/api-keys')
|
||||
async def get_or_create_api_key_for_user(
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
request: CreateUserApiKeyRequest,
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> CreateUserApiKeyResponse:
|
||||
"""
|
||||
Get or create an API key for a user on behalf of the automations service.
|
||||
|
||||
If a key with the given name already exists for the user/org and is not expired,
|
||||
returns the existing key. Otherwise, creates a new key.
|
||||
|
||||
The created/returned keys are system keys and are:
|
||||
- Not visible to the user in their API keys list
|
||||
- Not deletable by the user
|
||||
- Never expire
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
org_id: The organization ID
|
||||
request: Request body containing name (required)
|
||||
x_service_api_key: Service API key header for authentication
|
||||
|
||||
Returns:
|
||||
CreateUserApiKeyResponse: The API key and metadata
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if service key is invalid
|
||||
HTTPException: 404 if user not found
|
||||
HTTPException: 403 if user is not a member of the specified org
|
||||
"""
|
||||
# Validate service API key
|
||||
service_id = await validate_service_api_key(x_service_api_key)
|
||||
|
||||
# Verify user exists
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if not user:
|
||||
logger.warning(
|
||||
'Service attempted to create key for non-existent user',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'User {user_id} not found',
|
||||
)
|
||||
|
||||
# Verify user is a member of the specified org
|
||||
org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id))
|
||||
if not org_member:
|
||||
logger.warning(
|
||||
'Service attempted to create key for user not in org',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'User {user_id} is not a member of org {org_id}',
|
||||
)
|
||||
|
||||
# Get or create the system API key
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
|
||||
try:
|
||||
api_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=request.name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Failed to get or create system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to get or create API key',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Service created API key for user',
|
||||
extra={
|
||||
'service_id': service_id,
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': request.name,
|
||||
},
|
||||
)
|
||||
|
||||
return CreateUserApiKeyResponse(
|
||||
key=api_key,
|
||||
user_id=user_id,
|
||||
org_id=str(org_id),
|
||||
name=request.name,
|
||||
)
|
||||
|
||||
|
||||
@service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}')
|
||||
async def delete_user_api_key(
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
key_name: str,
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> dict:
|
||||
"""
|
||||
Delete a system API key created by the service.
|
||||
|
||||
This endpoint allows the automations service to clean up API keys
|
||||
it previously created for users.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
org_id: The organization ID
|
||||
key_name: The name of the key to delete (without __SYSTEM__: prefix)
|
||||
x_service_api_key: Service API key header for authentication
|
||||
|
||||
Returns:
|
||||
dict: Success message
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if service key is invalid
|
||||
HTTPException: 404 if key not found
|
||||
"""
|
||||
# Validate service API key
|
||||
service_id = await validate_service_api_key(x_service_api_key)
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
|
||||
# Delete the key by name (wrap with system key prefix since service creates system keys)
|
||||
system_key_name = api_key_store.make_system_key_name(key_name)
|
||||
success = await api_key_store.delete_api_key_by_name(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
allow_system=True,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Service deleted API key for user',
|
||||
extra={
|
||||
'service_id': service_id,
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': key_name,
|
||||
},
|
||||
)
|
||||
|
||||
return {'message': 'API key deleted successfully'}
|
||||
@@ -365,14 +365,12 @@ class OrgInvitationService:
|
||||
'Failed to set up organization access. Please try again.'
|
||||
)
|
||||
|
||||
# Step 5: Add user to organization
|
||||
from storage.org_member_store import OrgMemberStore as OMS
|
||||
|
||||
org_member_kwargs = OMS.get_kwargs_from_settings(settings)
|
||||
# Don't override with org defaults - use invitation-specified role
|
||||
org_member_kwargs.pop('llm_model', None)
|
||||
org_member_kwargs.pop('llm_base_url', None)
|
||||
# Step 4.5: Fetch organization to get its LLM settings
|
||||
org = await OrgStore.get_org_by_id(invitation.org_id)
|
||||
if not org:
|
||||
raise InvitationInvalidError('Organization not found')
|
||||
|
||||
# Step 5: Add user to organization with inherited org LLM settings
|
||||
# Get the llm_api_key as string (it's SecretStr | None in Settings)
|
||||
llm_api_key = (
|
||||
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
|
||||
@@ -384,6 +382,9 @@ class OrgInvitationService:
|
||||
role_id=invitation.role_id,
|
||||
llm_api_key=llm_api_key,
|
||||
status='active',
|
||||
llm_model=org.default_llm_model,
|
||||
llm_base_url=org.default_llm_base_url,
|
||||
max_iterations=org.default_max_iterations,
|
||||
)
|
||||
|
||||
# Step 6: Mark invitation as accepted
|
||||
|
||||
171
enterprise/server/sharing/aws_shared_event_service.py
Normal file
171
enterprise/server/sharing/aws_shared_event_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Implementation of SharedEventService for AWS S3.
|
||||
|
||||
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
|
||||
|
||||
Uses role-based authentication (no credentials needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
import boto3
|
||||
from fastapi import Request
|
||||
from pydantic import Field
|
||||
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.aws_event_service import AwsEventService
|
||||
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 AwsSharedEventService(SharedEventService):
|
||||
"""Implementation of SharedEventService for AWS S3 that validates shared access.
|
||||
|
||||
Uses role-based authentication (no credentials needed).
|
||||
"""
|
||||
|
||||
shared_conversation_info_service: SharedConversationInfoService
|
||||
s3_client: Any
|
||||
bucket_name: str
|
||||
|
||||
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
return None
|
||||
|
||||
return AwsEventService(
|
||||
s3_client=self.s3_client,
|
||||
bucket_name=self.bucket_name,
|
||||
prefix=Path('users'),
|
||||
user_id=shared_conversation_info.created_by_user_id,
|
||||
app_conversation_info_service=None,
|
||||
app_conversation_info_load_tasks={},
|
||||
)
|
||||
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: UUID
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
return None
|
||||
|
||||
# If conversation is shared, get the event
|
||||
return await event_service.get_event(conversation_id, event_id)
|
||||
|
||||
async def search_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> EventPage:
|
||||
"""Search events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return EventPage(items=[], next_page_id=None)
|
||||
|
||||
# If conversation is shared, search events for this conversation
|
||||
return await event_service.search_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def count_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return 0
|
||||
|
||||
# If conversation is shared, count events for this conversation
|
||||
return await event_service.count_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
)
|
||||
|
||||
|
||||
class AwsSharedEventServiceInjector(SharedEventServiceInjector):
|
||||
bucket_name: str | None = Field(
|
||||
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
|
||||
)
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SharedEventService, None]:
|
||||
# Define inline to prevent circular lookup
|
||||
from openhands.app_server.config import get_db_session
|
||||
|
||||
async with get_db_session(state, request) as db_session:
|
||||
shared_conversation_info_service = SQLSharedConversationInfoService(
|
||||
db_session=db_session
|
||||
)
|
||||
|
||||
bucket_name = self.bucket_name
|
||||
if bucket_name is None:
|
||||
raise ValueError(
|
||||
'bucket_name is required. Set FILE_STORE_PATH environment variable.'
|
||||
)
|
||||
|
||||
# Use role-based authentication - boto3 will automatically
|
||||
# use IAM role credentials when running in AWS
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
endpoint_url=os.getenv('AWS_S3_ENDPOINT'),
|
||||
)
|
||||
|
||||
service = AwsSharedEventService(
|
||||
shared_conversation_info_service=shared_conversation_info_service,
|
||||
s3_client=s3_client,
|
||||
bucket_name=bucket_name,
|
||||
)
|
||||
yield service
|
||||
@@ -5,19 +5,45 @@ from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
from server.sharing.shared_event_service import (
|
||||
SharedEventService,
|
||||
SharedEventServiceInjector,
|
||||
)
|
||||
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
|
||||
from openhands.utils.environment import StorageProvider, get_storage_provider
|
||||
|
||||
|
||||
def get_shared_event_service_injector() -> SharedEventServiceInjector:
|
||||
"""Get the appropriate SharedEventServiceInjector based on configuration.
|
||||
|
||||
Uses get_storage_provider() to determine the storage backend.
|
||||
See openhands.utils.environment for supported environment variables.
|
||||
|
||||
Note: Shared events only support AWS and GCP storage. Filesystem storage
|
||||
falls back to GCP for shared events.
|
||||
"""
|
||||
provider = get_storage_provider()
|
||||
|
||||
if provider == StorageProvider.AWS:
|
||||
from server.sharing.aws_shared_event_service import (
|
||||
AwsSharedEventServiceInjector,
|
||||
)
|
||||
|
||||
return AwsSharedEventServiceInjector()
|
||||
else:
|
||||
# GCP is the default for shared events (including filesystem fallback)
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
|
||||
return GoogleCloudSharedEventServiceInjector()
|
||||
|
||||
|
||||
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
|
||||
shared_event_service_dependency = Depends(
|
||||
GoogleCloudSharedEventServiceInjector().depends
|
||||
)
|
||||
shared_event_service_dependency = Depends(get_shared_event_service_injector().depends)
|
||||
|
||||
|
||||
# Read methods
|
||||
|
||||
@@ -119,6 +119,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
@@ -141,6 +142,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
sandbox_id__eq=sandbox_id__eq,
|
||||
)
|
||||
|
||||
# Add sort order
|
||||
@@ -198,6 +200,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
) -> int:
|
||||
"""Count conversations matching the given filters with SAAS metadata."""
|
||||
query = (
|
||||
@@ -220,6 +223,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
sandbox_id__eq=sandbox_id__eq,
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
@@ -234,6 +238,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
):
|
||||
"""Apply filters to query that includes SAAS metadata."""
|
||||
# Apply the same filters as the base class
|
||||
@@ -259,6 +264,9 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
StoredConversationMetadata.last_updated_at < updated_at__lt
|
||||
)
|
||||
|
||||
if sandbox_id__eq is not None:
|
||||
conditions.append(StoredConversationMetadata.sandbox_id == sandbox_id__eq)
|
||||
|
||||
if conditions:
|
||||
query = query.where(*conditions)
|
||||
return query
|
||||
@@ -334,7 +342,10 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
await super().save_app_conversation_info(info)
|
||||
|
||||
# Get current user_id for SAAS metadata
|
||||
# Fall back to info.created_by_user_id for webhook callbacks (which use ADMIN context)
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str and info.created_by_user_id:
|
||||
user_id_str = info.created_by_user_id
|
||||
if user_id_str:
|
||||
# Convert string user_id to UUID
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
|
||||
172
enterprise/server/utils/saas_pending_message_injector.py
Normal file
172
enterprise/server/utils/saas_pending_message_injector.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Enterprise injector for PendingMessageService with SAAS filtering."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user import User
|
||||
|
||||
from openhands.agent_server.models import ImageContent, TextContent
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.pending_messages.pending_message_models import (
|
||||
PendingMessageResponse,
|
||||
)
|
||||
from openhands.app_server.pending_messages.pending_message_service import (
|
||||
PendingMessageService,
|
||||
PendingMessageServiceInjector,
|
||||
SQLPendingMessageService,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
|
||||
|
||||
class SaasSQLPendingMessageService(SQLPendingMessageService):
|
||||
"""Extended SQLPendingMessageService with user and organization-based filtering.
|
||||
|
||||
This enterprise version ensures that:
|
||||
- Users can only queue messages for conversations they own
|
||||
- Organization isolation is enforced for multi-tenant deployments
|
||||
"""
|
||||
|
||||
def __init__(self, db_session, user_context: UserContext):
|
||||
super().__init__(db_session=db_session)
|
||||
self.user_context = user_context
|
||||
|
||||
async def _get_current_user(self) -> User | None:
|
||||
"""Get the current user using the existing db_session.
|
||||
|
||||
Returns:
|
||||
User object or None if no user_id is available
|
||||
"""
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str:
|
||||
return None
|
||||
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
result = await self.db_session.execute(
|
||||
select(User).where(User.id == user_id_uuid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def _validate_conversation_ownership(self, conversation_id: str) -> None:
|
||||
"""Validate that the current user owns the conversation.
|
||||
|
||||
This ensures multi-tenant isolation by checking:
|
||||
- The conversation belongs to the current user
|
||||
- The conversation belongs to the user's current organization
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to validate (can be task-id or UUID)
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation or authentication fails
|
||||
"""
|
||||
# For internal operations (e.g., processing pending messages during startup)
|
||||
# we need a mode that bypasses filtering. The ADMIN context enables this.
|
||||
if self.user_context == ADMIN:
|
||||
return
|
||||
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str:
|
||||
raise AuthError('User authentication required')
|
||||
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
|
||||
# Check conversation ownership via SAAS metadata
|
||||
query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
# If no SAAS metadata exists, the conversation might be a new task-id
|
||||
# that hasn't been linked to a conversation yet. Allow access in this case
|
||||
# as the message will be validated when the conversation is created.
|
||||
if saas_metadata is None:
|
||||
return
|
||||
|
||||
# Verify user ownership
|
||||
if saas_metadata.user_id != user_id_uuid:
|
||||
raise AuthError('You do not have access to this conversation')
|
||||
|
||||
# Verify organization ownership if applicable
|
||||
user = await self._get_current_user()
|
||||
if user and user.current_org_id is not None:
|
||||
if saas_metadata.org_id != user.current_org_id:
|
||||
raise AuthError('Conversation belongs to a different organization')
|
||||
|
||||
async def add_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
content: list[TextContent | ImageContent],
|
||||
role: str = 'user',
|
||||
) -> PendingMessageResponse:
|
||||
"""Queue a message with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to queue the message for
|
||||
content: Message content
|
||||
role: Message role (default: 'user')
|
||||
|
||||
Returns:
|
||||
PendingMessageResponse with the queued message info
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().add_message(conversation_id, content, role)
|
||||
|
||||
async def get_pending_messages(self, conversation_id: str):
|
||||
"""Get pending messages with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to get messages for
|
||||
|
||||
Returns:
|
||||
List of pending messages
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().get_pending_messages(conversation_id)
|
||||
|
||||
async def count_pending_messages(self, conversation_id: str) -> int:
|
||||
"""Count pending messages with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to count messages for
|
||||
|
||||
Returns:
|
||||
Number of pending messages
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().count_pending_messages(conversation_id)
|
||||
|
||||
|
||||
class SaasPendingMessageServiceInjector(PendingMessageServiceInjector):
|
||||
"""Enterprise injector for PendingMessageService with SAAS filtering."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[PendingMessageService, None]:
|
||||
from openhands.app_server.config import (
|
||||
get_db_session,
|
||||
get_user_context,
|
||||
)
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
service = SaasSQLPendingMessageService(
|
||||
db_session=db_session, user_context=user_context
|
||||
)
|
||||
yield service
|
||||
38
enterprise/server/utils/url_utils.py
Normal file
38
enterprise/server/utils/url_utils.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import Request
|
||||
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV, IS_STAGING_ENV
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from openhands.app_server.config import get_global_config
|
||||
|
||||
|
||||
def get_web_url(request: Request):
|
||||
web_url = get_global_config().web_url
|
||||
if not web_url:
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
web_url = f'{scheme}://{request.url.netloc}'
|
||||
else:
|
||||
web_url = web_url.rstrip('/')
|
||||
return web_url
|
||||
|
||||
|
||||
def get_cookie_domain() -> str | None:
|
||||
config = get_global_config()
|
||||
web_url = config.web_url
|
||||
# for now just use the full hostname except for staging stacks.
|
||||
return (
|
||||
URL(web_url).hostname
|
||||
if web_url and not (IS_FEATURE_ENV or IS_STAGING_ENV or IS_LOCAL_ENV)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def get_cookie_samesite() -> Literal['lax', 'strict']:
|
||||
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
|
||||
web_url = get_global_config().web_url
|
||||
return (
|
||||
'strict'
|
||||
if web_url and not (IS_FEATURE_ENV or IS_STAGING_ENV or IS_LOCAL_ENV)
|
||||
else 'lax'
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import secrets
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from storage.api_key import ApiKey
|
||||
@@ -13,9 +14,22 @@ from storage.user_store import UserStore
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiKeyValidationResult:
|
||||
"""Result of API key validation containing user and org context."""
|
||||
|
||||
user_id: str
|
||||
org_id: UUID | None
|
||||
key_id: int
|
||||
key_name: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiKeyStore:
|
||||
API_KEY_PREFIX = 'sk-oh-'
|
||||
# Prefix for system keys created by internal services (e.g., automations)
|
||||
# Keys with this prefix are hidden from users and cannot be deleted by users
|
||||
SYSTEM_KEY_NAME_PREFIX = '__SYSTEM__:'
|
||||
|
||||
def generate_api_key(self, length: int = 32) -> str:
|
||||
"""Generate a random API key with the sk-oh- prefix."""
|
||||
@@ -23,6 +37,19 @@ class ApiKeyStore:
|
||||
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return f'{self.API_KEY_PREFIX}{random_part}'
|
||||
|
||||
@classmethod
|
||||
def is_system_key_name(cls, name: str | None) -> bool:
|
||||
"""Check if a key name indicates a system key."""
|
||||
return name is not None and name.startswith(cls.SYSTEM_KEY_NAME_PREFIX)
|
||||
|
||||
@classmethod
|
||||
def make_system_key_name(cls, name: str) -> str:
|
||||
"""Create a system key name with the appropriate prefix.
|
||||
|
||||
Format: __SYSTEM__:<name>
|
||||
"""
|
||||
return f'{cls.SYSTEM_KEY_NAME_PREFIX}{name}'
|
||||
|
||||
async def create_api_key(
|
||||
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
|
||||
) -> str:
|
||||
@@ -31,7 +58,8 @@ class ApiKeyStore:
|
||||
Args:
|
||||
user_id: The ID of the user to create the key for
|
||||
name: Optional name for the key
|
||||
expires_at: Optional expiration date for the key
|
||||
expires_at: Expiration datetime in UTC. Timezone info is stripped before
|
||||
writing to the TIMESTAMP WITHOUT TIME ZONE column.
|
||||
|
||||
Returns:
|
||||
The generated API key
|
||||
@@ -42,6 +70,10 @@ class ApiKeyStore:
|
||||
raise ValueError(f'User not found: {user_id}')
|
||||
org_id = user.current_org_id
|
||||
|
||||
# Column is TIMESTAMP WITHOUT TIME ZONE; strip tzinfo before writing.
|
||||
if expires_at is not None and expires_at.tzinfo is not None:
|
||||
expires_at = expires_at.replace(tzinfo=None)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
key=api_key,
|
||||
@@ -55,8 +87,115 @@ class ApiKeyStore:
|
||||
|
||||
return api_key
|
||||
|
||||
async def validate_api_key(self, api_key: str) -> str | None:
|
||||
"""Validate an API key and return the associated user_id if valid."""
|
||||
async def get_or_create_system_api_key(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
name: str,
|
||||
) -> str:
|
||||
"""Get or create a system API key for a user on behalf of an internal service.
|
||||
|
||||
If a key with the given name already exists for this user/org and is not expired,
|
||||
returns the existing key. Otherwise, creates a new key (and deletes any expired one).
|
||||
|
||||
System keys are:
|
||||
- Not visible to users in their API keys list (filtered by name prefix)
|
||||
- Not deletable by users (protected by name prefix check)
|
||||
- Associated with a specific org (not the user's current org)
|
||||
- Never expire (no expiration date)
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to create the key for
|
||||
org_id: The organization ID to associate the key with
|
||||
name: Required name for the key (will be prefixed with __SYSTEM__:)
|
||||
|
||||
Returns:
|
||||
The API key (existing or newly created)
|
||||
"""
|
||||
# Create system key name with prefix
|
||||
system_key_name = self.make_system_key_name(name)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
# Check if key already exists for this user/org/name
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(
|
||||
ApiKey.user_id == user_id,
|
||||
ApiKey.org_id == org_id,
|
||||
ApiKey.name == system_key_name,
|
||||
)
|
||||
)
|
||||
existing_key = result.scalars().first()
|
||||
|
||||
if existing_key:
|
||||
# Check if expired
|
||||
if existing_key.expires_at:
|
||||
now = datetime.now(UTC)
|
||||
expires_at = existing_key.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
|
||||
if expires_at < now:
|
||||
# Key is expired, delete it and create new one
|
||||
logger.info(
|
||||
'System API key expired, re-issuing',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
await session.delete(existing_key)
|
||||
await session.commit()
|
||||
else:
|
||||
# Key exists and is not expired, return it
|
||||
logger.debug(
|
||||
'Returning existing system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
return existing_key.key
|
||||
else:
|
||||
# Key exists and has no expiration, return it
|
||||
logger.debug(
|
||||
'Returning existing system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
return existing_key.key
|
||||
|
||||
# Create new key (no expiration)
|
||||
api_key = self.generate_api_key()
|
||||
|
||||
async with a_session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
key=api_key,
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
expires_at=None, # System keys never expire
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
'Created system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
|
||||
return api_key
|
||||
|
||||
async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None:
|
||||
"""Validate an API key and return the associated user_id and org_id if valid."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
@@ -66,9 +205,8 @@ class ApiKeyStore:
|
||||
if not key_record:
|
||||
return None
|
||||
|
||||
# Check if the key has expired
|
||||
# expires_at is stored as naive UTC; re-attach tzinfo for comparison.
|
||||
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)
|
||||
@@ -85,7 +223,12 @@ class ApiKeyStore:
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return key_record.user_id
|
||||
return ApiKeyValidationResult(
|
||||
user_id=key_record.user_id,
|
||||
org_id=key_record.org_id,
|
||||
key_id=key_record.id,
|
||||
key_name=key_record.name,
|
||||
)
|
||||
|
||||
async def delete_api_key(self, api_key: str) -> bool:
|
||||
"""Delete an API key by the key value."""
|
||||
@@ -101,8 +244,18 @@ class ApiKeyStore:
|
||||
|
||||
return True
|
||||
|
||||
async def delete_api_key_by_id(self, key_id: int) -> bool:
|
||||
"""Delete an API key by its ID."""
|
||||
async def delete_api_key_by_id(
|
||||
self, key_id: int, allow_system: bool = False
|
||||
) -> bool:
|
||||
"""Delete an API key by its ID.
|
||||
|
||||
Args:
|
||||
key_id: The ID of the key to delete
|
||||
allow_system: If False (default), system keys cannot be deleted
|
||||
|
||||
Returns:
|
||||
True if the key was deleted, False if not found or is a protected system key
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
@@ -110,13 +263,26 @@ class ApiKeyStore:
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
# Protect system keys from deletion unless explicitly allowed
|
||||
if self.is_system_key_name(key_record.name) and not allow_system:
|
||||
logger.warning(
|
||||
'Attempted to delete system API key',
|
||||
extra={'key_id': key_id, 'user_id': key_record.user_id},
|
||||
)
|
||||
return False
|
||||
|
||||
await session.delete(key_record)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
async def list_api_keys(self, user_id: str) -> list[ApiKey]:
|
||||
"""List all API keys for a user."""
|
||||
"""List all user-visible API keys for a user.
|
||||
|
||||
This excludes:
|
||||
- System keys (name starts with __SYSTEM__:) - created by internal services
|
||||
- MCP_API_KEY - internal MCP key
|
||||
"""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f'User not found: {user_id}')
|
||||
@@ -125,11 +291,17 @@ class ApiKeyStore:
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(
|
||||
ApiKey.user_id == user_id, ApiKey.org_id == org_id
|
||||
ApiKey.user_id == user_id,
|
||||
ApiKey.org_id == org_id,
|
||||
)
|
||||
)
|
||||
keys = result.scalars().all()
|
||||
return [key for key in keys if key.name != 'MCP_API_KEY']
|
||||
# Filter out system keys and MCP_API_KEY
|
||||
return [
|
||||
key
|
||||
for key in keys
|
||||
if key.name != 'MCP_API_KEY' and not self.is_system_key_name(key.name)
|
||||
]
|
||||
|
||||
async def retrieve_mcp_api_key(self, user_id: str) -> str | None:
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
@@ -159,17 +331,44 @@ class ApiKeyStore:
|
||||
key_record = result.scalars().first()
|
||||
return key_record.key if key_record else None
|
||||
|
||||
async def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
|
||||
"""Delete an API key by name for a specific user."""
|
||||
async def delete_api_key_by_name(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
org_id: UUID | None = None,
|
||||
allow_system: bool = False,
|
||||
) -> bool:
|
||||
"""Delete an API key by name for a specific user.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user whose key to delete
|
||||
name: The name of the key to delete
|
||||
org_id: Optional organization ID to filter by (required for system keys)
|
||||
allow_system: If False (default), system keys cannot be deleted
|
||||
|
||||
Returns:
|
||||
True if the key was deleted, False if not found or is a protected system key
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
)
|
||||
# Build the query filters
|
||||
filters = [ApiKey.user_id == user_id, ApiKey.name == name]
|
||||
if org_id is not None:
|
||||
filters.append(ApiKey.org_id == org_id)
|
||||
|
||||
result = await session.execute(select(ApiKey).filter(*filters))
|
||||
key_record = result.scalars().first()
|
||||
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
# Protect system keys from deletion unless explicitly allowed
|
||||
if self.is_system_key_name(key_record.name) and not allow_system:
|
||||
logger.warning(
|
||||
'Attempted to delete system API key',
|
||||
extra={'user_id': user_id, 'key_name': name},
|
||||
)
|
||||
return False
|
||||
|
||||
await session.delete(key_record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -29,6 +29,38 @@ KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
|
||||
UNLIMITED_BUDGET_SETTING = 1000000000.0
|
||||
|
||||
# Check if billing is enabled (defaults to false for enterprise deployments)
|
||||
ENABLE_BILLING = os.environ.get('ENABLE_BILLING', 'false').lower() == 'true'
|
||||
|
||||
|
||||
def _get_default_initial_budget() -> float | None:
|
||||
"""Get the default initial budget for new teams.
|
||||
|
||||
When billing is disabled (ENABLE_BILLING=false), returns None to disable
|
||||
budget enforcement in LiteLLM. When billing is enabled, returns the
|
||||
DEFAULT_INITIAL_BUDGET environment variable value (default 0.0).
|
||||
|
||||
Returns:
|
||||
float | None: The default budget, or None to disable budget enforcement.
|
||||
"""
|
||||
if not ENABLE_BILLING:
|
||||
return None
|
||||
|
||||
try:
|
||||
budget = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
|
||||
if budget < 0:
|
||||
raise ValueError(
|
||||
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {budget}'
|
||||
)
|
||||
return budget
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}'
|
||||
) from e
|
||||
|
||||
|
||||
DEFAULT_INITIAL_BUDGET: float | None = _get_default_initial_budget()
|
||||
|
||||
|
||||
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
|
||||
"""Generate the key alias for OpenHands Cloud managed keys."""
|
||||
@@ -101,12 +133,15 @@ class LiteLlmManager:
|
||||
) as client:
|
||||
# Check if team already exists and get its budget
|
||||
# New users joining existing orgs should inherit the team's budget
|
||||
team_budget = 0.0
|
||||
# When billing is disabled, DEFAULT_INITIAL_BUDGET is None
|
||||
team_budget: float | None = DEFAULT_INITIAL_BUDGET
|
||||
try:
|
||||
existing_team = await LiteLlmManager._get_team(client, org_id)
|
||||
if existing_team:
|
||||
team_info = existing_team.get('team_info', {})
|
||||
team_budget = team_info.get('max_budget', 0.0) or 0.0
|
||||
# Preserve None from existing team (no budget enforcement)
|
||||
existing_budget = team_info.get('max_budget')
|
||||
team_budget = existing_budget
|
||||
logger.info(
|
||||
'LiteLlmManager:create_entries:existing_team_budget',
|
||||
extra={
|
||||
@@ -129,9 +164,33 @@ class LiteLlmManager:
|
||||
)
|
||||
|
||||
if create_user:
|
||||
await LiteLlmManager._create_user(
|
||||
user_created = await LiteLlmManager._create_user(
|
||||
client, keycloak_user_info.get('email'), keycloak_user_id
|
||||
)
|
||||
if not user_created:
|
||||
logger.error(
|
||||
'create_entries_failed_user_creation',
|
||||
extra={
|
||||
'org_id': org_id,
|
||||
'user_id': keycloak_user_id,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
# Verify user exists before proceeding with key generation
|
||||
user_exists = await LiteLlmManager._user_exists(
|
||||
client, keycloak_user_id
|
||||
)
|
||||
if not user_exists:
|
||||
logger.error(
|
||||
'create_entries_user_not_found_before_key_generation',
|
||||
extra={
|
||||
'org_id': org_id,
|
||||
'user_id': keycloak_user_id,
|
||||
'create_user_flag': create_user,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
await LiteLlmManager._add_user_to_team(
|
||||
client, keycloak_user_id, org_id, team_budget
|
||||
@@ -516,8 +575,17 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
team_alias: str,
|
||||
team_id: str,
|
||||
max_budget: float,
|
||||
max_budget: float | None,
|
||||
):
|
||||
"""Create a new team in LiteLLM.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
team_alias: The alias for the team.
|
||||
team_id: The ID for the team.
|
||||
max_budget: The maximum budget for the team. When None, budget
|
||||
enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
@@ -527,7 +595,7 @@ class LiteLlmManager:
|
||||
'team_id': team_id,
|
||||
'team_alias': team_alias,
|
||||
'models': [],
|
||||
'max_budget': max_budget,
|
||||
'max_budget': max_budget, # None disables budget enforcement
|
||||
'spend': 0,
|
||||
'metadata': {
|
||||
'version': ORG_SETTINGS_VERSION,
|
||||
@@ -611,15 +679,48 @@ class LiteLlmManager:
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@staticmethod
|
||||
async def _user_exists(
|
||||
client: httpx.AsyncClient,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
"""Check if a user exists in LiteLLM.
|
||||
|
||||
Returns True if the user exists, False otherwise.
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
return False
|
||||
try:
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/user/info?user_id={user_id}',
|
||||
)
|
||||
if response.is_success:
|
||||
user_data = response.json()
|
||||
# Check that user_info exists and has the user_id
|
||||
user_info = user_data.get('user_info', {})
|
||||
return user_info.get('user_id') == user_id
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'litellm_user_exists_check_failed',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def _create_user(
|
||||
client: httpx.AsyncClient,
|
||||
email: str | None,
|
||||
keycloak_user_id: str,
|
||||
):
|
||||
) -> bool:
|
||||
"""Create a user in LiteLLM.
|
||||
|
||||
Returns True if the user was created or already exists and is verified,
|
||||
False if creation failed and user does not exist.
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
return False
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/user/new',
|
||||
json={
|
||||
@@ -672,17 +773,33 @@ class LiteLlmManager:
|
||||
'user_id': keycloak_user_id,
|
||||
},
|
||||
)
|
||||
return
|
||||
# Verify the user actually exists before returning success
|
||||
user_exists = await LiteLlmManager._user_exists(
|
||||
client, keycloak_user_id
|
||||
)
|
||||
if not user_exists:
|
||||
logger.error(
|
||||
'litellm_user_claimed_exists_but_not_found',
|
||||
extra={
|
||||
'user_id': keycloak_user_id,
|
||||
'status_code': response.status_code,
|
||||
'text': response.text,
|
||||
},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
logger.error(
|
||||
'error_creating_litellm_user',
|
||||
extra={
|
||||
'status_code': response.status_code,
|
||||
'text': response.text,
|
||||
'user_id': [keycloak_user_id],
|
||||
'user_id': keycloak_user_id,
|
||||
'email': None,
|
||||
},
|
||||
)
|
||||
return False
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def _get_user(client: httpx.AsyncClient, user_id: str) -> dict | None:
|
||||
@@ -909,8 +1026,17 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
keycloak_user_id: str,
|
||||
team_id: str,
|
||||
max_budget: float,
|
||||
max_budget: float | None,
|
||||
):
|
||||
"""Add a user to a team in LiteLLM.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
keycloak_user_id: The user's Keycloak ID.
|
||||
team_id: The team ID.
|
||||
max_budget: The maximum budget for the user in the team. When None,
|
||||
budget enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
@@ -919,7 +1045,7 @@ class LiteLlmManager:
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'member': {'user_id': keycloak_user_id, 'role': 'user'},
|
||||
'max_budget_in_team': max_budget,
|
||||
'max_budget_in_team': max_budget, # None disables budget enforcement
|
||||
},
|
||||
)
|
||||
# Failed to add user to team - this is an unforseen error state...
|
||||
@@ -989,8 +1115,17 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
keycloak_user_id: str,
|
||||
team_id: str,
|
||||
max_budget: float,
|
||||
max_budget: float | None,
|
||||
):
|
||||
"""Update a user's budget in a team.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
keycloak_user_id: The user's Keycloak ID.
|
||||
team_id: The team ID.
|
||||
max_budget: The maximum budget for the user in the team. When None,
|
||||
budget enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
@@ -999,7 +1134,7 @@ class LiteLlmManager:
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'user_id': keycloak_user_id,
|
||||
'max_budget_in_team': max_budget,
|
||||
'max_budget_in_team': max_budget, # None disables budget enforcement
|
||||
},
|
||||
)
|
||||
# Failed to update user in team - this is an unforseen error state...
|
||||
@@ -1388,6 +1523,7 @@ class LiteLlmManager:
|
||||
create_team = staticmethod(with_http_client(_create_team))
|
||||
get_team = staticmethod(with_http_client(_get_team))
|
||||
update_team = staticmethod(with_http_client(_update_team))
|
||||
user_exists = staticmethod(with_http_client(_user_exists))
|
||||
create_user = staticmethod(with_http_client(_create_user))
|
||||
get_user = staticmethod(with_http_client(_get_user))
|
||||
update_user = staticmethod(with_http_client(_update_user))
|
||||
|
||||
@@ -47,6 +47,7 @@ class Org(Base): # type: ignore
|
||||
conversation_expiration = Column(Integer, nullable=True)
|
||||
condenser_max_size = Column(Integer, nullable=True)
|
||||
byor_export_enabled = Column(Boolean, nullable=False, default=False)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
|
||||
@@ -28,6 +28,9 @@ class OrgMemberStore:
|
||||
role_id: int,
|
||||
llm_api_key: str,
|
||||
status: Optional[str] = None,
|
||||
llm_model: Optional[str] = None,
|
||||
llm_base_url: Optional[str] = None,
|
||||
max_iterations: Optional[int] = None,
|
||||
) -> OrgMember:
|
||||
"""Add a user to an organization with a specific role."""
|
||||
async with a_session_maker() as session:
|
||||
@@ -37,6 +40,9 @@ class OrgMemberStore:
|
||||
role_id=role_id,
|
||||
llm_api_key=llm_api_key,
|
||||
status=status,
|
||||
llm_model=llm_model,
|
||||
llm_base_url=llm_base_url,
|
||||
max_iterations=max_iterations,
|
||||
)
|
||||
session.add(org_member)
|
||||
await session.commit()
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from server.constants import (
|
||||
DEFAULT_V1_ENABLED,
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
@@ -36,6 +37,8 @@ class OrgStore:
|
||||
org = Org(**kwargs)
|
||||
org.org_version = ORG_SETTINGS_VERSION
|
||||
org.default_llm_model = get_default_litellm_model()
|
||||
if org.v1_enabled is None:
|
||||
org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
await session.refresh(org)
|
||||
|
||||
@@ -28,12 +28,14 @@ class SaasConversationValidator(ConversationValidator):
|
||||
|
||||
# Validate the API key and get the user_id
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
user_id = await api_key_store.validate_api_key(api_key)
|
||||
validation_result = await api_key_store.validate_api_key(api_key)
|
||||
|
||||
if not user_id:
|
||||
if not validation_result:
|
||||
logger.warning('Invalid API key')
|
||||
return None
|
||||
|
||||
user_id = validation_result.user_id
|
||||
|
||||
# Get the offline token for the user
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
if not offline_token:
|
||||
|
||||
@@ -11,9 +11,10 @@ from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import LITE_LLM_API_URL
|
||||
from server.logger import logger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker
|
||||
from storage.encrypt_utils import encrypt_value
|
||||
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
@@ -116,6 +117,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
# Apply default if sandbox_grouping_strategy is None in the database
|
||||
if kwargs.get('sandbox_grouping_strategy') is None:
|
||||
kwargs.pop('sandbox_grouping_strategy', None)
|
||||
|
||||
settings = Settings(**kwargs)
|
||||
return settings
|
||||
@@ -186,6 +190,42 @@ class SaasSettingsStore(SettingsStore):
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
# Map Settings fields to Org fields with 'default_' prefix
|
||||
# The generic loop above doesn't update these because Org uses
|
||||
# 'default_llm_model' not 'llm_model', etc.
|
||||
# Use exclude_unset to only update explicitly-set fields (allows clearing with null)
|
||||
settings_data = item.model_dump(exclude_unset=True)
|
||||
if 'llm_model' in settings_data:
|
||||
org.default_llm_model = settings_data['llm_model']
|
||||
if 'llm_base_url' in settings_data:
|
||||
org.default_llm_base_url = settings_data['llm_base_url']
|
||||
if 'max_iterations' in settings_data:
|
||||
org.default_max_iterations = settings_data['max_iterations']
|
||||
|
||||
# Propagate LLM settings to all org members
|
||||
# This ensures all members see the same LLM configuration when an admin saves
|
||||
# Note: Concurrent saves by multiple admins will result in last-write-wins.
|
||||
# Consider adding optimistic locking if this becomes a problem.
|
||||
member_update_values: dict = {}
|
||||
if item.llm_model is not None:
|
||||
member_update_values['llm_model'] = item.llm_model
|
||||
if item.llm_base_url is not None:
|
||||
member_update_values['llm_base_url'] = item.llm_base_url
|
||||
if item.max_iterations is not None:
|
||||
member_update_values['max_iterations'] = item.max_iterations
|
||||
if item.llm_api_key is not None:
|
||||
member_update_values['_llm_api_key'] = encrypt_value(
|
||||
item.llm_api_key.get_secret_value()
|
||||
)
|
||||
|
||||
if member_update_values:
|
||||
stmt = (
|
||||
update(OrgMember)
|
||||
.where(OrgMember.org_id == org_id)
|
||||
.values(**member_update_values)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
await session.commit()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -25,10 +25,10 @@ class SlackConversationStore:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_slack_conversation(
|
||||
self, slack_converstion: SlackConversation
|
||||
self, slack_conversation: SlackConversation
|
||||
) -> None:
|
||||
async with a_session_maker() as session:
|
||||
session.merge(slack_converstion)
|
||||
await session.merge(slack_conversation)
|
||||
await session.commit()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -33,6 +33,7 @@ class User(Base): # type: ignore
|
||||
email_verified = Column(Boolean, nullable=True)
|
||||
git_user_name = Column(String, nullable=True)
|
||||
git_user_email = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
role = relationship('Role', back_populates='users')
|
||||
|
||||
@@ -27,6 +27,7 @@ class UserSettings(Base): # type: ignore
|
||||
)
|
||||
sandbox_base_container_image = Column(String, nullable=True)
|
||||
sandbox_runtime_container_image = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
user_version = Column(Integer, nullable=False, default=0)
|
||||
accepted_tos = Column(DateTime, nullable=True)
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
|
||||
@@ -7,6 +7,7 @@ from uuid import UUID
|
||||
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import (
|
||||
DEFAULT_V1_ENABLED,
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
PERSONAL_WORKSPACE_VERSION_TO_MODEL,
|
||||
@@ -241,6 +242,10 @@ class UserStore:
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
# Apply DEFAULT_V1_ENABLED for migrated orgs if v1_enabled was not set
|
||||
if org.v1_enabled is None:
|
||||
org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
|
||||
user_kwargs = UserStore.get_kwargs_from_user_settings(
|
||||
decrypted_user_settings
|
||||
)
|
||||
@@ -892,6 +897,8 @@ class UserStore:
|
||||
language='en', enable_proactive_conversation_starters=True
|
||||
)
|
||||
|
||||
default_settings.v1_enabled = DEFAULT_V1_ENABLED
|
||||
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
|
||||
settings = await LiteLlmManager.create_entries(
|
||||
|
||||
@@ -1,562 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common Room Sync
|
||||
|
||||
This script queries the database to count conversations created by each user,
|
||||
then creates or updates a signal in Common Room for each user with their
|
||||
conversation count.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import requests
|
||||
from sqlalchemy import text
|
||||
|
||||
# Add the parent directory to the path so we can import from storage
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from server.auth.token_manager import get_keycloak_admin
|
||||
from storage.database import get_engine
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger('common_room_sync')
|
||||
|
||||
# Common Room API configuration
|
||||
COMMON_ROOM_API_KEY = os.environ.get('COMMON_ROOM_API_KEY')
|
||||
COMMON_ROOM_DESTINATION_SOURCE_ID = os.environ.get('COMMON_ROOM_DESTINATION_SOURCE_ID')
|
||||
COMMON_ROOM_API_BASE_URL = 'https://api.commonroom.io/community/v1'
|
||||
|
||||
# Sync configuration
|
||||
BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '100'))
|
||||
KEYCLOAK_BATCH_SIZE = int(os.environ.get('KEYCLOAK_BATCH_SIZE', '20'))
|
||||
MAX_RETRIES = int(os.environ.get('MAX_RETRIES', '3'))
|
||||
INITIAL_BACKOFF_SECONDS = float(os.environ.get('INITIAL_BACKOFF_SECONDS', '1'))
|
||||
MAX_BACKOFF_SECONDS = float(os.environ.get('MAX_BACKOFF_SECONDS', '60'))
|
||||
BACKOFF_FACTOR = float(os.environ.get('BACKOFF_FACTOR', '2'))
|
||||
RATE_LIMIT = float(os.environ.get('RATE_LIMIT', '2')) # Requests per second
|
||||
|
||||
|
||||
class CommonRoomSyncError(Exception):
|
||||
"""Base exception for Common Room sync errors."""
|
||||
|
||||
|
||||
class DatabaseError(CommonRoomSyncError):
|
||||
"""Exception for database errors."""
|
||||
|
||||
|
||||
class CommonRoomAPIError(CommonRoomSyncError):
|
||||
"""Exception for Common Room API errors."""
|
||||
|
||||
|
||||
class KeycloakClientError(CommonRoomSyncError):
|
||||
"""Exception for Keycloak client errors."""
|
||||
|
||||
|
||||
def get_recent_conversations(minutes: int = 60) -> List[Dict[str, Any]]:
|
||||
"""Get conversations created in the past N minutes.
|
||||
|
||||
Args:
|
||||
minutes: Number of minutes to look back for new conversations.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries, each containing conversation details.
|
||||
|
||||
Raises:
|
||||
DatabaseError: If the database query fails.
|
||||
"""
|
||||
try:
|
||||
# Use a different syntax for the interval that works with pg8000
|
||||
query = text("""
|
||||
SELECT
|
||||
conversation_id, user_id, title, created_at
|
||||
FROM
|
||||
conversation_metadata
|
||||
WHERE
|
||||
created_at >= NOW() - (INTERVAL '1 minute' * :minutes)
|
||||
ORDER BY
|
||||
created_at DESC
|
||||
""")
|
||||
|
||||
with get_engine().connect() as connection:
|
||||
result = connection.execute(query, {'minutes': minutes})
|
||||
conversations = [
|
||||
{
|
||||
'conversation_id': row[0],
|
||||
'user_id': row[1],
|
||||
'title': row[2],
|
||||
'created_at': row[3].isoformat() if row[3] else None,
|
||||
}
|
||||
for row in result
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f'Retrieved {len(conversations)} conversations created in the past {minutes} minutes'
|
||||
)
|
||||
return conversations
|
||||
except Exception as e:
|
||||
logger.exception(f'Error querying recent conversations: {e}')
|
||||
raise DatabaseError(f'Failed to query recent conversations: {e}')
|
||||
|
||||
|
||||
async def get_users_from_keycloak(user_ids: Set[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get user information from Keycloak for a set of user IDs.
|
||||
|
||||
Args:
|
||||
user_ids: A set of user IDs to look up.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping user IDs to user information dictionaries.
|
||||
|
||||
Raises:
|
||||
KeycloakClientError: If the Keycloak API call fails.
|
||||
"""
|
||||
try:
|
||||
# Get Keycloak admin client
|
||||
keycloak_admin = get_keycloak_admin()
|
||||
|
||||
# Create a dictionary to store user information
|
||||
user_info_dict = {}
|
||||
|
||||
# Convert set to list for easier batching
|
||||
user_id_list = list(user_ids)
|
||||
|
||||
# Process user IDs in batches
|
||||
for i in range(0, len(user_id_list), KEYCLOAK_BATCH_SIZE):
|
||||
batch = user_id_list[i : i + KEYCLOAK_BATCH_SIZE]
|
||||
batch_tasks = []
|
||||
|
||||
# Create tasks for each user ID in the batch
|
||||
for user_id in batch:
|
||||
# Use the Keycloak admin client to get user by ID
|
||||
batch_tasks.append(get_user_by_id(keycloak_admin, user_id))
|
||||
|
||||
# Run the batch of tasks concurrently
|
||||
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
|
||||
|
||||
# Process the results
|
||||
for user_id, result in zip(batch, batch_results):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(f'Error getting user {user_id}: {result}')
|
||||
continue
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
user_info_dict[user_id] = {
|
||||
'username': result.get('username'),
|
||||
'email': result.get('email'),
|
||||
'id': result.get('id'),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f'Retrieved information for {len(user_info_dict)} users from Keycloak'
|
||||
)
|
||||
return user_info_dict
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Error getting users from Keycloak: {e}'
|
||||
logger.exception(error_msg)
|
||||
raise KeycloakClientError(error_msg)
|
||||
|
||||
|
||||
async def get_user_by_id(keycloak_admin, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a user from Keycloak by ID.
|
||||
|
||||
Args:
|
||||
keycloak_admin: The Keycloak admin client.
|
||||
user_id: The user ID to look up.
|
||||
|
||||
Returns:
|
||||
A dictionary with the user's information, or None if not found.
|
||||
"""
|
||||
try:
|
||||
# Use the Keycloak admin client to get user by ID
|
||||
user = keycloak_admin.get_user(user_id)
|
||||
if user:
|
||||
logger.debug(
|
||||
f"Found user in Keycloak: {user.get('username')}, {user.get('email')}"
|
||||
)
|
||||
return user
|
||||
else:
|
||||
logger.warning(f'User {user_id} not found in Keycloak')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f'Error getting user {user_id} from Keycloak: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def get_user_info(
|
||||
user_id: str, user_info_cache: Dict[str, Dict[str, Any]]
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Get the email address and GitHub username for a user from the cache.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to look up.
|
||||
user_info_cache: A dictionary mapping user IDs to user information.
|
||||
|
||||
Returns:
|
||||
A dictionary with the user's email and username, or None if not found.
|
||||
"""
|
||||
# Check if the user is in the cache
|
||||
if user_id in user_info_cache:
|
||||
user_info = user_info_cache[user_id]
|
||||
logger.debug(
|
||||
f"Found user info in cache: {user_info.get('username')}, {user_info.get('email')}"
|
||||
)
|
||||
return user_info
|
||||
else:
|
||||
logger.warning(f'User {user_id} not found in user info cache')
|
||||
return None
|
||||
|
||||
|
||||
def register_user_in_common_room(
|
||||
user_id: str, email: str, github_username: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Create or update a user in Common Room.
|
||||
|
||||
Args:
|
||||
user_id: The user ID.
|
||||
email: The user's email address.
|
||||
github_username: The user's GitHub username.
|
||||
|
||||
Returns:
|
||||
The API response from Common Room.
|
||||
|
||||
Raises:
|
||||
CommonRoomAPIError: If the Common Room API request fails.
|
||||
"""
|
||||
if not COMMON_ROOM_API_KEY:
|
||||
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
|
||||
|
||||
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
|
||||
raise CommonRoomAPIError(
|
||||
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
|
||||
)
|
||||
|
||||
try:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Create or update user in Common Room
|
||||
user_data = {
|
||||
'id': user_id,
|
||||
'email': email,
|
||||
'username': github_username,
|
||||
'github': {'type': 'handle', 'value': github_username},
|
||||
}
|
||||
|
||||
user_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/user'
|
||||
user_response = requests.post(user_url, headers=headers, json=user_data)
|
||||
|
||||
if user_response.status_code not in (200, 202):
|
||||
logger.error(
|
||||
f'Failed to create/update user in Common Room: {user_response.text}'
|
||||
)
|
||||
logger.error(f'Response status code: {user_response.status_code}')
|
||||
raise CommonRoomAPIError(
|
||||
f'Failed to create/update user: {user_response.text}'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Registered/updated user {user_id} (GitHub: {github_username}) in Common Room'
|
||||
)
|
||||
return user_response.json()
|
||||
except requests.RequestException as e:
|
||||
logger.exception(f'Error communicating with Common Room API: {e}')
|
||||
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
|
||||
|
||||
|
||||
def register_conversation_activity(
|
||||
user_id: str,
|
||||
conversation_id: str,
|
||||
conversation_title: str,
|
||||
created_at: datetime,
|
||||
email: str,
|
||||
github_username: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create an activity in Common Room for a new conversation.
|
||||
|
||||
Args:
|
||||
user_id: The user ID who created the conversation.
|
||||
conversation_id: The ID of the conversation.
|
||||
conversation_title: The title of the conversation.
|
||||
created_at: The datetime object when the conversation was created.
|
||||
email: The user's email address.
|
||||
github_username: The user's GitHub username.
|
||||
|
||||
Returns:
|
||||
The API response from Common Room.
|
||||
|
||||
Raises:
|
||||
CommonRoomAPIError: If the Common Room API request fails.
|
||||
"""
|
||||
if not COMMON_ROOM_API_KEY:
|
||||
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
|
||||
|
||||
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
|
||||
raise CommonRoomAPIError(
|
||||
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
|
||||
)
|
||||
|
||||
try:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Format the datetime object to the expected ISO format
|
||||
formatted_timestamp = (
|
||||
created_at.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
if created_at
|
||||
else time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
)
|
||||
|
||||
# Create activity for the conversation
|
||||
activity_data = {
|
||||
'id': f'conversation_{conversation_id}', # Use conversation ID to ensure uniqueness
|
||||
'activityType': 'started_session',
|
||||
'user': {
|
||||
'id': user_id,
|
||||
'email': email,
|
||||
'github': {'type': 'handle', 'value': github_username},
|
||||
'username': github_username,
|
||||
},
|
||||
'activityTitle': {
|
||||
'type': 'text',
|
||||
'value': conversation_title or 'New Conversation',
|
||||
},
|
||||
'content': {
|
||||
'type': 'text',
|
||||
'value': f'Started a new conversation: {conversation_title or "Untitled"}',
|
||||
},
|
||||
'timestamp': formatted_timestamp,
|
||||
'url': f'https://app.all-hands.dev/conversations/{conversation_id}',
|
||||
}
|
||||
|
||||
# Log the activity data for debugging
|
||||
logger.info(f'Activity data payload: {activity_data}')
|
||||
|
||||
activity_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/activity'
|
||||
activity_response = requests.post(
|
||||
activity_url, headers=headers, json=activity_data
|
||||
)
|
||||
|
||||
if activity_response.status_code not in (200, 202):
|
||||
logger.error(
|
||||
f'Failed to create activity in Common Room: {activity_response.text}'
|
||||
)
|
||||
logger.error(f'Response status code: {activity_response.status_code}')
|
||||
raise CommonRoomAPIError(
|
||||
f'Failed to create activity: {activity_response.text}'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Registered conversation activity for user {user_id}, conversation {conversation_id}'
|
||||
)
|
||||
return activity_response.json()
|
||||
except requests.RequestException as e:
|
||||
logger.exception(f'Error communicating with Common Room API: {e}')
|
||||
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
|
||||
|
||||
|
||||
def retry_with_backoff(func, *args, **kwargs):
|
||||
"""Retry a function with exponential backoff.
|
||||
|
||||
Args:
|
||||
func: The function to retry.
|
||||
*args: Positional arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
|
||||
Raises:
|
||||
The last exception raised by the function.
|
||||
"""
|
||||
backoff = INITIAL_BACKOFF_SECONDS
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
|
||||
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
|
||||
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
|
||||
time.sleep(sleep_time)
|
||||
backoff *= BACKOFF_FACTOR
|
||||
else:
|
||||
logger.exception(f'All {MAX_RETRIES} attempts failed')
|
||||
raise last_exception
|
||||
|
||||
|
||||
async def retry_with_backoff_async(func, *args, **kwargs):
|
||||
"""Retry an async function with exponential backoff.
|
||||
|
||||
Args:
|
||||
func: The async function to retry.
|
||||
*args: Positional arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
|
||||
Raises:
|
||||
The last exception raised by the function.
|
||||
"""
|
||||
backoff = INITIAL_BACKOFF_SECONDS
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
|
||||
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
|
||||
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
|
||||
await asyncio.sleep(sleep_time)
|
||||
backoff *= BACKOFF_FACTOR
|
||||
else:
|
||||
logger.exception(f'All {MAX_RETRIES} attempts failed')
|
||||
raise last_exception
|
||||
|
||||
|
||||
async def async_sync_recent_conversations_to_common_room(minutes: int = 60):
|
||||
"""Async main function to sync recent conversations to Common Room.
|
||||
|
||||
Args:
|
||||
minutes: Number of minutes to look back for new conversations.
|
||||
"""
|
||||
logger.info(
|
||||
f'Starting Common Room recent conversations sync (past {minutes} minutes)'
|
||||
)
|
||||
|
||||
stats = {
|
||||
'total_conversations': 0,
|
||||
'registered_users': 0,
|
||||
'registered_activities': 0,
|
||||
'errors': 0,
|
||||
'missing_user_info': 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Get conversations created in the past N minutes
|
||||
recent_conversations = retry_with_backoff(get_recent_conversations, minutes)
|
||||
stats['total_conversations'] = len(recent_conversations)
|
||||
|
||||
logger.info(f'Processing {len(recent_conversations)} recent conversations')
|
||||
|
||||
if not recent_conversations:
|
||||
logger.info('No recent conversations found, exiting')
|
||||
return
|
||||
|
||||
# Extract all unique user IDs
|
||||
user_ids = {conv['user_id'] for conv in recent_conversations if conv['user_id']}
|
||||
|
||||
# Get user information for all users in batches
|
||||
user_info_cache = await retry_with_backoff_async(
|
||||
get_users_from_keycloak, user_ids
|
||||
)
|
||||
|
||||
# Track registered users to avoid duplicate registrations
|
||||
registered_users = set()
|
||||
|
||||
# Process each conversation
|
||||
for conversation in recent_conversations:
|
||||
conversation_id = conversation['conversation_id']
|
||||
user_id = conversation['user_id']
|
||||
title = conversation['title']
|
||||
created_at = conversation[
|
||||
'created_at'
|
||||
] # This might be a string or datetime object
|
||||
|
||||
try:
|
||||
# Get user info from cache
|
||||
user_info = get_user_info(user_id, user_info_cache)
|
||||
if not user_info:
|
||||
logger.warning(
|
||||
f'Could not find user info for user {user_id}, skipping conversation {conversation_id}'
|
||||
)
|
||||
stats['missing_user_info'] += 1
|
||||
continue
|
||||
|
||||
email = user_info['email']
|
||||
github_username = user_info['username']
|
||||
|
||||
if not email:
|
||||
logger.warning(
|
||||
f'User {user_id} has no email, skipping conversation {conversation_id}'
|
||||
)
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
# Register user in Common Room if not already registered in this run
|
||||
if user_id not in registered_users:
|
||||
register_user_in_common_room(user_id, email, github_username)
|
||||
registered_users.add(user_id)
|
||||
stats['registered_users'] += 1
|
||||
|
||||
# If created_at is a string, parse it to a datetime object
|
||||
# If it's already a datetime object, use it as is
|
||||
# If it's None, use current time
|
||||
created_at_datetime = (
|
||||
created_at
|
||||
if isinstance(created_at, datetime)
|
||||
else datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||
if created_at
|
||||
else datetime.now(UTC)
|
||||
)
|
||||
|
||||
# Register conversation activity with email and github username
|
||||
register_conversation_activity(
|
||||
user_id,
|
||||
conversation_id,
|
||||
title,
|
||||
created_at_datetime,
|
||||
email,
|
||||
github_username,
|
||||
)
|
||||
stats['registered_activities'] += 1
|
||||
|
||||
# Sleep to respect rate limit
|
||||
await asyncio.sleep(1 / RATE_LIMIT)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f'Error processing conversation {conversation_id} for user {user_id}: {e}'
|
||||
)
|
||||
stats['errors'] += 1
|
||||
except Exception as e:
|
||||
logger.exception(f'Sync failed: {e}')
|
||||
raise
|
||||
finally:
|
||||
logger.info(f'Sync completed. Stats: {stats}')
|
||||
|
||||
|
||||
def sync_recent_conversations_to_common_room(minutes: int = 60):
|
||||
"""Main function to sync recent conversations to Common Room.
|
||||
|
||||
Args:
|
||||
minutes: Number of minutes to look back for new conversations.
|
||||
"""
|
||||
# Run the async function in the event loop
|
||||
asyncio.run(async_sync_recent_conversations_to_common_room(minutes))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Default to looking back 60 minutes for new conversations
|
||||
minutes = int(os.environ.get('SYNC_MINUTES', '60'))
|
||||
sync_recent_conversations_to_common_room(minutes)
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Common Room conversation count sync.
|
||||
|
||||
This script tests the functionality of the Common Room sync script
|
||||
without making any API calls to Common Room or database connections.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from sync.common_room_sync import (
|
||||
retry_with_backoff,
|
||||
)
|
||||
|
||||
|
||||
class TestCommonRoomSync(unittest.TestCase):
|
||||
"""Test cases for Common Room sync functionality."""
|
||||
|
||||
def test_retry_with_backoff(self):
|
||||
"""Test the retry_with_backoff function."""
|
||||
# Mock function that succeeds on the second attempt
|
||||
mock_func = MagicMock(
|
||||
side_effect=[Exception('First attempt failed'), 'success']
|
||||
)
|
||||
|
||||
# Set environment variables for testing
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'MAX_RETRIES': '3',
|
||||
'INITIAL_BACKOFF_SECONDS': '0.01',
|
||||
'BACKOFF_FACTOR': '2',
|
||||
'MAX_BACKOFF_SECONDS': '1',
|
||||
},
|
||||
):
|
||||
result = retry_with_backoff(mock_func, 'arg1', 'arg2', kwarg1='kwarg1')
|
||||
|
||||
# Check that the function was called twice
|
||||
self.assertEqual(mock_func.call_count, 2)
|
||||
# Check that the function was called with the correct arguments
|
||||
mock_func.assert_called_with('arg1', 'arg2', kwarg1='kwarg1')
|
||||
# Check that the function returned the expected result
|
||||
self.assertEqual(result, 'success')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to verify the conversation count query.
|
||||
|
||||
This script tests the database query to count conversations by user,
|
||||
without making any API calls to Common Room.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
# Add the parent directory to the path so we can import from storage
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from storage.database import get_engine
|
||||
|
||||
|
||||
def test_conversation_count_query():
|
||||
"""Test the query to count conversations by user."""
|
||||
try:
|
||||
# Query to count conversations by user
|
||||
count_query = text("""
|
||||
SELECT
|
||||
user_id, COUNT(*) as conversation_count
|
||||
FROM
|
||||
conversation_metadata
|
||||
GROUP BY
|
||||
user_id
|
||||
""")
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
with engine.connect() as connection:
|
||||
count_result = connection.execute(count_query)
|
||||
user_counts = [
|
||||
{'user_id': row[0], 'conversation_count': row[1]}
|
||||
for row in count_result
|
||||
]
|
||||
|
||||
print(f'Found {len(user_counts)} users with conversations')
|
||||
|
||||
# Print the first 5 results
|
||||
for i, user_data in enumerate(user_counts[:5]):
|
||||
print(
|
||||
f"User {i+1}: {user_data['user_id']} - {user_data['conversation_count']} conversations"
|
||||
)
|
||||
|
||||
# Test the user_entity query for the first user (if any)
|
||||
if user_counts:
|
||||
first_user_id = user_counts[0]['user_id']
|
||||
|
||||
user_query = text("""
|
||||
SELECT username, email, id
|
||||
FROM user_entity
|
||||
WHERE id = :user_id
|
||||
""")
|
||||
|
||||
with engine.connect() as connection:
|
||||
user_result = connection.execute(user_query, {'user_id': first_user_id})
|
||||
user_row = user_result.fetchone()
|
||||
|
||||
if user_row:
|
||||
print(f'\nUser details for {first_user_id}:')
|
||||
print(f' GitHub Username: {user_row[0]}')
|
||||
print(f' Email: {user_row[1]}')
|
||||
print(f' ID: {user_row[2]}')
|
||||
else:
|
||||
print(
|
||||
f'\nNo user details found for {first_user_id} in user_entity table'
|
||||
)
|
||||
|
||||
print('\nTest completed successfully')
|
||||
except Exception as e:
|
||||
print(f'Error: {str(e)}')
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_conversation_count_query()
|
||||
@@ -28,6 +28,7 @@ from storage.org import Org
|
||||
from storage.org_invitation import OrgInvitation # noqa: F401
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.slack_conversation import SlackConversation # noqa: F401
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
|
||||
0
enterprise/tests/unit/routes/__init__.py
Normal file
0
enterprise/tests/unit/routes/__init__.py
Normal file
331
enterprise/tests/unit/routes/test_service.py
Normal file
331
enterprise/tests/unit/routes/test_service.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Unit tests for service API routes."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from server.routes.service import (
|
||||
CreateUserApiKeyRequest,
|
||||
delete_user_api_key,
|
||||
get_or_create_api_key_for_user,
|
||||
validate_service_api_key,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateServiceApiKey:
|
||||
"""Test cases for validate_service_api_key."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_service_key(self):
|
||||
"""Test validation with valid service API key."""
|
||||
with patch(
|
||||
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
|
||||
):
|
||||
result = await validate_service_api_key('test-service-key')
|
||||
assert result == 'automations-service'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_service_key(self):
|
||||
"""Test validation with missing service API key header."""
|
||||
with patch(
|
||||
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'X-Service-API-Key header is required' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_service_key(self):
|
||||
"""Test validation with invalid service API key."""
|
||||
with patch(
|
||||
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key('wrong-key')
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'Invalid service API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_auth_not_configured(self):
|
||||
"""Test validation when service auth is not configured."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', ''):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key('any-key')
|
||||
assert exc_info.value.status_code == 503
|
||||
assert 'Service authentication not configured' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestCreateUserApiKeyRequest:
|
||||
"""Test cases for CreateUserApiKeyRequest validation."""
|
||||
|
||||
def test_valid_request(self):
|
||||
"""Test valid request with all fields."""
|
||||
request = CreateUserApiKeyRequest(
|
||||
name='automation',
|
||||
)
|
||||
assert request.name == 'automation'
|
||||
|
||||
def test_name_is_required(self):
|
||||
"""Test that name field is required."""
|
||||
with pytest.raises(ValueError):
|
||||
CreateUserApiKeyRequest(
|
||||
name='', # Empty name should fail
|
||||
)
|
||||
|
||||
def test_name_is_stripped(self):
|
||||
"""Test that name field is stripped of whitespace."""
|
||||
request = CreateUserApiKeyRequest(
|
||||
name=' automation ',
|
||||
)
|
||||
assert request.name == 'automation'
|
||||
|
||||
def test_whitespace_only_name_fails(self):
|
||||
"""Test that whitespace-only name fails validation."""
|
||||
with pytest.raises(ValueError):
|
||||
CreateUserApiKeyRequest(
|
||||
name=' ',
|
||||
)
|
||||
|
||||
|
||||
class TestGetOrCreateApiKeyForUser:
|
||||
"""Test cases for get_or_create_api_key_for_user endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def valid_user_id(self):
|
||||
"""Return a valid user ID."""
|
||||
return '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
|
||||
@pytest.fixture
|
||||
def valid_org_id(self):
|
||||
"""Return a valid org ID."""
|
||||
return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
@pytest.fixture
|
||||
def valid_request(self):
|
||||
"""Create a valid request object."""
|
||||
return CreateUserApiKeyRequest(
|
||||
name='automation',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_not_found(self, valid_user_id, valid_org_id, valid_request):
|
||||
"""Test error when user doesn't exist."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
mock_get_user.return_value = None
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert 'not found' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_not_in_org(self, valid_user_id, valid_org_id, valid_request):
|
||||
"""Test error when user is not a member of the org."""
|
||||
mock_user = MagicMock()
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = None
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'not a member of org' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_key_creation(
|
||||
self, valid_user_id, valid_org_id, valid_request
|
||||
):
|
||||
"""Test successful API key creation."""
|
||||
mock_user = MagicMock()
|
||||
mock_org_member = MagicMock()
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.get_or_create_system_api_key = AsyncMock(
|
||||
return_value='sk-oh-test-key-12345678901234567890'
|
||||
)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = mock_org_member
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
response = await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert response.key == 'sk-oh-test-key-12345678901234567890'
|
||||
assert response.user_id == valid_user_id
|
||||
assert response.org_id == str(valid_org_id)
|
||||
assert response.name == 'automation'
|
||||
|
||||
# Verify the store was called with correct arguments
|
||||
mock_api_key_store.get_or_create_system_api_key.assert_called_once_with(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
name='automation',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_exception_handling(
|
||||
self, valid_user_id, valid_org_id, valid_request
|
||||
):
|
||||
"""Test error handling when store raises exception."""
|
||||
mock_user = MagicMock()
|
||||
mock_org_member = MagicMock()
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.get_or_create_system_api_key = AsyncMock(
|
||||
side_effect=Exception('Database error')
|
||||
)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = mock_org_member
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to get or create API key' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestDeleteUserApiKey:
|
||||
"""Test cases for delete_user_api_key endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def valid_org_id(self):
|
||||
"""Return a valid org ID."""
|
||||
return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_delete(self, valid_org_id):
|
||||
"""Test successful deletion of a system API key."""
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:automation'
|
||||
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=True)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
response = await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert response == {'message': 'API key deleted successfully'}
|
||||
|
||||
# Verify the store was called with correct arguments
|
||||
mock_api_key_store.make_system_key_name.assert_called_once_with('automation')
|
||||
mock_api_key_store.delete_api_key_by_name.assert_called_once_with(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
allow_system=True,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_key_not_found(self, valid_org_id):
|
||||
"""Test error when key to delete is not found."""
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:nonexistent'
|
||||
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=False)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='nonexistent',
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert 'not found' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_invalid_service_key(self, valid_org_id):
|
||||
"""Test error when service API key is invalid."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key='wrong-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'Invalid service API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_missing_service_key(self, valid_org_id):
|
||||
"""Test error when service API key header is missing."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'X-Service-API-Key header is required' in exc_info.value.detail
|
||||
@@ -1,19 +1,26 @@
|
||||
"""Unit tests for API keys routes, focusing on BYOR key validation and retrieval."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.api_keys import (
|
||||
ByorPermittedResponse,
|
||||
CurrentApiKeyResponse,
|
||||
LlmApiKeyResponse,
|
||||
check_byor_permitted,
|
||||
delete_byor_key_from_litellm,
|
||||
get_current_api_key,
|
||||
get_llm_api_key_for_byor,
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
class TestVerifyByorKeyInLitellm:
|
||||
"""Test the verify_byor_key_in_litellm function."""
|
||||
@@ -512,3 +519,81 @@ class TestCheckByorPermitted:
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to check BYOR export permission' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestGetCurrentApiKey:
|
||||
"""Test the get_current_api_key endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_api_key_info_for_bearer_auth(self, mock_get_user_auth):
|
||||
"""Test that API key metadata including org_id is returned for bearer token auth."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
org_id = uuid.uuid4()
|
||||
mock_request = MagicMock()
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
refresh_token=SecretStr('mock-token'),
|
||||
user_id=user_id,
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=org_id,
|
||||
api_key_id=42,
|
||||
api_key_name='My Production Key',
|
||||
)
|
||||
mock_get_user_auth.return_value = user_auth
|
||||
|
||||
# Act
|
||||
result = await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, CurrentApiKeyResponse)
|
||||
assert result.org_id == str(org_id)
|
||||
assert result.id == 42
|
||||
assert result.name == 'My Production Key'
|
||||
assert result.user_id == user_id
|
||||
assert result.auth_type == 'bearer'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_400_for_cookie_auth(self, mock_get_user_auth):
|
||||
"""Test that 400 Bad Request is returned when using cookie authentication."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_request = MagicMock()
|
||||
|
||||
mock_user_auth = MagicMock()
|
||||
mock_user_auth.get_auth_type.return_value = AuthType.COOKIE
|
||||
mock_get_user_auth.return_value = mock_user_auth
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert 'API key authentication' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_400_when_api_key_org_id_is_none(self, mock_get_user_auth):
|
||||
"""Test that 400 is returned when API key has no org_id (legacy key)."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_request = MagicMock()
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
refresh_token=SecretStr('mock-token'),
|
||||
user_id=user_id,
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=None, # No org_id - legacy key
|
||||
api_key_id=42,
|
||||
api_key_name='Legacy Key',
|
||||
)
|
||||
mock_get_user_auth.return_value = user_auth
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert 'created before organization support' in exc_info.value.detail
|
||||
|
||||
314
enterprise/tests/unit/storage/test_api_key_store.py
Normal file
314
enterprise/tests/unit/storage/test_api_key_store.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Unit tests for ApiKeyStore system key functionality."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_key_store():
|
||||
"""Create ApiKeyStore instance."""
|
||||
return ApiKeyStore()
|
||||
|
||||
|
||||
class TestApiKeyStoreSystemKeys:
|
||||
"""Test cases for system API key functionality."""
|
||||
|
||||
def test_is_system_key_name_with_prefix(self, api_key_store):
|
||||
"""Test that names with __SYSTEM__: prefix are identified as system keys."""
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:automation') is True
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:test-key') is True
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:') is True
|
||||
|
||||
def test_is_system_key_name_without_prefix(self, api_key_store):
|
||||
"""Test that names without __SYSTEM__: prefix are not system keys."""
|
||||
assert api_key_store.is_system_key_name('my-key') is False
|
||||
assert api_key_store.is_system_key_name('automation') is False
|
||||
assert api_key_store.is_system_key_name('MCP_API_KEY') is False
|
||||
assert api_key_store.is_system_key_name('') is False
|
||||
|
||||
def test_is_system_key_name_none(self, api_key_store):
|
||||
"""Test that None is not a system key."""
|
||||
assert api_key_store.is_system_key_name(None) is False
|
||||
|
||||
def test_make_system_key_name(self, api_key_store):
|
||||
"""Test system key name generation."""
|
||||
assert (
|
||||
api_key_store.make_system_key_name('automation') == '__SYSTEM__:automation'
|
||||
)
|
||||
assert api_key_store.make_system_key_name('test-key') == '__SYSTEM__:test-key'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_creates_new(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test creating a new system API key when none exists."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
api_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert api_key.startswith('sk-oh-')
|
||||
assert len(api_key) == len('sk-oh-') + 32
|
||||
|
||||
# Verify the key was created in the database
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.key == api_key))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is not None
|
||||
assert key_record.user_id == user_id
|
||||
assert key_record.org_id == org_id
|
||||
assert key_record.name == '__SYSTEM__:automation'
|
||||
assert key_record.expires_at is None # System keys never expire
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_returns_existing(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that existing valid system key is returned."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Create the first key
|
||||
first_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
# Request again - should return the same key
|
||||
second_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert first_key == second_key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_different_names(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that different names create different keys."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
key1 = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='automation-1',
|
||||
)
|
||||
|
||||
key2 = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='automation-2',
|
||||
)
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_reissues_expired(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that expired system key is replaced with a new one."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
system_key_name = '__SYSTEM__:automation'
|
||||
|
||||
# First, manually create an expired key
|
||||
expired_time = datetime.now(UTC) - timedelta(hours=1)
|
||||
async with async_session_maker() as session:
|
||||
expired_key = ApiKey(
|
||||
key='sk-oh-expired-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
expires_at=expired_time.replace(tzinfo=None),
|
||||
)
|
||||
session.add(expired_key)
|
||||
await session.commit()
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Request the key - should create a new one
|
||||
new_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert new_key != 'sk-oh-expired-key-12345678901234567890'
|
||||
assert new_key.startswith('sk-oh-')
|
||||
|
||||
# Verify old key was deleted and new key exists
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(ApiKey.name == system_key_name)
|
||||
)
|
||||
keys = result.scalars().all()
|
||||
assert len(keys) == 1
|
||||
assert keys[0].key == new_key
|
||||
assert keys[0].expires_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_api_keys_excludes_system_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that list_api_keys excludes system keys."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a user key and a system key
|
||||
async with async_session_maker() as session:
|
||||
user_key = ApiKey(
|
||||
key='sk-oh-user-key-123456789012345678901',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='my-user-key',
|
||||
)
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
mcp_key = ApiKey(
|
||||
key='sk-oh-mcp-key-1234567890123456789012',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='MCP_API_KEY',
|
||||
)
|
||||
session.add(user_key)
|
||||
session.add(system_key)
|
||||
session.add(mcp_key)
|
||||
await session.commit()
|
||||
|
||||
# Mock UserStore.get_user_by_id to return a user with the correct org
|
||||
mock_user = MagicMock()
|
||||
mock_user.current_org_id = org_id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
with patch(
|
||||
'storage.api_key_store.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
mock_get_user.return_value = mock_user
|
||||
keys = await api_key_store.list_api_keys(user_id)
|
||||
|
||||
# Should only return the user key
|
||||
assert len(keys) == 1
|
||||
assert keys[0].name == 'my-user-key'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_protects_system_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that system keys cannot be deleted by users."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a system key
|
||||
async with async_session_maker() as session:
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
session.add(system_key)
|
||||
await session.commit()
|
||||
key_id = system_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Attempt to delete without allow_system flag
|
||||
result = await api_key_store.delete_api_key_by_id(
|
||||
key_id, allow_system=False
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
# Verify the key still exists
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_allows_system_with_flag(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that system keys can be deleted with allow_system=True."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a system key
|
||||
async with async_session_maker() as session:
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
session.add(system_key)
|
||||
await session.commit()
|
||||
key_id = system_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Delete with allow_system=True
|
||||
result = await api_key_store.delete_api_key_by_id(key_id, allow_system=True)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the key was deleted
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_allows_regular_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that regular keys can be deleted normally."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a regular key
|
||||
async with async_session_maker() as session:
|
||||
regular_key = ApiKey(
|
||||
key='sk-oh-regular-key-1234567890123456789',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='my-regular-key',
|
||||
)
|
||||
session.add(regular_key)
|
||||
await session.commit()
|
||||
key_id = regular_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Delete without allow_system flag - should work for regular keys
|
||||
result = await api_key_store.delete_api_key_by_id(
|
||||
key_id, allow_system=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the key was deleted
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is None
|
||||
@@ -10,6 +10,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from server.utils.saas_app_conversation_info_injector import (
|
||||
SaasSQLAppConversationInfoService,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
@@ -17,9 +20,6 @@ from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
|
||||
from enterprise.server.utils.saas_app_conversation_info_injector import (
|
||||
SaasSQLAppConversationInfoService,
|
||||
)
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationInfo,
|
||||
)
|
||||
@@ -663,3 +663,330 @@ class TestSaasSQLAppConversationInfoServiceAdminContext:
|
||||
|
||||
admin_page = await admin_service.search_app_conversation_info()
|
||||
assert len(admin_page.items) == 5
|
||||
|
||||
|
||||
class TestSaasSQLAppConversationInfoServiceWebhookFallback:
|
||||
"""Test suite for webhook callback fallback using info.created_by_user_id."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_with_admin_context_uses_created_by_user_id_fallback(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that save_app_conversation_info uses info.created_by_user_id when user_context returns None.
|
||||
|
||||
This is the key fix for SDK-created conversations: when the webhook endpoint
|
||||
uses ADMIN context (user_id=None), the service should fall back to using
|
||||
the created_by_user_id from the AppConversationInfo object.
|
||||
"""
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Create service with ADMIN context (user_id=None)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
# Create conversation info with created_by_user_id set (as would come from sandbox_info)
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID), # This should be used as fallback
|
||||
sandbox_id='sandbox_webhook_test',
|
||||
title='Webhook Created Conversation',
|
||||
)
|
||||
|
||||
# Act: Save using ADMIN context
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: SAAS metadata should be created with user_id from info.created_by_user_id
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None, 'SAAS metadata should be created'
|
||||
assert (
|
||||
saas_metadata.user_id == USER1_ID
|
||||
), 'user_id should match info.created_by_user_id'
|
||||
assert saas_metadata.org_id == ORG1_ID, 'org_id should match user current org'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_with_admin_context_no_user_id_skips_saas_metadata(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that save_app_conversation_info skips SAAS metadata when both user_context and info have no user_id."""
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Create service with ADMIN context (user_id=None)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
# Create conversation info without created_by_user_id
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=None, # No user_id available
|
||||
sandbox_id='sandbox_no_user',
|
||||
title='No User Conversation',
|
||||
)
|
||||
|
||||
# Act: Save using ADMIN context with no user_id fallback
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: SAAS metadata should NOT be created
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert (
|
||||
saas_metadata is None
|
||||
), 'SAAS metadata should not be created without user_id'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_created_conversation_visible_to_user(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test end-to-end: conversation saved via webhook is visible to the owning user."""
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Save conversation using ADMIN context (simulating webhook)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_webhook_e2e',
|
||||
title='E2E Webhook Conversation',
|
||||
)
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Act: Query as the owning user
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
user1_page = await user1_service.search_app_conversation_info()
|
||||
|
||||
# Assert: User should see the webhook-created conversation
|
||||
assert len(user1_page.items) == 1
|
||||
assert user1_page.items[0].id == conv_id
|
||||
assert user1_page.items[0].title == 'E2E Webhook Conversation'
|
||||
|
||||
|
||||
class TestSandboxIdFilterSaas:
|
||||
"""Test suite for sandbox_id__eq filter parameter in SAAS service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_sandbox_id(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test searching conversations by exact sandbox_id match with SAAS user filtering."""
|
||||
# Create service for user1
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create conversations with different sandbox IDs for user1
|
||||
conv1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_alpha',
|
||||
title='Conversation Alpha',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_beta',
|
||||
title='Conversation Beta',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv3 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_alpha',
|
||||
title='Conversation Gamma',
|
||||
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save all conversations
|
||||
await user1_service.save_app_conversation_info(conv1)
|
||||
await user1_service.save_app_conversation_info(conv2)
|
||||
await user1_service.save_app_conversation_info(conv3)
|
||||
|
||||
# Search for sandbox_alpha - should return 2 conversations
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_alpha'
|
||||
)
|
||||
assert len(page.items) == 2
|
||||
sandbox_ids = {item.sandbox_id for item in page.items}
|
||||
assert sandbox_ids == {'sandbox_alpha'}
|
||||
conversation_ids = {item.id for item in page.items}
|
||||
assert conv1.id in conversation_ids
|
||||
assert conv3.id in conversation_ids
|
||||
|
||||
# Search for sandbox_beta - should return 1 conversation
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_beta'
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv2.id
|
||||
|
||||
# Search for non-existent sandbox - should return 0 conversations
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_nonexistent'
|
||||
)
|
||||
assert len(page.items) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_by_sandbox_id(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test counting conversations by exact sandbox_id match with SAAS user filtering."""
|
||||
# Create service for user1
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create conversations with different sandbox IDs
|
||||
conv1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_x',
|
||||
title='Conversation X1',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_y',
|
||||
title='Conversation Y1',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv3 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_x',
|
||||
title='Conversation X2',
|
||||
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save all conversations
|
||||
await user1_service.save_app_conversation_info(conv1)
|
||||
await user1_service.save_app_conversation_info(conv2)
|
||||
await user1_service.save_app_conversation_info(conv3)
|
||||
|
||||
# Count for sandbox_x - should be 2
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_x'
|
||||
)
|
||||
assert count == 2
|
||||
|
||||
# Count for sandbox_y - should be 1
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_y'
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Count for non-existent sandbox - should be 0
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_nonexistent'
|
||||
)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_id_filter_respects_user_isolation(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that sandbox_id filter respects user isolation in SAAS environment."""
|
||||
# Create services for both users
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
user2_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
|
||||
)
|
||||
|
||||
# Create conversation with same sandbox_id for both users
|
||||
shared_sandbox_id = 'sandbox_shared'
|
||||
|
||||
conv_user1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id=shared_sandbox_id,
|
||||
title='User1 Conversation',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv_user2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER2_ID),
|
||||
sandbox_id=shared_sandbox_id,
|
||||
title='User2 Conversation',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save conversations
|
||||
await user1_service.save_app_conversation_info(conv_user1)
|
||||
await user2_service.save_app_conversation_info(conv_user2)
|
||||
|
||||
# User1 should only see their own conversation with this sandbox_id
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv_user1.id
|
||||
assert page.items[0].title == 'User1 Conversation'
|
||||
|
||||
# User2 should only see their own conversation with this sandbox_id
|
||||
page = await user2_service.search_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv_user2.id
|
||||
assert page.items[0].title == 'User2 Conversation'
|
||||
|
||||
# Count should also respect user isolation
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
count = await user2_service.count_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Unit tests for SlackConversationStore."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_conversation_store():
|
||||
"""Create SlackConversationStore instance."""
|
||||
return SlackConversationStore()
|
||||
|
||||
|
||||
class TestSlackConversationStore:
|
||||
"""Test cases for SlackConversationStore."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_slack_conversation_persists_to_database(
|
||||
self, slack_conversation_store, async_session_maker
|
||||
):
|
||||
"""Test that create_slack_conversation actually stores data in the database.
|
||||
|
||||
This test verifies that the await statement is present before session.merge().
|
||||
Without the await, the data won't be persisted and subsequent lookups will
|
||||
return None even though we just created the conversation.
|
||||
"""
|
||||
channel_id = 'C123456'
|
||||
parent_id = '1234567890.123456'
|
||||
conversation_id = 'conv-test-123'
|
||||
keycloak_user_id = 'user-123'
|
||||
|
||||
slack_conversation = SlackConversation(
|
||||
conversation_id=conversation_id,
|
||||
channel_id=channel_id,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
with patch(
|
||||
'storage.slack_conversation_store.a_session_maker', async_session_maker
|
||||
):
|
||||
# Create the slack conversation
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
# Verify we can retrieve the conversation using the store method
|
||||
result = await slack_conversation_store.get_slack_conversation(
|
||||
channel_id=channel_id,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
# This assertion would fail if the await was missing before session.merge()
|
||||
# because the data wouldn't be persisted to the database
|
||||
assert result is not None, (
|
||||
'Slack conversation was not persisted to the database. '
|
||||
'Ensure await is used before session.merge() in create_slack_conversation.'
|
||||
)
|
||||
assert result.conversation_id == conversation_id
|
||||
assert result.channel_id == channel_id
|
||||
assert result.parent_id == parent_id
|
||||
assert result.keycloak_user_id == keycloak_user_id
|
||||
|
||||
# Also verify directly in the database
|
||||
async with async_session_maker() as session:
|
||||
db_result = await session.execute(
|
||||
select(SlackConversation).where(
|
||||
SlackConversation.channel_id == channel_id,
|
||||
SlackConversation.parent_id == parent_id,
|
||||
)
|
||||
)
|
||||
db_conversation = db_result.scalar_one_or_none()
|
||||
assert db_conversation is not None
|
||||
assert db_conversation.conversation_id == conversation_id
|
||||
@@ -55,6 +55,28 @@ def test_generate_api_key(api_key_store):
|
||||
assert len(key) == len('sk-oh-') + 32
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.api_key_store.UserStore.get_user_by_id')
|
||||
async def test_create_api_key_strips_timezone_from_expires_at(
|
||||
mock_get_user, api_key_store, async_session_maker, mock_user
|
||||
):
|
||||
"""Timezone-aware expires_at must be stored as naive UTC without shifting the value."""
|
||||
user_id = str(uuid.uuid4())
|
||||
aware_expiry = datetime.now(UTC) + timedelta(days=30)
|
||||
mock_get_user.return_value = mock_user
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
key = await api_key_store.create_api_key(user_id, expires_at=aware_expiry)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.key == key))
|
||||
record = result.scalars().first()
|
||||
|
||||
assert record.expires_at is not None
|
||||
assert record.expires_at.tzinfo is None
|
||||
assert record.expires_at == aware_expiry.replace(tzinfo=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.api_key_store.UserStore.get_user_by_id')
|
||||
async def test_create_api_key(
|
||||
@@ -104,13 +126,18 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker):
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
key_id = key_record.id
|
||||
|
||||
# Execute - patch a_session_maker to use test's async session maker
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Verify
|
||||
assert result == user_id
|
||||
# Verify - result is now ApiKeyValidationResult
|
||||
assert result is not None
|
||||
assert result.user_id == user_id
|
||||
assert result.org_id == org_id
|
||||
assert result.key_id == key_id
|
||||
assert result.key_name == 'Test Key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -196,8 +223,9 @@ async def test_validate_api_key_valid_timezone_naive(
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Verify
|
||||
assert result == user_id
|
||||
# Verify - result is now ApiKeyValidationResult
|
||||
assert result is not None
|
||||
assert result.user_id == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -11,7 +11,6 @@ from server.auth.auth_error import AuthError
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.user.user_authorizer import UserAuthorizationResponse, UserAuthorizer
|
||||
from server.routes.auth import (
|
||||
_extract_recaptcha_state,
|
||||
accept_tos,
|
||||
authenticate,
|
||||
keycloak_callback,
|
||||
@@ -55,11 +54,12 @@ def mock_response():
|
||||
def test_set_response_cookie(mock_response, mock_request):
|
||||
"""Test setting the auth cookie on a response."""
|
||||
|
||||
with patch('server.routes.auth.config') as mock_config:
|
||||
with (
|
||||
patch('server.routes.auth.config') as mock_config,
|
||||
patch('server.utils.url_utils.get_global_config') as get_global_config,
|
||||
):
|
||||
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
|
||||
|
||||
# Configure mock_request.url.hostname
|
||||
mock_request.url.hostname = 'example.com'
|
||||
get_global_config.return_value = MagicMock(web_url='https://example.com')
|
||||
|
||||
set_response_cookie(
|
||||
request=mock_request,
|
||||
@@ -1036,79 +1036,6 @@ async def test_keycloak_callback_no_email_in_user_info(
|
||||
mock_token_manager.check_duplicate_base_email.assert_not_called()
|
||||
|
||||
|
||||
class TestExtractRecaptchaState:
|
||||
"""Tests for _extract_recaptcha_state() helper function."""
|
||||
|
||||
def test_should_extract_redirect_url_and_token_from_new_json_format(self):
|
||||
"""Test extraction from new base64-encoded JSON format."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(encoded_state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == 'https://example.com'
|
||||
assert token == 'test-token'
|
||||
|
||||
def test_should_handle_old_format_plain_redirect_url(self):
|
||||
"""Test handling of old format (plain redirect URL string)."""
|
||||
# Arrange
|
||||
state = 'https://example.com'
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == 'https://example.com'
|
||||
assert token is None
|
||||
|
||||
def test_should_handle_none_state(self):
|
||||
"""Test handling of None state."""
|
||||
# Arrange
|
||||
state = None
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == ''
|
||||
assert token is None
|
||||
|
||||
def test_should_handle_invalid_base64_gracefully(self):
|
||||
"""Test handling of invalid base64/JSON (fallback to old format)."""
|
||||
# Arrange
|
||||
state = 'not-valid-base64!!!'
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == state
|
||||
assert token is None
|
||||
|
||||
def test_should_handle_missing_redirect_url_in_json(self):
|
||||
"""Test handling when redirect_url is missing in JSON."""
|
||||
# Arrange
|
||||
state_data = {'recaptcha_token': 'test-token'}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(encoded_state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == ''
|
||||
assert token == 'test-token'
|
||||
|
||||
|
||||
class TestKeycloakCallbackRecaptcha:
|
||||
"""Tests for reCAPTCHA integration in keycloak_callback()."""
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ def mock_checkout_request():
|
||||
'server': ('test.com', 80),
|
||||
}
|
||||
)
|
||||
request._base_url = URL('http://test.com/')
|
||||
request._url = URL('http://test.com/')
|
||||
return request
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def mock_subscription_request():
|
||||
'server': ('test.com', 80),
|
||||
}
|
||||
)
|
||||
request._base_url = URL('http://test.com/')
|
||||
request._url = URL('http://test.com/')
|
||||
return request
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ async def test_create_checkout_session_success(
|
||||
async def test_success_callback_session_not_found(async_session_maker):
|
||||
"""Test success callback when billing session is not found."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
mock_request._url = URL('http://test.com/')
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.a_session_maker', async_session_maker),
|
||||
@@ -281,7 +281,7 @@ async def test_success_callback_stripe_incomplete(
|
||||
):
|
||||
"""Test success callback when Stripe session is not complete."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
mock_request._url = URL('http://test.com/')
|
||||
|
||||
session_id = 'test_incomplete_session'
|
||||
async with async_session_maker() as session:
|
||||
@@ -319,7 +319,7 @@ async def test_success_callback_stripe_incomplete(
|
||||
async def test_success_callback_success(async_session_maker, test_org, test_user):
|
||||
"""Test successful payment completion and credit update."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
mock_request._url = URL('http://test.com/')
|
||||
|
||||
session_id = 'test_success_session'
|
||||
async with async_session_maker() as session:
|
||||
@@ -391,7 +391,7 @@ async def test_success_callback_lite_llm_error(
|
||||
):
|
||||
"""Test handling of LiteLLM API errors during success callback."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
mock_request._url = URL('http://test.com/')
|
||||
|
||||
session_id = 'test_litellm_error_session'
|
||||
async with async_session_maker() as session:
|
||||
@@ -445,7 +445,7 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(
|
||||
the database transaction rolls back.
|
||||
"""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
mock_request._url = URL('http://test.com/')
|
||||
|
||||
session_id = 'test_budget_rollback_session'
|
||||
async with async_session_maker() as session:
|
||||
@@ -502,7 +502,7 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(
|
||||
async def test_cancel_callback_session_not_found(async_session_maker):
|
||||
"""Test cancel callback when billing session is not found."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
mock_request._url = URL('http://test.com/')
|
||||
|
||||
with patch('server.routes.billing.a_session_maker', async_session_maker):
|
||||
response = await cancel_callback('nonexistent_session_id', mock_request)
|
||||
@@ -517,7 +517,7 @@ async def test_cancel_callback_session_not_found(async_session_maker):
|
||||
async def test_cancel_callback_success(async_session_maker, test_org, test_user):
|
||||
"""Test successful cancellation of billing session."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
mock_request._url = URL('http://test.com/')
|
||||
|
||||
session_id = 'test_cancel_session'
|
||||
async with async_session_maker() as session:
|
||||
@@ -588,7 +588,7 @@ async def test_create_customer_setup_session_success():
|
||||
'headers': [],
|
||||
}
|
||||
)
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
mock_request._url = URL('http://test.com/')
|
||||
|
||||
mock_customer_info = {'customer_id': 'mock-customer-id', 'org_id': 'mock-org-id'}
|
||||
mock_session = MagicMock()
|
||||
@@ -613,6 +613,6 @@ async def test_create_customer_setup_session_success():
|
||||
customer='mock-customer-id',
|
||||
mode='setup',
|
||||
payment_method_types=['card'],
|
||||
success_url='https://test.com/?setup=success',
|
||||
cancel_url='https://test.com/',
|
||||
success_url='https://test.com?setup=success',
|
||||
cancel_url='https://test.com',
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
Unit tests for LiteLlmManager class.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -21,6 +23,98 @@ from storage.user_settings import UserSettings
|
||||
from openhands.server.settings import Settings
|
||||
|
||||
|
||||
class TestDefaultInitialBudget:
|
||||
"""Test cases for DEFAULT_INITIAL_BUDGET configuration."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_module_state(self):
|
||||
"""Ensure module is properly restored after each test."""
|
||||
# Save original module if it exists
|
||||
original_module = sys.modules.get('storage.lite_llm_manager')
|
||||
|
||||
yield
|
||||
|
||||
# Restore module state after each test
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env vars
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
os.environ.pop('ENABLE_BILLING', None)
|
||||
|
||||
# Restore original module or reimport fresh
|
||||
if original_module is not None:
|
||||
sys.modules['storage.lite_llm_manager'] = original_module
|
||||
else:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
|
||||
def test_default_initial_budget_none_when_billing_disabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET is None when billing is disabled."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Ensure billing is disabled (default) and reimport
|
||||
os.environ.pop('ENABLE_BILLING', None)
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET is None
|
||||
|
||||
def test_default_initial_budget_defaults_to_zero_when_billing_enabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when billing is enabled."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Enable billing and reimport
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 0.0
|
||||
|
||||
def test_default_initial_budget_uses_env_var_when_billing_enabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable when billing enabled."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 100.0
|
||||
|
||||
def test_default_initial_budget_ignores_env_var_when_billing_disabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET returns None when billing disabled, ignoring env var."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ.pop('ENABLE_BILLING', None) # billing disabled by default
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET is None
|
||||
|
||||
def test_default_initial_budget_rejects_invalid_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
assert 'Invalid DEFAULT_INITIAL_BUDGET' in str(exc_info.value)
|
||||
|
||||
def test_default_initial_budget_rejects_negative_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for negative values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
assert 'must be non-negative' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestLiteLlmManager:
|
||||
"""Test cases for LiteLlmManager class."""
|
||||
|
||||
@@ -145,6 +239,16 @@ class TestLiteLlmManager:
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
|
||||
# Mock user exists check response
|
||||
mock_user_exists_response = MagicMock()
|
||||
mock_user_exists_response.is_success = True
|
||||
mock_user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
@@ -152,12 +256,8 @@ class TestLiteLlmManager:
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_404_response
|
||||
mock_client.get.return_value.raise_for_status.side_effect = (
|
||||
httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
)
|
||||
# First GET is for _get_team (404), second GET is for _user_exists (success)
|
||||
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
@@ -180,8 +280,8 @@ class TestLiteLlmManager:
|
||||
assert result.llm_api_key.get_secret_value() == 'test-api-key'
|
||||
assert result.llm_base_url == 'http://test.com'
|
||||
|
||||
# Verify API calls were made (get_team + 4 posts)
|
||||
assert mock_client.get.call_count == 1 # get_team
|
||||
# Verify API calls were made (get_team + user_exists + 4 posts)
|
||||
assert mock_client.get.call_count == 2 # get_team + user_exists
|
||||
assert (
|
||||
mock_client.post.call_count == 4
|
||||
) # create_team, add_user_to_team, delete_key_by_alias, generate_key
|
||||
@@ -200,13 +300,21 @@ class TestLiteLlmManager:
|
||||
}
|
||||
mock_team_response.raise_for_status = MagicMock()
|
||||
|
||||
# Mock user exists check response
|
||||
mock_user_exists_response = MagicMock()
|
||||
mock_user_exists_response.is_success = True
|
||||
mock_user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
return_value={'email': 'test@example.com'}
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_team_response
|
||||
# First GET is for _get_team (success), second GET is for _user_exists (success)
|
||||
mock_client.get.side_effect = [mock_team_response, mock_user_exists_response]
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
@@ -226,8 +334,8 @@ class TestLiteLlmManager:
|
||||
assert result is not None
|
||||
|
||||
# Verify _get_team was called first
|
||||
mock_client.get.assert_called_once()
|
||||
get_call_url = mock_client.get.call_args[0][0]
|
||||
assert mock_client.get.call_count == 2 # get_team + user_exists
|
||||
get_call_url = mock_client.get.call_args_list[0][0][0]
|
||||
assert 'team/info' in get_call_url
|
||||
assert 'test-org-id' in get_call_url
|
||||
|
||||
@@ -242,26 +350,32 @@ class TestLiteLlmManager:
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == 30.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_new_org_uses_zero_budget(
|
||||
async def test_create_entries_new_org_uses_default_initial_budget(
|
||||
self, mock_settings, mock_response
|
||||
):
|
||||
"""Test that create_entries uses budget=0 for new org (team doesn't exist)."""
|
||||
"""Test that create_entries uses DEFAULT_INITIAL_BUDGET for new org."""
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
return_value={'email': 'test@example.com'}
|
||||
)
|
||||
|
||||
# Mock user exists check response
|
||||
mock_user_exists_response = MagicMock()
|
||||
mock_user_exists_response.is_success = True
|
||||
mock_user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_404_response
|
||||
mock_client.get.return_value.raise_for_status.side_effect = (
|
||||
httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
)
|
||||
# First GET is for _get_team (404), second GET is for _user_exists (success)
|
||||
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
@@ -273,6 +387,7 @@ class TestLiteLlmManager:
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
|
||||
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
|
||||
patch('httpx.AsyncClient', mock_client_class),
|
||||
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', 0.0),
|
||||
):
|
||||
result = await LiteLlmManager.create_entries(
|
||||
'test-org-id', 'test-user-id', mock_settings, create_user=False
|
||||
@@ -280,16 +395,73 @@ class TestLiteLlmManager:
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify _create_team was called with budget=0
|
||||
# Verify _create_team was called with DEFAULT_INITIAL_BUDGET (0.0)
|
||||
create_team_call = mock_client.post.call_args_list[0]
|
||||
assert 'team/new' in create_team_call[0][0]
|
||||
assert create_team_call[1]['json']['max_budget'] == 0.0
|
||||
|
||||
# Verify _add_user_to_team was called with budget=0
|
||||
# Verify _add_user_to_team was called with DEFAULT_INITIAL_BUDGET (0.0)
|
||||
add_user_call = mock_client.post.call_args_list[1]
|
||||
assert 'team/member_add' in add_user_call[0][0]
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_new_org_uses_custom_default_budget(
|
||||
self, mock_settings, mock_response
|
||||
):
|
||||
"""Test that create_entries uses custom DEFAULT_INITIAL_BUDGET for new org."""
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
|
||||
# Mock user exists check response
|
||||
mock_user_exists_response = MagicMock()
|
||||
mock_user_exists_response.is_success = True
|
||||
mock_user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
return_value={'email': 'test@example.com'}
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
# First GET is for _get_team (404), second GET is for _user_exists (success)
|
||||
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
custom_budget = 50.0
|
||||
with (
|
||||
patch.dict(os.environ, {'LOCAL_DEPLOYMENT': ''}),
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'),
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
|
||||
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
|
||||
patch('httpx.AsyncClient', mock_client_class),
|
||||
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', custom_budget),
|
||||
):
|
||||
result = await LiteLlmManager.create_entries(
|
||||
'test-org-id', 'test-user-id', mock_settings, create_user=False
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify _create_team was called with custom DEFAULT_INITIAL_BUDGET
|
||||
create_team_call = mock_client.post.call_args_list[0]
|
||||
assert 'team/new' in create_team_call[0][0]
|
||||
assert create_team_call[1]['json']['max_budget'] == custom_budget
|
||||
|
||||
# Verify _add_user_to_team was called with custom DEFAULT_INITIAL_BUDGET
|
||||
add_user_call = mock_client.post.call_args_list[1]
|
||||
assert 'team/member_add' in add_user_call[0][0]
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == custom_budget
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_propagates_non_404_errors(self, mock_settings):
|
||||
"""Test that create_entries propagates non-404 errors from _get_team."""
|
||||
@@ -687,15 +859,16 @@ class TestLiteLlmManager:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_success(self, mock_http_client, mock_response):
|
||||
"""Test successful _create_user operation."""
|
||||
"""Test successful _create_user operation returns True."""
|
||||
mock_http_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._create_user(
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_http_client.post.assert_called_once()
|
||||
call_args = mock_http_client.post.call_args
|
||||
assert 'http://test.com/user/new' in call_args[0]
|
||||
@@ -704,7 +877,7 @@ class TestLiteLlmManager:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_duplicate_email(self, mock_http_client, mock_response):
|
||||
"""Test _create_user with duplicate email handling."""
|
||||
"""Test _create_user with duplicate email handling returns True."""
|
||||
# First call fails with duplicate email
|
||||
error_response = MagicMock()
|
||||
error_response.is_success = False
|
||||
@@ -716,23 +889,81 @@ class TestLiteLlmManager:
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._create_user(
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert mock_http_client.post.call_count == 2
|
||||
# Second call should have None email
|
||||
second_call_args = mock_http_client.post.call_args_list[1]
|
||||
assert second_call_args[1]['json']['user_email'] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_user_exists_returns_true(self, mock_http_client):
|
||||
"""Test _user_exists returns True when user exists in LiteLLM."""
|
||||
# Arrange
|
||||
user_response = MagicMock()
|
||||
user_response.is_success = True
|
||||
user_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id', 'email': 'test@example.com'}
|
||||
}
|
||||
mock_http_client.get.return_value = user_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_http_client.get.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_user_exists_returns_false_when_not_found(self, mock_http_client):
|
||||
"""Test _user_exists returns False when user not found."""
|
||||
# Arrange
|
||||
user_response = MagicMock()
|
||||
user_response.is_success = False
|
||||
mock_http_client.get.return_value = user_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_user_exists_returns_false_on_mismatched_user_id(
|
||||
self, mock_http_client
|
||||
):
|
||||
"""Test _user_exists returns False when returned user_id doesn't match."""
|
||||
# Arrange
|
||||
user_response = MagicMock()
|
||||
user_response.is_success = True
|
||||
user_response.json.return_value = {
|
||||
'user_info': {'user_id': 'different-user-id'}
|
||||
}
|
||||
mock_http_client.get.return_value = user_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.logger')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_create_user_already_exists_with_409_status_code(
|
||||
async def test_create_user_already_exists_and_verified(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user handles 409 Conflict when user already exists."""
|
||||
"""Test _create_user returns True when user already exists and is verified."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
@@ -744,14 +975,141 @@ class TestLiteLlmManager:
|
||||
second_response.status_code = 409
|
||||
second_response.text = 'User with id test-user-id already exists'
|
||||
|
||||
user_exists_response = MagicMock()
|
||||
user_exists_response.is_success = True
|
||||
user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
mock_http_client.get.return_value = user_exists_response
|
||||
|
||||
# Act
|
||||
await LiteLlmManager._create_user(
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_logger.warning.assert_any_call(
|
||||
'litellm_user_already_exists',
|
||||
extra={'user_id': 'test-user-id'},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.logger')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_create_user_already_exists_but_not_found_returns_false(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user returns False when LiteLLM claims user exists but verification fails."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
first_response.status_code = 400
|
||||
first_response.text = 'duplicate email'
|
||||
|
||||
second_response = MagicMock()
|
||||
second_response.is_success = False
|
||||
second_response.status_code = 409
|
||||
second_response.text = 'User with id test-user-id already exists'
|
||||
|
||||
user_not_exists_response = MagicMock()
|
||||
user_not_exists_response.is_success = False
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
mock_http_client.get.return_value = user_not_exists_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_logger.error.assert_any_call(
|
||||
'litellm_user_claimed_exists_but_not_found',
|
||||
extra={
|
||||
'user_id': 'test-user-id',
|
||||
'status_code': 409,
|
||||
'text': 'User with id test-user-id already exists',
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.logger')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_create_user_failure_returns_false(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user returns False when creation fails with non-'already exists' error."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
first_response.status_code = 400
|
||||
first_response.text = 'duplicate email'
|
||||
|
||||
second_response = MagicMock()
|
||||
second_response.is_success = False
|
||||
second_response.status_code = 500
|
||||
second_response.text = 'Internal server error'
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_logger.error.assert_any_call(
|
||||
'error_creating_litellm_user',
|
||||
extra={
|
||||
'status_code': 500,
|
||||
'text': 'Internal server error',
|
||||
'user_id': 'test-user-id',
|
||||
'email': None,
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.lite_llm_manager.logger')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
|
||||
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
|
||||
async def test_create_user_already_exists_with_409_status_code(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user handles 409 Conflict when user already exists and verifies."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
first_response.status_code = 400
|
||||
first_response.text = 'duplicate email'
|
||||
|
||||
second_response = MagicMock()
|
||||
second_response.is_success = False
|
||||
second_response.status_code = 409
|
||||
second_response.text = 'User with id test-user-id already exists'
|
||||
|
||||
user_exists_response = MagicMock()
|
||||
user_exists_response.is_success = True
|
||||
user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
mock_http_client.get.return_value = user_exists_response
|
||||
|
||||
# Act
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_logger.warning.assert_any_call(
|
||||
'litellm_user_already_exists',
|
||||
extra={'user_id': 'test-user-id'},
|
||||
@@ -764,7 +1122,7 @@ class TestLiteLlmManager:
|
||||
async def test_create_user_already_exists_with_400_status_code(
|
||||
self, mock_logger, mock_http_client
|
||||
):
|
||||
"""Test _create_user handles 400 Bad Request when user already exists."""
|
||||
"""Test _create_user handles 400 Bad Request when user already exists and verifies."""
|
||||
# Arrange
|
||||
first_response = MagicMock()
|
||||
first_response.is_success = False
|
||||
@@ -776,14 +1134,22 @@ class TestLiteLlmManager:
|
||||
second_response.status_code = 400
|
||||
second_response.text = 'User already exists'
|
||||
|
||||
user_exists_response = MagicMock()
|
||||
user_exists_response.is_success = True
|
||||
user_exists_response.json.return_value = {
|
||||
'user_info': {'user_id': 'test-user-id'}
|
||||
}
|
||||
|
||||
mock_http_client.post.side_effect = [first_response, second_response]
|
||||
mock_http_client.get.return_value = user_exists_response
|
||||
|
||||
# Act
|
||||
await LiteLlmManager._create_user(
|
||||
result = await LiteLlmManager._create_user(
|
||||
mock_http_client, 'test@example.com', 'test-user-id'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_logger.warning.assert_any_call(
|
||||
'litellm_user_already_exists',
|
||||
extra={'user_id': 'test-user-id'},
|
||||
|
||||
@@ -98,6 +98,11 @@ class TestAcceptInvitationEmailValidation:
|
||||
|
||||
mock_keycloak_user_info = {'email': 'alice@example.com'} # Email from Keycloak
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.default_llm_model = 'test-model'
|
||||
mock_org.default_llm_base_url = None
|
||||
mock_org.default_max_iterations = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
|
||||
@@ -121,6 +126,10 @@ class TestAcceptInvitationEmailValidation:
|
||||
'server.services.org_invitation_service.OrgService.create_litellm_integration',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create_litellm,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgStore.get_org_by_id',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_org,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
|
||||
new_callable=AsyncMock,
|
||||
@@ -145,6 +154,7 @@ class TestAcceptInvitationEmailValidation:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.llm_api_key = SecretStr('test-key')
|
||||
mock_create_litellm.return_value = mock_settings
|
||||
mock_get_org.return_value = mock_org
|
||||
mock_update_status.return_value = mock_invitation
|
||||
|
||||
# Act - should not raise error because Keycloak email matches
|
||||
@@ -214,6 +224,11 @@ class TestAcceptInvitationEmailValidation:
|
||||
|
||||
mock_invitation.email = 'alice@example.com' # Lowercase in invitation
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.default_llm_model = 'test-model'
|
||||
mock_org.default_llm_base_url = None
|
||||
mock_org.default_max_iterations = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
|
||||
@@ -234,6 +249,10 @@ class TestAcceptInvitationEmailValidation:
|
||||
'server.services.org_invitation_service.OrgService.create_litellm_integration',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create_litellm,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgStore.get_org_by_id',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_org,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
|
||||
new_callable=AsyncMock,
|
||||
@@ -250,6 +269,7 @@ class TestAcceptInvitationEmailValidation:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.llm_api_key = SecretStr('test-key')
|
||||
mock_create_litellm.return_value = mock_settings
|
||||
mock_get_org.return_value = mock_org
|
||||
mock_update_status.return_value = mock_invitation
|
||||
|
||||
# Act - should not raise error because emails match case-insensitively
|
||||
@@ -258,6 +278,75 @@ class TestAcceptInvitationEmailValidation:
|
||||
# Assert - invitation was accepted (update_invitation_status was called)
|
||||
mock_update_status.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_invitation_inherits_org_llm_settings(self, mock_invitation):
|
||||
"""Test that new members inherit the organization's LLM settings when accepting invitation."""
|
||||
# Arrange
|
||||
user_id = UUID('87654321-4321-8765-4321-876543218765')
|
||||
token = 'inv-test-token-12345'
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = user_id
|
||||
mock_user.email = 'alice@example.com'
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.default_llm_model = 'claude-sonnet-4'
|
||||
mock_org.default_llm_base_url = 'https://api.anthropic.com'
|
||||
mock_org.default_max_iterations = 100
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_invitation,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgInvitationStore.is_token_expired'
|
||||
) as mock_is_expired,
|
||||
patch(
|
||||
'server.services.org_invitation_service.UserStore.get_user_by_id',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_user,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgService.create_litellm_integration',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create_litellm,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgStore.get_org_by_id',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_org,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_add_user,
|
||||
patch(
|
||||
'server.services.org_invitation_service.OrgInvitationStore.update_invitation_status',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update_status,
|
||||
):
|
||||
mock_get_invitation.return_value = mock_invitation
|
||||
mock_is_expired.return_value = False
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = None
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.llm_api_key = SecretStr('test-key')
|
||||
mock_create_litellm.return_value = mock_settings
|
||||
mock_get_org.return_value = mock_org
|
||||
mock_update_status.return_value = mock_invitation
|
||||
|
||||
# Act
|
||||
await OrgInvitationService.accept_invitation(token, user_id)
|
||||
|
||||
# Assert - verify add_user_to_org was called with org's LLM settings
|
||||
mock_add_user.assert_called_once()
|
||||
call_kwargs = mock_add_user.call_args.kwargs
|
||||
assert call_kwargs['llm_model'] == 'claude-sonnet-4'
|
||||
assert call_kwargs['llm_base_url'] == 'https://api.anthropic.com'
|
||||
assert call_kwargs['max_iterations'] == 100
|
||||
|
||||
|
||||
class TestCreateInvitationsBatch:
|
||||
"""Test cases for batch invitation creation."""
|
||||
|
||||
@@ -246,6 +246,43 @@ async def test_add_user_to_org(async_session_maker):
|
||||
assert org_member.status == 'active'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_user_to_org_with_llm_settings(async_session_maker):
|
||||
"""Test that add_user_to_org correctly sets inherited LLM settings from organization."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org-llm')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id)
|
||||
role = Role(name='member', rank=2)
|
||||
session.add_all([user, role])
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
user_id = user.id
|
||||
role_id = role.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
org_member = await OrgMemberStore.add_user_to_org(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
llm_api_key='test-api-key',
|
||||
status='active',
|
||||
llm_model='claude-sonnet-4',
|
||||
llm_base_url='https://api.example.com',
|
||||
max_iterations=50,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert org_member is not None
|
||||
assert org_member.llm_model == 'claude-sonnet-4'
|
||||
assert org_member.llm_base_url == 'https://api.example.com'
|
||||
assert org_member.max_iterations == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_in_org(async_session_maker):
|
||||
# Test updating user role in org
|
||||
|
||||
@@ -144,6 +144,86 @@ async def test_create_org(async_session_maker, mock_litellm_api):
|
||||
assert org.id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_defaults_to_true_when_default_is_true(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True and org.v1_enabled is not specified (None)
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should be set to True
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
|
||||
):
|
||||
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-true'})
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_defaults_to_false_when_default_is_false(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False and org.v1_enabled is not specified (None)
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should be set to False
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
|
||||
):
|
||||
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-false'})
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_explicit_false_overrides_default_true(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True but org.v1_enabled is explicitly set to False
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should stay False (explicit value wins over default)
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
|
||||
):
|
||||
org = await OrgStore.create_org(
|
||||
kwargs={'name': 'test-org-v1-explicit-false', 'v1_enabled': False}
|
||||
)
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_explicit_true_overrides_default_false(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False but org.v1_enabled is explicitly set to True
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should stay True (explicit value wins over default)
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
|
||||
):
|
||||
org = await OrgStore.create_org(
|
||||
kwargs={'name': 'test-org-v1-explicit-true', 'v1_enabled': True}
|
||||
)
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_by_name(async_session_maker, mock_litellm_api):
|
||||
# Test getting org by name
|
||||
|
||||
@@ -304,6 +304,76 @@ class TestRunMaintenanceTasks:
|
||||
assert 'error' in updated_task.info
|
||||
assert updated_task.info['error'] == 'Test error'
|
||||
|
||||
def test_set_stale_task_error_uses_naive_utc_cutoff(self, session_maker):
|
||||
"""set_stale_task_error must compare against a naive UTC cutoff."""
|
||||
# Create tasks using naive UTC started_at values (matching what run_tasks writes).
|
||||
with session_maker() as session:
|
||||
stale = MaintenanceTask(
|
||||
status=MaintenanceTaskStatus.WORKING,
|
||||
processor_type='test.processor',
|
||||
processor_json='{}',
|
||||
started_at=datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(hours=2),
|
||||
)
|
||||
recent = MaintenanceTask(
|
||||
status=MaintenanceTaskStatus.WORKING,
|
||||
processor_type='test.processor',
|
||||
processor_json='{}',
|
||||
started_at=datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(minutes=30),
|
||||
)
|
||||
session.add_all([stale, recent])
|
||||
session.commit()
|
||||
stale_id, recent_id = stale.id, recent.id
|
||||
|
||||
with patch('run_maintenance_tasks.session_maker', return_value=session_maker()):
|
||||
set_stale_task_error()
|
||||
|
||||
with session_maker() as session:
|
||||
assert (
|
||||
session.get(MaintenanceTask, stale_id).status
|
||||
== MaintenanceTaskStatus.ERROR
|
||||
)
|
||||
assert (
|
||||
session.get(MaintenanceTask, recent_id).status
|
||||
== MaintenanceTaskStatus.WORKING
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tasks_stores_naive_utc_started_at(self, session_maker):
|
||||
"""run_tasks must write naive UTC datetimes to started_at and updated_at."""
|
||||
processor = AsyncMock(return_value={})
|
||||
|
||||
with session_maker() as session:
|
||||
task = MaintenanceTask(
|
||||
status=MaintenanceTaskStatus.PENDING,
|
||||
processor_type='test.processor',
|
||||
processor_json='{}',
|
||||
)
|
||||
session.add(task)
|
||||
session.commit()
|
||||
task_id = task.id
|
||||
|
||||
with patch(
|
||||
'storage.maintenance_task.MaintenanceTask.get_processor',
|
||||
return_value=processor,
|
||||
):
|
||||
with patch(
|
||||
'run_maintenance_tasks.session_maker', return_value=session_maker()
|
||||
):
|
||||
with patch('asyncio.sleep', new_callable=AsyncMock):
|
||||
try:
|
||||
await asyncio.wait_for(run_tasks(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
with session_maker() as session:
|
||||
updated = session.get(MaintenanceTask, task_id)
|
||||
assert updated.started_at is not None
|
||||
assert updated.started_at.tzinfo is None
|
||||
assert updated.updated_at is not None
|
||||
assert updated.updated_at.tzinfo is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tasks_respects_delay(self, session_maker):
|
||||
"""Test that run_tasks respects the delay parameter."""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -233,3 +234,206 @@ async def test_ensure_api_key_generates_new_key_when_verification_fails(
|
||||
|
||||
assert item.llm_api_key is not None
|
||||
assert item.llm_api_key.get_secret_value() == new_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_with_multiple_members_fixture(session_maker):
|
||||
"""Set up an organization with multiple members for testing LLM settings propagation.
|
||||
|
||||
Uses sync session to avoid UUID conversion issues with async SQLite.
|
||||
"""
|
||||
from storage.encrypt_utils import decrypt_value
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.user import User
|
||||
|
||||
# Use realistic UUIDs that work well with SQLite
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
admin_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5082')
|
||||
member1_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5083')
|
||||
member2_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5084')
|
||||
|
||||
with session_maker() as session:
|
||||
# Create role
|
||||
role = Role(id=10, name='member', rank=3)
|
||||
session.add(role)
|
||||
|
||||
# Create org
|
||||
org = Org(
|
||||
id=org_id,
|
||||
name='test-org',
|
||||
org_version=1,
|
||||
enable_default_condenser=True,
|
||||
enable_proactive_conversation_starters=True,
|
||||
)
|
||||
session.add(org)
|
||||
|
||||
# Create users
|
||||
admin_user = User(
|
||||
id=admin_user_id, current_org_id=org_id, user_consents_to_analytics=True
|
||||
)
|
||||
session.add(admin_user)
|
||||
|
||||
member1_user = User(
|
||||
id=member1_user_id, current_org_id=org_id, user_consents_to_analytics=True
|
||||
)
|
||||
session.add(member1_user)
|
||||
|
||||
member2_user = User(
|
||||
id=member2_user_id, current_org_id=org_id, user_consents_to_analytics=True
|
||||
)
|
||||
session.add(member2_user)
|
||||
|
||||
# Create org members with DIFFERENT initial LLM settings
|
||||
admin_member = OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=admin_user_id,
|
||||
role_id=10,
|
||||
llm_api_key='admin-initial-key',
|
||||
llm_model='old-model-v1',
|
||||
llm_base_url='http://old-url-1.com',
|
||||
max_iterations=10,
|
||||
status='active',
|
||||
)
|
||||
session.add(admin_member)
|
||||
|
||||
member1 = OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=member1_user_id,
|
||||
role_id=10,
|
||||
llm_api_key='member1-initial-key',
|
||||
llm_model='old-model-v2',
|
||||
llm_base_url='http://old-url-2.com',
|
||||
max_iterations=20,
|
||||
status='active',
|
||||
)
|
||||
session.add(member1)
|
||||
|
||||
member2 = OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=member2_user_id,
|
||||
role_id=10,
|
||||
llm_api_key='member2-initial-key',
|
||||
llm_model='old-model-v3',
|
||||
llm_base_url='http://old-url-3.com',
|
||||
max_iterations=30,
|
||||
status='active',
|
||||
)
|
||||
session.add(member2)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
'org_id': org_id,
|
||||
'admin_user_id': admin_user_id,
|
||||
'member1_user_id': member1_user_id,
|
||||
'member2_user_id': member2_user_id,
|
||||
'decrypt_value': decrypt_value,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_propagates_llm_settings_to_all_org_members(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When admin saves LLM settings, all org members should receive the updated settings.
|
||||
|
||||
This test verifies using a real database that:
|
||||
1. The bulk UPDATE targets the correct organization (WHERE clause is correct)
|
||||
2. All LLM fields are correctly set (llm_model, llm_base_url, max_iterations, llm_api_key)
|
||||
3. The llm_api_key is properly encrypted
|
||||
4. All members in the org receive the same updated values
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from storage.org_member import OrgMember
|
||||
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
org_id = fixture['org_id']
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
decrypt_value = fixture['decrypt_value']
|
||||
|
||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||
|
||||
new_settings = DataSettings(
|
||||
llm_model='new-shared-model/gpt-4',
|
||||
llm_base_url='http://new-shared-url.com',
|
||||
max_iterations=100,
|
||||
llm_api_key=SecretStr('new-shared-api-key'),
|
||||
)
|
||||
|
||||
# Act - call store() with async session
|
||||
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
|
||||
await store.store(new_settings)
|
||||
|
||||
# Assert - verify ALL org members have the updated LLM settings using sync session
|
||||
with session_maker() as session:
|
||||
result = session.execute(select(OrgMember).where(OrgMember.org_id == org_id))
|
||||
members = result.scalars().all()
|
||||
|
||||
# Verify we have all 3 members
|
||||
assert len(members) == 3, f'Expected 3 org members, got {len(members)}'
|
||||
|
||||
for member in members:
|
||||
# Verify LLM model is updated
|
||||
assert (
|
||||
member.llm_model == 'new-shared-model/gpt-4'
|
||||
), f'Expected llm_model to be updated for member {member.user_id}'
|
||||
|
||||
# Verify LLM base URL is updated
|
||||
assert (
|
||||
member.llm_base_url == 'http://new-shared-url.com'
|
||||
), f'Expected llm_base_url to be updated for member {member.user_id}'
|
||||
|
||||
# Verify max_iterations is updated
|
||||
assert (
|
||||
member.max_iterations == 100
|
||||
), f'Expected max_iterations to be 100 for member {member.user_id}'
|
||||
|
||||
# Verify the API key is encrypted and decrypts to the correct value
|
||||
decrypted_key = decrypt_value(member._llm_api_key)
|
||||
assert (
|
||||
decrypted_key == 'new-shared-api-key'
|
||||
), f'Expected llm_api_key to decrypt to new-shared-api-key for member {member.user_id}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_updates_org_default_llm_settings(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When admin saves LLM settings, org's default_llm_model/base_url/max_iterations should be updated.
|
||||
|
||||
This test verifies that the Org table's default settings are updated so that
|
||||
new members joining later will inherit the correct LLM configuration.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from storage.org import Org
|
||||
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
org_id = fixture['org_id']
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
|
||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||
|
||||
new_settings = DataSettings(
|
||||
llm_model='anthropic/claude-sonnet-4',
|
||||
llm_base_url='https://api.anthropic.com/v1',
|
||||
max_iterations=75,
|
||||
llm_api_key=SecretStr('test-api-key'),
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
|
||||
await store.store(new_settings)
|
||||
|
||||
# Assert - verify org's default fields were updated
|
||||
with session_maker() as session:
|
||||
result = session.execute(select(Org).where(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
|
||||
assert org is not None
|
||||
assert org.default_llm_model == 'anthropic/claude-sonnet-4'
|
||||
assert org.default_llm_base_url == 'https://api.anthropic.com/v1'
|
||||
assert org.default_max_iterations == 75
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
@@ -18,6 +19,7 @@ from server.auth.saas_user_auth import (
|
||||
saas_user_auth_from_cookie,
|
||||
saas_user_auth_from_signed_token,
|
||||
)
|
||||
from storage.api_key_store import ApiKeyValidationResult
|
||||
from storage.user_authorization import UserAuthorizationType
|
||||
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
@@ -468,12 +470,22 @@ async def test_saas_user_auth_from_bearer_success():
|
||||
algorithm='HS256',
|
||||
)
|
||||
|
||||
mock_org_id = uuid.uuid4()
|
||||
mock_validation_result = ApiKeyValidationResult(
|
||||
user_id='test_user_id',
|
||||
org_id=mock_org_id,
|
||||
key_id=42,
|
||||
key_name='Test Key',
|
||||
)
|
||||
|
||||
with (
|
||||
patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls,
|
||||
patch('server.auth.saas_user_auth.token_manager') as mock_token_manager,
|
||||
):
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.validate_api_key = AsyncMock(return_value='test_user_id')
|
||||
mock_api_key_store.validate_api_key = AsyncMock(
|
||||
return_value=mock_validation_result
|
||||
)
|
||||
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
|
||||
|
||||
mock_token_manager.load_offline_token = AsyncMock(return_value=offline_token)
|
||||
@@ -485,6 +497,9 @@ async def test_saas_user_auth_from_bearer_success():
|
||||
|
||||
assert isinstance(result, SaasUserAuth)
|
||||
assert result.user_id == 'test_user_id'
|
||||
assert result.api_key_org_id == mock_org_id
|
||||
assert result.api_key_id == 42
|
||||
assert result.api_key_name == 'Test Key'
|
||||
mock_api_key_store.validate_api_key.assert_called_once_with('test_api_key')
|
||||
mock_token_manager.load_offline_token.assert_called_once_with('test_user_id')
|
||||
mock_token_manager.refresh.assert_called_once_with(offline_token)
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
"""Tests for AwsSharedEventService."""
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from server.sharing.aws_shared_event_service import (
|
||||
AwsSharedEventService,
|
||||
AwsSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
from server.sharing.shared_conversation_models import SharedConversation
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.llm.utils.metrics import TokenUsage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_shared_conversation_info_service():
|
||||
"""Create a mock SharedConversationInfoService."""
|
||||
return AsyncMock(spec=SharedConversationInfoService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_client():
|
||||
"""Create a mock S3 client."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_service():
|
||||
"""Create a mock EventService for returned by get_event_service."""
|
||||
return AsyncMock(spec=EventService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aws_shared_event_service(mock_shared_conversation_info_service, mock_s3_client):
|
||||
"""Create an AwsSharedEventService for testing."""
|
||||
return AwsSharedEventService(
|
||||
shared_conversation_info_service=mock_shared_conversation_info_service,
|
||||
s3_client=mock_s3_client,
|
||||
bucket_name='test-bucket',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_public_conversation():
|
||||
"""Create a sample public conversation."""
|
||||
return SharedConversation(
|
||||
id=uuid4(),
|
||||
created_by_user_id='test_user',
|
||||
sandbox_id='test_sandbox',
|
||||
title='Test Public Conversation',
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
metrics=MetricsSnapshot(
|
||||
accumulated_cost=0.0,
|
||||
max_budget_per_task=10.0,
|
||||
accumulated_token_usage=TokenUsage(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_event():
|
||||
"""Create a sample event."""
|
||||
# For testing purposes, we'll just use a mock that the EventPage can accept
|
||||
# The actual event creation is complex and not the focus of these tests
|
||||
return None
|
||||
|
||||
|
||||
class TestAwsSharedEventService:
|
||||
"""Test cases for AwsSharedEventService."""
|
||||
|
||||
async def test_get_shared_event_returns_event_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that get_shared_event returns an event for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
event_id = uuid4()
|
||||
|
||||
# Mock the public conversation service to return a public conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return an event
|
||||
mock_event_service.get_event.return_value = sample_event
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_shared_event(
|
||||
conversation_id, event_id
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == sample_event
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.get_event.assert_called_once_with(conversation_id, event_id)
|
||||
|
||||
async def test_get_shared_event_returns_none_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that get_shared_event returns None for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
event_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_shared_event(
|
||||
conversation_id, event_id
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is None
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called since get_event_service returns None
|
||||
mock_event_service.get_event.assert_not_called()
|
||||
|
||||
async def test_search_shared_events_returns_events_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that search_shared_events returns events for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_page = EventPage(items=[], next_page_id=None)
|
||||
mock_event_service.search_events.return_value = mock_event_page
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.search_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == mock_event_page
|
||||
assert len(result.items) == 0 # Empty list as we mocked
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.search_events.assert_called_once_with(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
timestamp__gte=None,
|
||||
timestamp__lt=None,
|
||||
sort_order=EventSortOrder.TIMESTAMP,
|
||||
page_id=None,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
async def test_search_shared_events_returns_empty_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that search_shared_events returns empty page for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.search_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(result, EventPage)
|
||||
assert len(result.items) == 0
|
||||
assert result.next_page_id is None
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called
|
||||
mock_event_service.search_events.assert_not_called()
|
||||
|
||||
async def test_count_shared_events_returns_count_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
):
|
||||
"""Test that count_shared_events returns count for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return a count
|
||||
mock_event_service.count_events.return_value = 5
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.count_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 5
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.count_events.assert_called_once_with(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
timestamp__gte=None,
|
||||
timestamp__lt=None,
|
||||
)
|
||||
|
||||
async def test_count_shared_events_returns_zero_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that count_shared_events returns 0 for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.count_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 0
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called
|
||||
mock_event_service.count_events.assert_not_called()
|
||||
|
||||
async def test_batch_get_shared_events_returns_events_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that batch_get_shared_events returns events for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
event_ids = [uuid4() for _ in range(3)]
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_service.get_event.return_value = sample_event
|
||||
|
||||
# Call the method
|
||||
results = await aws_shared_event_service.batch_get_shared_events(
|
||||
conversation_id, event_ids
|
||||
)
|
||||
|
||||
# Verify the results
|
||||
assert len(results) == 3
|
||||
assert all(result == sample_event for result in results)
|
||||
|
||||
|
||||
class TestAwsSharedEventServiceGetEventService:
|
||||
"""Test cases for AwsSharedEventService.get_event_service method."""
|
||||
|
||||
async def test_get_event_service_returns_event_service_for_shared_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
sample_public_conversation,
|
||||
):
|
||||
"""Test that get_event_service returns an EventService for a shared conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock the shared conversation info service to return a shared conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_event_service(conversation_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is not None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
async def test_get_event_service_returns_none_for_non_shared_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
):
|
||||
"""Test that get_event_service returns None for a non-shared conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock the shared conversation info service to return None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_event_service(conversation_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
|
||||
class TestAwsSharedEventServiceInjector:
|
||||
"""Test cases for AwsSharedEventServiceInjector."""
|
||||
|
||||
def test_bucket_name_from_environment_variable(self):
|
||||
"""Test that bucket_name is read from FILE_STORE_PATH environment variable."""
|
||||
test_bucket_name = 'test-bucket-name'
|
||||
with patch.dict(os.environ, {'FILE_STORE_PATH': test_bucket_name}):
|
||||
# Create a new injector instance to pick up the environment variable
|
||||
# Note: The class attribute is evaluated at class definition time,
|
||||
# so we need to test that the attribute exists and can be overridden
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = os.environ.get('FILE_STORE_PATH')
|
||||
assert injector.bucket_name == test_bucket_name
|
||||
|
||||
def test_bucket_name_default_value_when_env_not_set(self):
|
||||
"""Test that bucket_name is None when FILE_STORE_PATH is not set."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Remove FILE_STORE_PATH if it exists
|
||||
os.environ.pop('FILE_STORE_PATH', None)
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
# The bucket_name will be whatever was set at class definition time
|
||||
# or None if FILE_STORE_PATH was not set when the class was defined
|
||||
assert hasattr(injector, 'bucket_name')
|
||||
|
||||
async def test_injector_yields_aws_shared_event_service(self):
|
||||
"""Test that the injector yields an AwsSharedEventService instance."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
# Verify the service is an instance of AwsSharedEventService
|
||||
assert isinstance(service, AwsSharedEventService)
|
||||
assert service.s3_client == mock_s3_client
|
||||
assert service.bucket_name == 'test-bucket'
|
||||
|
||||
async def test_injector_uses_bucket_name_from_instance(self):
|
||||
"""Test that the injector uses the bucket_name from the instance."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector with a specific bucket name
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'my-custom-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
assert service.bucket_name == 'my-custom-bucket'
|
||||
|
||||
async def test_injector_creates_sql_shared_conversation_info_service(self):
|
||||
"""Test that the injector creates SQLSharedConversationInfoService with db_session."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.SQLSharedConversationInfoService'
|
||||
) as mock_sql_service_class,
|
||||
):
|
||||
mock_sql_service = MagicMock()
|
||||
mock_sql_service_class.return_value = mock_sql_service
|
||||
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
# Verify the service has the correct shared_conversation_info_service
|
||||
assert service.shared_conversation_info_service == mock_sql_service
|
||||
|
||||
# Verify SQLSharedConversationInfoService was created with db_session
|
||||
mock_sql_service_class.assert_called_once_with(db_session=mock_db_session)
|
||||
|
||||
async def test_injector_works_without_request(self):
|
||||
"""Test that the injector works when request is None."""
|
||||
mock_state = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method with request=None
|
||||
async for service in injector.inject(mock_state, request=None):
|
||||
assert isinstance(service, AwsSharedEventService)
|
||||
|
||||
async def test_injector_uses_role_based_authentication(self):
|
||||
"""Test that the injector uses role-based authentication (no explicit credentials)."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
) as mock_boto3_client,
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
patch.dict(os.environ, {'AWS_S3_ENDPOINT': 'https://s3.example.com'}),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
pass
|
||||
|
||||
# Verify boto3.client was called with 's3' and endpoint_url
|
||||
# but without explicit credentials (role-based auth)
|
||||
mock_boto3_client.assert_called_once_with(
|
||||
's3',
|
||||
endpoint_url='https://s3.example.com',
|
||||
)
|
||||
171
enterprise/tests/unit/test_sharing/test_shared_event_router.py
Normal file
171
enterprise/tests/unit/test_sharing/test_shared_event_router.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for shared_event_router provider selection.
|
||||
|
||||
This module tests the get_shared_event_service_injector function which
|
||||
determines which SharedEventServiceInjector to use based on environment variables.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from server.sharing.aws_shared_event_service import AwsSharedEventServiceInjector
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_event_router import get_shared_event_service_injector
|
||||
|
||||
|
||||
class TestGetSharedEventServiceInjector:
|
||||
"""Test cases for get_shared_event_service_injector function."""
|
||||
|
||||
def test_defaults_to_google_cloud_when_no_env_set(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when no env is set."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{},
|
||||
clear=True,
|
||||
):
|
||||
os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None)
|
||||
os.environ.pop('FILE_STORE', None)
|
||||
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_google_cloud_when_file_store_google_cloud(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when FILE_STORE=google_cloud."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None)
|
||||
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_aws_when_provider_aws(self):
|
||||
"""Test that AwsSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=aws."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'aws',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_uses_gcp_when_provider_gcp(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=gcp."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'gcp',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_gcp_when_provider_google_cloud(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=google_cloud."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_provider_takes_precedence_over_file_store(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER takes precedence over FILE_STORE."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'aws',
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should use AWS because SHARED_EVENT_STORAGE_PROVIDER takes precedence
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_provider_gcp_takes_precedence_over_file_store_s3(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER=gcp takes precedence over FILE_STORE=s3."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'gcp',
|
||||
'FILE_STORE': 's3',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should use GCP because SHARED_EVENT_STORAGE_PROVIDER takes precedence
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_provider_is_case_insensitive_aws(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for AWS."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'AWS',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_provider_is_case_insensitive_gcp(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for GCP."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'GCP',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_unknown_provider_defaults_to_google_cloud(self):
|
||||
"""Test that unknown provider defaults to GoogleCloudSharedEventServiceInjector."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'unknown_provider',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should default to GCP for unknown providers
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_empty_provider_falls_back_to_file_store(self):
|
||||
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': '',
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should default to GCP for unknown providers
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -101,6 +101,72 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
|
||||
assert settings.llm_base_url == 'http://test.url'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_v1_enabled_true_when_default_is_true(
|
||||
mock_litellm_api,
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True
|
||||
WHEN: create_default_settings is called
|
||||
THEN: The default_settings.v1_enabled should be set to True
|
||||
"""
|
||||
org_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Track the settings passed to LiteLlmManager.create_entries
|
||||
captured_settings = None
|
||||
|
||||
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
|
||||
nonlocal captured_settings
|
||||
captured_settings = settings
|
||||
return settings
|
||||
|
||||
with (
|
||||
patch('storage.user_store.DEFAULT_V1_ENABLED', True),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.create_entries',
|
||||
side_effect=capture_create_entries,
|
||||
),
|
||||
):
|
||||
await UserStore.create_default_settings(org_id, user_id)
|
||||
|
||||
assert captured_settings is not None
|
||||
assert captured_settings.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_v1_enabled_false_when_default_is_false(
|
||||
mock_litellm_api,
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False
|
||||
WHEN: create_default_settings is called
|
||||
THEN: The default_settings.v1_enabled should be set to False
|
||||
"""
|
||||
org_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Track the settings passed to LiteLlmManager.create_entries
|
||||
captured_settings = None
|
||||
|
||||
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
|
||||
nonlocal captured_settings
|
||||
captured_settings = settings
|
||||
return settings
|
||||
|
||||
with (
|
||||
patch('storage.user_store.DEFAULT_V1_ENABLED', False),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.create_entries',
|
||||
side_effect=capture_create_entries,
|
||||
),
|
||||
):
|
||||
await UserStore.create_default_settings(org_id, user_id)
|
||||
|
||||
assert captured_settings is not None
|
||||
assert captured_settings.v1_enabled is False
|
||||
|
||||
|
||||
# --- Tests for get_user_by_id ---
|
||||
|
||||
|
||||
@@ -1243,3 +1309,19 @@ async def test_migrate_user_sql_multiple_conversations(async_session_maker):
|
||||
assert (
|
||||
row.org_id == user_uuid_str
|
||||
), f'org_id should match: {row.org_id} vs {user_uuid_str}'
|
||||
|
||||
|
||||
# Note: The v1_enabled logic in migrate_user follows the same pattern as OrgStore.create_org:
|
||||
# if org.v1_enabled is None:
|
||||
# org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
#
|
||||
# This behavior is tested in test_org_store.py via:
|
||||
# - test_create_org_v1_enabled_defaults_to_true_when_default_is_true
|
||||
# - test_create_org_v1_enabled_defaults_to_false_when_default_is_false
|
||||
# - test_create_org_v1_enabled_explicit_false_overrides_default_true
|
||||
# - test_create_org_v1_enabled_explicit_true_overrides_default_false
|
||||
#
|
||||
# Testing migrate_user directly is impractical due to its complex raw SQL migration
|
||||
# statements that have SQLite/UUID compatibility issues in the test environment.
|
||||
# The SQL migration tests above (test_migrate_user_sql_type_handling, etc.) verify
|
||||
# the SQL operations work correctly with proper type handling.
|
||||
|
||||
1
enterprise/tests/unit/utils/__init__.py
Normal file
1
enterprise/tests/unit/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests for enterprise server utils
|
||||
425
enterprise/tests/unit/utils/test_url_utils.py
Normal file
425
enterprise/tests/unit/utils/test_url_utils.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""Tests for URL utility functions that prevent URL hijacking attacks."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestGetWebUrl:
|
||||
"""Tests for get_web_url function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request(self):
|
||||
"""Create a mock FastAPI request object."""
|
||||
request = MagicMock()
|
||||
request.url = MagicMock()
|
||||
return request
|
||||
|
||||
def test_configured_web_url_is_used(self, mock_request):
|
||||
"""When web_url is configured, it should be used instead of request URL."""
|
||||
from server.utils.url_utils import get_web_url
|
||||
|
||||
mock_request.url.hostname = 'evil-attacker.com'
|
||||
mock_request.url.netloc = 'evil-attacker.com:443'
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://app.all-hands.dev'
|
||||
|
||||
with patch(
|
||||
'server.utils.url_utils.get_global_config', return_value=mock_config
|
||||
):
|
||||
result = get_web_url(mock_request)
|
||||
|
||||
assert result == 'https://app.all-hands.dev'
|
||||
# Should not use any info from the potentially poisoned request
|
||||
assert 'evil-attacker.com' not in result
|
||||
|
||||
def test_configured_web_url_trailing_slash_stripped(self, mock_request):
|
||||
"""Configured web_url should have trailing slashes stripped."""
|
||||
from server.utils.url_utils import get_web_url
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://app.all-hands.dev/'
|
||||
|
||||
with patch(
|
||||
'server.utils.url_utils.get_global_config', return_value=mock_config
|
||||
):
|
||||
result = get_web_url(mock_request)
|
||||
|
||||
assert result == 'https://app.all-hands.dev'
|
||||
assert not result.endswith('/')
|
||||
|
||||
def test_unconfigured_web_url_localhost_uses_http(self, mock_request):
|
||||
"""When web_url is not configured and hostname is localhost, use http."""
|
||||
from server.utils.url_utils import get_web_url
|
||||
|
||||
mock_request.url.hostname = 'localhost'
|
||||
mock_request.url.netloc = 'localhost:3000'
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = None
|
||||
|
||||
with patch(
|
||||
'server.utils.url_utils.get_global_config', return_value=mock_config
|
||||
):
|
||||
result = get_web_url(mock_request)
|
||||
|
||||
assert result == 'http://localhost:3000'
|
||||
|
||||
def test_unconfigured_web_url_non_localhost_uses_https(self, mock_request):
|
||||
"""When web_url is not configured and hostname is not localhost, use https."""
|
||||
from server.utils.url_utils import get_web_url
|
||||
|
||||
mock_request.url.hostname = 'example.com'
|
||||
mock_request.url.netloc = 'example.com:443'
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = None
|
||||
|
||||
with patch(
|
||||
'server.utils.url_utils.get_global_config', return_value=mock_config
|
||||
):
|
||||
result = get_web_url(mock_request)
|
||||
|
||||
assert result == 'https://example.com:443'
|
||||
|
||||
def test_unconfigured_web_url_empty_string_fallback(self, mock_request):
|
||||
"""Empty string web_url should trigger fallback."""
|
||||
from server.utils.url_utils import get_web_url
|
||||
|
||||
mock_request.url.hostname = 'localhost'
|
||||
mock_request.url.netloc = 'localhost:3000'
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = ''
|
||||
|
||||
with patch(
|
||||
'server.utils.url_utils.get_global_config', return_value=mock_config
|
||||
):
|
||||
result = get_web_url(mock_request)
|
||||
|
||||
assert result == 'http://localhost:3000'
|
||||
|
||||
|
||||
class TestGetCookieDomain:
|
||||
"""Tests for get_cookie_domain function."""
|
||||
|
||||
def test_production_with_configured_web_url(self):
|
||||
"""In production with web_url configured, should return hostname."""
|
||||
from server.utils.url_utils import get_cookie_domain
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://app.all-hands.dev'
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', False),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_domain()
|
||||
|
||||
assert result == 'app.all-hands.dev'
|
||||
|
||||
def test_production_without_web_url_returns_none(self):
|
||||
"""In production without web_url configured, should return None."""
|
||||
from server.utils.url_utils import get_cookie_domain
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = None
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', False),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_domain()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_local_env_returns_none(self):
|
||||
"""In local environment, should return None for cookie domain."""
|
||||
from server.utils.url_utils import get_cookie_domain
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://app.all-hands.dev'
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', False),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', True),
|
||||
):
|
||||
result = get_cookie_domain()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_staging_env_returns_none(self):
|
||||
"""In staging environment, should return None for cookie domain."""
|
||||
from server.utils.url_utils import get_cookie_domain
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://staging.all-hands.dev'
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', True),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_domain()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_feature_env_returns_none(self):
|
||||
"""In feature environment, should return None for cookie domain."""
|
||||
from server.utils.url_utils import get_cookie_domain
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://feature-123.staging.all-hands.dev'
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', True),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', True),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_domain()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetCookieSamesite:
|
||||
"""Tests for get_cookie_samesite function."""
|
||||
|
||||
def test_production_with_configured_web_url_returns_strict(self):
|
||||
"""In production with web_url configured, should return 'strict'."""
|
||||
from server.utils.url_utils import get_cookie_samesite
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://app.all-hands.dev'
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', False),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_samesite()
|
||||
|
||||
assert result == 'strict'
|
||||
|
||||
def test_production_without_web_url_returns_lax(self):
|
||||
"""In production without web_url configured, should return 'lax'."""
|
||||
from server.utils.url_utils import get_cookie_samesite
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = None
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', False),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_samesite()
|
||||
|
||||
assert result == 'lax'
|
||||
|
||||
def test_local_env_returns_lax(self):
|
||||
"""In local environment, should return 'lax'."""
|
||||
from server.utils.url_utils import get_cookie_samesite
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'http://localhost:3000'
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', False),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', True),
|
||||
):
|
||||
result = get_cookie_samesite()
|
||||
|
||||
assert result == 'lax'
|
||||
|
||||
def test_staging_env_returns_lax(self):
|
||||
"""In staging environment, should return 'lax'."""
|
||||
from server.utils.url_utils import get_cookie_samesite
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://staging.all-hands.dev'
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', True),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_samesite()
|
||||
|
||||
assert result == 'lax'
|
||||
|
||||
def test_feature_env_returns_lax(self):
|
||||
"""In feature environment, should return 'lax'."""
|
||||
from server.utils.url_utils import get_cookie_samesite
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://feature-xyz.staging.all-hands.dev'
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', True),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', True),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_samesite()
|
||||
|
||||
assert result == 'lax'
|
||||
|
||||
def test_empty_web_url_returns_lax(self):
|
||||
"""Empty web_url should be treated as unconfigured and return 'lax'."""
|
||||
from server.utils.url_utils import get_cookie_samesite
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = ''
|
||||
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', False),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
result = get_cookie_samesite()
|
||||
|
||||
assert result == 'lax'
|
||||
|
||||
|
||||
class TestSecurityScenarios:
|
||||
"""Tests for security-critical scenarios."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request(self):
|
||||
"""Create a mock FastAPI request object."""
|
||||
request = MagicMock()
|
||||
request.url = MagicMock()
|
||||
return request
|
||||
|
||||
def test_header_poisoning_attack_blocked_when_configured(self, mock_request):
|
||||
"""
|
||||
When web_url is configured, X-Forwarded-* header poisoning should not affect
|
||||
the returned URL.
|
||||
"""
|
||||
from server.utils.url_utils import get_web_url
|
||||
|
||||
# Simulate a poisoned request where attacker controls headers
|
||||
mock_request.url.hostname = 'evil.com'
|
||||
mock_request.url.netloc = 'evil.com:443'
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://app.all-hands.dev'
|
||||
|
||||
with patch(
|
||||
'server.utils.url_utils.get_global_config', return_value=mock_config
|
||||
):
|
||||
result = get_web_url(mock_request)
|
||||
|
||||
# Should use configured web_url, not the poisoned request data
|
||||
assert result == 'https://app.all-hands.dev'
|
||||
assert 'evil' not in result
|
||||
|
||||
def test_cookie_domain_not_set_in_dev_environments(self):
|
||||
"""
|
||||
Cookie domain should not be set in development environments to prevent
|
||||
cookies from leaking to other subdomains.
|
||||
"""
|
||||
from server.utils.url_utils import get_cookie_domain
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://my-feature.staging.all-hands.dev'
|
||||
|
||||
# Test each dev environment
|
||||
for env_name, env_config in [
|
||||
(
|
||||
'local',
|
||||
{
|
||||
'IS_LOCAL_ENV': True,
|
||||
'IS_STAGING_ENV': False,
|
||||
'IS_FEATURE_ENV': False,
|
||||
},
|
||||
),
|
||||
(
|
||||
'staging',
|
||||
{
|
||||
'IS_LOCAL_ENV': False,
|
||||
'IS_STAGING_ENV': True,
|
||||
'IS_FEATURE_ENV': False,
|
||||
},
|
||||
),
|
||||
(
|
||||
'feature',
|
||||
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': True},
|
||||
),
|
||||
]:
|
||||
with (
|
||||
patch(
|
||||
'server.utils.url_utils.get_global_config', return_value=mock_config
|
||||
),
|
||||
patch(
|
||||
'server.utils.url_utils.IS_FEATURE_ENV',
|
||||
env_config['IS_FEATURE_ENV'],
|
||||
),
|
||||
patch(
|
||||
'server.utils.url_utils.IS_STAGING_ENV',
|
||||
env_config['IS_STAGING_ENV'],
|
||||
),
|
||||
patch(
|
||||
'server.utils.url_utils.IS_LOCAL_ENV', env_config['IS_LOCAL_ENV']
|
||||
),
|
||||
):
|
||||
result = get_cookie_domain()
|
||||
assert result is None, f'Expected None for {env_name} environment'
|
||||
|
||||
def test_strict_samesite_only_in_production(self):
|
||||
"""
|
||||
SameSite=strict should only be set in production to ensure proper
|
||||
security without breaking OAuth flows in development.
|
||||
"""
|
||||
from server.utils.url_utils import get_cookie_samesite
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.web_url = 'https://app.all-hands.dev'
|
||||
|
||||
# Production should be strict
|
||||
with (
|
||||
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
|
||||
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
|
||||
patch('server.utils.url_utils.IS_STAGING_ENV', False),
|
||||
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
|
||||
):
|
||||
assert get_cookie_samesite() == 'strict'
|
||||
|
||||
# Dev environments should be lax
|
||||
for env_config in [
|
||||
{'IS_LOCAL_ENV': True, 'IS_STAGING_ENV': False, 'IS_FEATURE_ENV': False},
|
||||
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': False},
|
||||
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': True},
|
||||
]:
|
||||
with (
|
||||
patch(
|
||||
'server.utils.url_utils.get_global_config', return_value=mock_config
|
||||
),
|
||||
patch(
|
||||
'server.utils.url_utils.IS_FEATURE_ENV',
|
||||
env_config['IS_FEATURE_ENV'],
|
||||
),
|
||||
patch(
|
||||
'server.utils.url_utils.IS_STAGING_ENV',
|
||||
env_config['IS_STAGING_ENV'],
|
||||
),
|
||||
patch(
|
||||
'server.utils.url_utils.IS_LOCAL_ENV', env_config['IS_LOCAL_ENV']
|
||||
),
|
||||
):
|
||||
assert get_cookie_samesite() == 'lax'
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -8,3 +8,4 @@ node_modules/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.react-router/
|
||||
ralph/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest";
|
||||
import axios from "axios";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
@@ -6,6 +7,8 @@ vi.mock("#/api/open-hands-axios", () => ({
|
||||
openHands: { get: mockGet },
|
||||
}));
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
describe("V1ConversationService", () => {
|
||||
describe("readConversationFile", () => {
|
||||
it("uses default plan path when filePath is not provided", async () => {
|
||||
@@ -24,4 +27,91 @@ describe("V1ConversationService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.post as Mock).mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("uses query params for file upload path", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "test.txt", { type: "text/plain" });
|
||||
const uploadPath = "/workspace/custom/path.txt";
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
uploadPath,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
|
||||
|
||||
// Verify URL uses query params format
|
||||
expect(callUrl).toContain("/api/file/upload?");
|
||||
expect(callUrl).toContain("path=%2Fworkspace%2Fcustom%2Fpath.txt");
|
||||
|
||||
// Verify it's NOT using path params format
|
||||
expect(callUrl).not.toContain("/api/file/upload/%2F");
|
||||
});
|
||||
|
||||
it("uses default workspace path when no path provided", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "myfile.txt", { type: "text/plain" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
|
||||
|
||||
// Default path should be /workspace/{filename}
|
||||
expect(callUrl).toContain("path=%2Fworkspace%2Fmyfile.txt");
|
||||
});
|
||||
|
||||
it("sends file as FormData with correct headers", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "test.txt", { type: "text/plain" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (axios.post as Mock).mock.calls[0];
|
||||
|
||||
// Verify FormData is sent
|
||||
const formData = callArgs[1];
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
expect(formData.get("file")).toBe(file);
|
||||
|
||||
// Verify headers include session API key and content type
|
||||
const headers = callArgs[2].headers;
|
||||
expect(headers).toHaveProperty("X-Session-API-Key", sessionApiKey);
|
||||
expect(headers).toHaveProperty("Content-Type", "multipart/form-data");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("ExpandableMessage", () => {
|
||||
|
||||
it("should render the out of credits message when the user is out of credits", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - We only care about the app_mode and feature_flags fields
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { renderWithProviders } from "../../../test-utils";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createMockWebClientConfig } from "../../helpers/mock-config";
|
||||
|
||||
const mockTrackAddTeamMembersButtonClick = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackAddTeamMembersButtonClick: mockTrackAddTeamMembersButtonClick,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock posthog feature flag
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked module to get access to the mock
|
||||
import * as posthog from "posthog-js/react";
|
||||
|
||||
describe("AccountSettingsContextMenu", () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickAccountSettingsMock = vi.fn();
|
||||
const onLogoutMock = vi.fn();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// Set default feature flag to false
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
// Create a wrapper with MemoryRouter and renderWithProviders
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
|
||||
const renderWithSaasConfig = (ui: React.ReactElement, options?: { analyticsConsent?: boolean }) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "saas" }));
|
||||
queryClient.setQueryData(["settings"], { user_consents_to_analytics: options?.analyticsConsent ?? true });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWithOssConfig = (ui: React.ReactElement) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" }));
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
onClickAccountSettingsMock.mockClear();
|
||||
onLogoutMock.mockClear();
|
||||
onCloseMock.mockClear();
|
||||
mockTrackAddTeamMembersButtonClick.mockClear();
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockClear();
|
||||
});
|
||||
|
||||
it("should always render the right options", () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Documentation link with correct attributes", () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a");
|
||||
expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev");
|
||||
expect(documentationLink).toHaveAttribute("target", "_blank");
|
||||
expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should call onLogout when the logout option is clicked", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("logout button is always enabled", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onClose when clicking outside of the element", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(accountSettingsButton);
|
||||
await user.click(document.body);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show Add Team Members button in SaaS mode when feature flag is enabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("add-team-members-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button in SaaS mode when feature flag is disabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button in OSS mode even when feature flag is enabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithOssConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button when analytics consent is disabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
{ analyticsConsent: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call tracking function and onClose when Add Team Members button is clicked", async () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const addTeamMembersButton = screen.getByTestId("add-team-members-button");
|
||||
await user.click(addTeamMembersButton);
|
||||
|
||||
expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ContextMenuContainer } from "#/components/features/context-menu/context-menu-container";
|
||||
|
||||
describe("ContextMenuContainer", () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
it("should render children", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock}>
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply consistent base styling", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
expect(container).toHaveClass("bg-[#050505]");
|
||||
expect(container).toHaveClass("border");
|
||||
expect(container).toHaveClass("border-[#242424]");
|
||||
expect(container).toHaveClass("rounded-[12px]");
|
||||
expect(container).toHaveClass("p-[25px]");
|
||||
expect(container).toHaveClass("context-menu-box-shadow");
|
||||
});
|
||||
|
||||
it("should call onClose when clicking outside", async () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
await user.click(document.body);
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should render children in a flex row layout", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
const innerDiv = container.firstChild as HTMLElement;
|
||||
expect(innerDiv).toHaveClass("flex");
|
||||
expect(innerDiv).toHaveClass("flex-row");
|
||||
expect(innerDiv).toHaveClass("gap-4");
|
||||
});
|
||||
|
||||
it("should apply additional className when provided", () => {
|
||||
render(
|
||||
<ContextMenuContainer
|
||||
onClose={onCloseMock}
|
||||
testId="test-container"
|
||||
className="custom-class"
|
||||
>
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
expect(container).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ContextMenuCTA } from "#/components/features/context-menu/context-menu-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ContextMenuCTA", () => {
|
||||
it("should render the CTA component", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE_DESCRIPTION")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call trackSaasSelfhostedInquiry with location 'context_menu' when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "context_menu",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render Learn More as a link with correct href and target", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise/",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should render the stacked icon", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const contentContainer = screen.getByTestId("context-menu-cta-content");
|
||||
const icon = contentContainer.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveAttribute("width", "40");
|
||||
expect(icon).toHaveAttribute("height", "40");
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ describe("SystemMessage UI Rendering", () => {
|
||||
<ToolsContextMenu
|
||||
onClose={() => {}}
|
||||
onShowSkills={() => {}}
|
||||
onShowHooks={() => {}}
|
||||
onShowAgentTools={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("#/hooks/use-auth-url", () => ({
|
||||
bitbucket: "https://bitbucket.org/site/oauth2/authorize",
|
||||
bitbucket_data_center:
|
||||
"https://bitbucket-dc.example.com/site/oauth2/authorize",
|
||||
enterprise_sso: "https://auth.example.com/realms/test/protocol/openid-connect/auth",
|
||||
};
|
||||
if (config.appMode === "saas") {
|
||||
return urls[config.identityProvider] || null;
|
||||
@@ -48,9 +49,17 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock feature flags - we'll control the return value in each test
|
||||
const mockEnableProjUserJourney = vi.fn(() => true);
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
|
||||
}));
|
||||
|
||||
describe("LoginContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
// Reset mock to return true by default
|
||||
mockEnableProjUserJourney.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -117,6 +126,74 @@ describe("LoginContent", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Enterprise SSO button when configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Enterprise SSO alongside other providers when all configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["github", "gitlab", "bitbucket", "enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to Enterprise SSO auth URL when Enterprise SSO button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUrl = "https://auth.example.com/realms/test/protocol/openid-connect/auth";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const enterpriseSsoButton = screen.getByRole("button", {
|
||||
name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i,
|
||||
});
|
||||
await user.click(enterpriseSsoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toContain(mockUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display message when no providers are configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -205,6 +282,65 @@ describe("LoginContent", () => {
|
||||
expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the enterprise LoginCTA component when appMode is saas and feature flag enabled", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when appMode is oss even with feature flag enabled", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="oss"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when appMode is null", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode={null}
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when feature flag is disabled", () => {
|
||||
// Disable the feature flag
|
||||
mockEnableProjUserJourney.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display invitation pending message when hasInvitation is true", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LoginCTA } from "#/components/features/auth/login-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LoginCTA", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render enterprise CTA with title and description", () => {
|
||||
render(<LoginCTA />);
|
||||
|
||||
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE_DEPLOY")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all enterprise feature list items", () => {
|
||||
render(<LoginCTA />);
|
||||
|
||||
expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_COMPLIANCE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Learn More as a link with correct href and target", () => {
|
||||
render(<LoginCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise/",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should call trackSaasSelfhostedInquiry with location 'login_page' when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "login_page",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,12 +84,12 @@ describe("TaskTrackingObservationContent", () => {
|
||||
expect(taskItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("displays task IDs and notes", () => {
|
||||
it("does not display task IDs but displays notes", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("ID: task-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: task-2")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-3")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { HookEventItem } from "#/components/features/conversation-panel/hook-event-item";
|
||||
import { HooksEmptyState } from "#/components/features/conversation-panel/hooks-empty-state";
|
||||
import { HooksLoadingState } from "#/components/features/conversation-panel/hooks-loading-state";
|
||||
import { HooksModalHeader } from "#/components/features/conversation-panel/hooks-modal-header";
|
||||
import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
HOOKS_MODAL$TITLE: "Available Hooks",
|
||||
HOOKS_MODAL$HOOK_COUNT: `${params?.count ?? 0} hooks`,
|
||||
HOOKS_MODAL$EVENT_PRE_TOOL_USE: "Pre Tool Use",
|
||||
HOOKS_MODAL$EVENT_POST_TOOL_USE: "Post Tool Use",
|
||||
HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT: "User Prompt Submit",
|
||||
HOOKS_MODAL$EVENT_SESSION_START: "Session Start",
|
||||
HOOKS_MODAL$EVENT_SESSION_END: "Session End",
|
||||
HOOKS_MODAL$EVENT_STOP: "Stop",
|
||||
HOOKS_MODAL$MATCHER: "Matcher",
|
||||
HOOKS_MODAL$COMMANDS: "Commands",
|
||||
HOOKS_MODAL$TYPE: `Type: ${params?.type ?? ""}`,
|
||||
HOOKS_MODAL$TIMEOUT: `Timeout: ${params?.timeout ?? 0}s`,
|
||||
HOOKS_MODAL$ASYNC: "Async",
|
||||
COMMON$FETCH_ERROR: "Failed to fetch data",
|
||||
CONVERSATION$NO_HOOKS: "No hooks configured",
|
||||
BUTTON$REFRESH: "Refresh",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("HooksLoadingState", () => {
|
||||
it("should render loading spinner", () => {
|
||||
render(<HooksLoadingState />);
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HooksEmptyState", () => {
|
||||
it("should render no hooks message when not error", () => {
|
||||
render(<HooksEmptyState isError={false} />);
|
||||
expect(screen.getByText("No hooks configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error message when isError is true", () => {
|
||||
render(<HooksEmptyState isError={true} />);
|
||||
expect(screen.getByText("Failed to fetch data")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HooksModalHeader", () => {
|
||||
const defaultProps = {
|
||||
isAgentReady: true,
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render title", () => {
|
||||
render(<HooksModalHeader {...defaultProps} />);
|
||||
expect(screen.getByText("Available Hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render refresh button when agent is ready", () => {
|
||||
render(<HooksModalHeader {...defaultProps} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render refresh button when agent is not ready", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isAgentReady={false} />);
|
||||
expect(screen.queryByTestId("refresh-hooks")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onRefresh when refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefresh = vi.fn();
|
||||
render(<HooksModalHeader {...defaultProps} onRefresh={onRefresh} />);
|
||||
|
||||
await user.click(screen.getByTestId("refresh-hooks"));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should disable refresh button when loading", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable refresh button when refetching", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isRefetching={true} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HookEventItem", () => {
|
||||
const mockHookEvent: HookEvent = {
|
||||
event_type: "stop",
|
||||
matchers: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: ".openhands/hooks/on_stop.sh",
|
||||
timeout: 30,
|
||||
async: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
hookEvent: mockHookEvent,
|
||||
isExpanded: false,
|
||||
onToggle: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render event type label using i18n", () => {
|
||||
render(<HookEventItem {...defaultProps} />);
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render hook count", () => {
|
||||
render(<HookEventItem {...defaultProps} />);
|
||||
expect(screen.getByText("1 hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onToggle when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(<HookEventItem {...defaultProps} onToggle={onToggle} />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledWith("stop");
|
||||
});
|
||||
|
||||
it("should show collapsed state by default", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={false} />);
|
||||
// Matcher content should not be visible when collapsed
|
||||
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show expanded state with matcher content", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={true} />);
|
||||
// Matcher content should be visible when expanded
|
||||
expect(screen.getByText("*")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render async badge for async hooks", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={true} />);
|
||||
expect(screen.getByText("Async")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render different event types with correct i18n labels", () => {
|
||||
const eventTypes = [
|
||||
{ type: "pre_tool_use", label: "Pre Tool Use" },
|
||||
{ type: "post_tool_use", label: "Post Tool Use" },
|
||||
{ type: "user_prompt_submit", label: "User Prompt Submit" },
|
||||
{ type: "session_start", label: "Session Start" },
|
||||
{ type: "session_end", label: "Session End" },
|
||||
{ type: "stop", label: "Stop" },
|
||||
];
|
||||
|
||||
eventTypes.forEach(({ type, label }) => {
|
||||
const { unmount } = render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={{ ...mockHookEvent, event_type: type }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to event_type when no i18n key exists", () => {
|
||||
render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={{ ...mockHookEvent, event_type: "unknown_event" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("unknown_event")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ vi.mock("react-i18next", async () => {
|
||||
CONVERSATION$SHOW_SKILLS: "Show Skills",
|
||||
BUTTON$DISPLAY_COST: "Display Cost",
|
||||
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
|
||||
"Close Conversation (Stop Runtime)",
|
||||
"Close Conversation (Stop Sandbox)",
|
||||
COMMON$DELETE_CONVERSATION: "Delete Conversation",
|
||||
CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
|
||||
CONVERSATION$LINK_COPIED: "Link copied to clipboard",
|
||||
@@ -565,7 +565,7 @@ describe("ConversationNameContextMenu", () => {
|
||||
"Delete Conversation",
|
||||
);
|
||||
expect(screen.getByTestId("stop-button")).toHaveTextContent(
|
||||
"Close Conversation (Stop Runtime)",
|
||||
"Close Conversation (Stop Sandbox)",
|
||||
);
|
||||
expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
|
||||
"Display Cost",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
|
||||
const CONVERSATION_ID = "conv-abc123";
|
||||
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: CONVERSATION_ID }),
|
||||
}));
|
||||
|
||||
let mockHasTaskList = false;
|
||||
vi.mock("#/hooks/use-task-list", () => ({
|
||||
useTaskList: () => ({
|
||||
hasTaskList: mockHasTaskList,
|
||||
taskList: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ConversationTabsContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockHasTaskList = false;
|
||||
});
|
||||
|
||||
it("should render nothing when isOpen is false", () => {
|
||||
const { container } = render(
|
||||
<ConversationTabsContextMenu isOpen={false} onClose={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should render all default tabs when open", () => {
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const expectedTabs = [
|
||||
"COMMON$PLANNER",
|
||||
"COMMON$CHANGES",
|
||||
"COMMON$CODE",
|
||||
"COMMON$TERMINAL",
|
||||
"COMMON$APP",
|
||||
"COMMON$BROWSER",
|
||||
];
|
||||
for (const tab of expectedTabs) {
|
||||
expect(screen.getByText(tab)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should re-pin a tab when clicking an unpinned tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
|
||||
// Unpin
|
||||
await user.click(terminalItem);
|
||||
let storedState = JSON.parse(
|
||||
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.unpinnedTabs).toContain("terminal");
|
||||
|
||||
// Re-pin
|
||||
await user.click(terminalItem);
|
||||
storedState = JSON.parse(
|
||||
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.unpinnedTabs).not.toContain("terminal");
|
||||
});
|
||||
|
||||
describe("with tasklist", () => {
|
||||
beforeEach(() => {
|
||||
mockHasTaskList = true;
|
||||
});
|
||||
|
||||
it("should show tasklist in context menu when hasTaskList is true", () => {
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("COMMON$TASK_LIST")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd";
|
||||
@@ -16,6 +15,14 @@ vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: mockConversationId }),
|
||||
}));
|
||||
|
||||
let mockHasTaskList = false;
|
||||
vi.mock("#/hooks/use-task-list", () => ({
|
||||
useTaskList: () => ({
|
||||
hasTaskList: mockHasTaskList,
|
||||
taskList: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createWrapper = (conversationId: string) => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
|
||||
@@ -31,6 +38,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
localStorage.clear();
|
||||
vi.resetAllMocks();
|
||||
mockConversationId = TASK_CONVERSATION_ID;
|
||||
mockHasTaskList = false;
|
||||
useConversationStore.setState({
|
||||
selectedTab: null,
|
||||
isRightPanelShown: false,
|
||||
@@ -71,47 +79,6 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
expect(parsed).toHaveProperty("rightPanelShown");
|
||||
expect(parsed).toHaveProperty("unpinnedTabs");
|
||||
});
|
||||
|
||||
it("should store unpinned tabs in consolidated key via context menu", async () => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
await user.click(terminalItem);
|
||||
|
||||
const consolidatedKey = `conversation-state-${REAL_CONVERSATION_ID}`;
|
||||
const storedState = localStorage.getItem(consolidatedKey);
|
||||
expect(storedState).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(storedState!);
|
||||
expect(parsed.unpinnedTabs).toContain("terminal");
|
||||
});
|
||||
|
||||
it("should hide a tab after unpinning it from context menu", async () => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<>
|
||||
<ConversationTabs />
|
||||
<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />
|
||||
</>,
|
||||
{ wrapper: createWrapper(REAL_CONVERSATION_ID) },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("conversation-tab-terminal"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
await user.click(terminalItem);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-tab-terminal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook integration", () => {
|
||||
@@ -205,4 +172,37 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
expect(storedState.selectedTab).toBe("browser");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tasklist tab", () => {
|
||||
beforeEach(() => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
mockHasTaskList = true;
|
||||
});
|
||||
|
||||
it("should show tasklist tab when hasTaskList is true", () => {
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId("conversation-tab-tasklist"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should select tasklist tab when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
const tasklistTab = screen.getByTestId("conversation-tab-tasklist");
|
||||
await user.click(tasklistTab);
|
||||
|
||||
const { selectedTab, hasRightPanelToggled } =
|
||||
useConversationStore.getState();
|
||||
expect(selectedTab).toBe("tasklist");
|
||||
expect(hasRightPanelToggled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
|
||||
|
||||
const mockCapture = vi.fn();
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: () => ({
|
||||
capture: mockCapture,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
describe("EnterpriseBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Feature Flag", () => {
|
||||
it("should not render when proj_user_journey feature flag is disabled", () => {
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
|
||||
const { container } = renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render when proj_user_journey feature flag is enabled", () => {
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the self-hosted label", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$SELF_HOSTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the enterprise title", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the enterprise description", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$DESCRIPTION")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all four enterprise feature items", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_DATA_PRIVACY"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_DEPLOYMENT"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ENTERPRISE$FEATURE_SSO")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_SUPPORT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the learn more link", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveTextContent("ENTERPRISE$LEARN_MORE");
|
||||
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Learn More Link Interaction", () => {
|
||||
it("should capture PostHog event when learn more link is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
await user.click(link);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith("saas_selfhosted_inquiry");
|
||||
});
|
||||
|
||||
it("should have correct href attribute for opening in new tab", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { HomepageCTA } from "#/components/features/home/homepage-cta";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"CTA$ENTERPRISE_TITLE": "Get OpenHands for Enterprise",
|
||||
"CTA$ENTERPRISE_DESCRIPTION":
|
||||
"Cloud allows you to access OpenHands anywhere and coordinate with your team like never before",
|
||||
"CTA$LEARN_MORE": "Learn More",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock local storage
|
||||
vi.mock("#/utils/local-storage", () => ({
|
||||
setCTADismissed: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { setCTADismissed } from "#/utils/local-storage";
|
||||
|
||||
describe("HomepageCTA", () => {
|
||||
const mockSetShouldShowCTA = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderHomepageCTA = () => {
|
||||
return render(<HomepageCTA setShouldShowCTA={mockSetShouldShowCTA} />);
|
||||
};
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the enterprise title", () => {
|
||||
renderHomepageCTA();
|
||||
expect(
|
||||
screen.getByText("Get OpenHands for Enterprise"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the enterprise description", () => {
|
||||
renderHomepageCTA();
|
||||
expect(
|
||||
screen.getByText(/Cloud allows you to access OpenHands anywhere/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Learn More link", () => {
|
||||
renderHomepageCTA();
|
||||
const link = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the close button with correct aria-label", () => {
|
||||
renderHomepageCTA();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("close button behavior", () => {
|
||||
it("calls setCTADismissed with 'homepage' when close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(setCTADismissed).toHaveBeenCalledWith("homepage");
|
||||
});
|
||||
|
||||
it("calls setShouldShowCTA with false when close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockSetShouldShowCTA).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("calls both setCTADismissed and setShouldShowCTA in order", async () => {
|
||||
const user = userEvent.setup();
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.mocked(setCTADismissed).mockImplementation(() => {
|
||||
callOrder.push("setCTADismissed");
|
||||
});
|
||||
mockSetShouldShowCTA.mockImplementation(() => {
|
||||
callOrder.push("setShouldShowCTA");
|
||||
});
|
||||
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(callOrder).toEqual(["setCTADismissed", "setShouldShowCTA"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Learn More link behavior", () => {
|
||||
it("calls trackSaasSelfhostedInquiry with location 'home_page' when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "home_page",
|
||||
});
|
||||
});
|
||||
|
||||
it("has correct href and target attributes", () => {
|
||||
renderHomepageCTA();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise/",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessibility", () => {
|
||||
it("close button is focusable", () => {
|
||||
renderHomepageCTA();
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
expect(closeButton).not.toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
it("Learn More link is focusable", () => {
|
||||
renderHomepageCTA();
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(learnMoreLink).not.toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import OnboardingForm from "#/routes/onboarding-form";
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
const mockTrackOnboardingCompleted = vi.fn();
|
||||
|
||||
vi.mock("react-router", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("react-router")>();
|
||||
@@ -22,6 +24,16 @@ vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => mockUseConfig(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackOnboardingCompleted: mockTrackOnboardingCompleted,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderOnboardingForm = () => {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
@@ -30,10 +42,15 @@ const renderOnboardingForm = () => {
|
||||
);
|
||||
};
|
||||
|
||||
describe("OnboardingForm", () => {
|
||||
describe("OnboardingForm - SaaS Mode", () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
mockTrackOnboardingCompleted.mockClear();
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
@@ -50,7 +67,7 @@ describe("OnboardingForm", () => {
|
||||
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display step progress indicator with 3 bars", () => {
|
||||
it("should display step progress indicator with 3 bars for saas mode", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
@@ -69,7 +86,7 @@ describe("OnboardingForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
@@ -84,7 +101,7 @@ describe("OnboardingForm", () => {
|
||||
let progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 2, first two progress bars should be filled
|
||||
@@ -96,7 +113,7 @@ describe("OnboardingForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
@@ -107,29 +124,51 @@ describe("OnboardingForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select role
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select org size
|
||||
// Step 1 - select org size (first step in saas mode - single select)
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select use case
|
||||
// Step 2 - select use case (multi-select)
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role (last step in saas mode - single select)
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
step1: "software_engineer",
|
||||
step2: "org_2_10",
|
||||
step3: "new_features",
|
||||
org_size: "org_2_10",
|
||||
use_case: ["new_features"],
|
||||
role: "software_engineer",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should render 6 options on step 1", () => {
|
||||
it("should track onboarding completion to PostHog in SaaS mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Complete the full SaaS onboarding flow
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledWith({
|
||||
role: "software_engineer",
|
||||
orgSize: "org_2_10",
|
||||
useCase: ["new_features"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should render 5 options on step 1 (org size question)", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const options = screen
|
||||
@@ -137,31 +176,86 @@ describe("OnboardingForm", () => {
|
||||
.filter((btn) =>
|
||||
btn.getAttribute("data-testid")?.startsWith("step-option-"),
|
||||
);
|
||||
expect(options).toHaveLength(6);
|
||||
expect(options).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should preserve selections when navigating through steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Select role on step 1
|
||||
await user.click(screen.getByTestId("step-option-cto_founder"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select org size on step 2
|
||||
// Select org size on step 1 (single select)
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select use case on step 3
|
||||
// Select use case on step 2 (multi-select)
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select role on step 3 (single select)
|
||||
await user.click(screen.getByTestId("step-option-cto_founder"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
// Verify all selections were preserved
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
step1: "cto_founder",
|
||||
step2: "solo",
|
||||
step3: "fixing_bugs",
|
||||
org_size: "solo",
|
||||
use_case: ["fixing_bugs"],
|
||||
role: "cto_founder",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow selecting multiple options on multi-select steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select org size (single select)
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select multiple use cases (multi-select)
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByTestId("step-option-refactoring"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role (single select)
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
org_size: "solo",
|
||||
use_case: ["new_features", "fixing_bugs", "refactoring"],
|
||||
role: "software_engineer",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow deselecting options on multi-select steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select org size
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select and deselect use cases
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByTestId("step-option-new_features")); // Deselect
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
org_size: "solo",
|
||||
use_case: ["fixing_bugs"],
|
||||
role: "software_engineer",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -171,10 +265,10 @@ describe("OnboardingForm", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 3
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 3, all three progress bars should be filled
|
||||
@@ -194,7 +288,7 @@ describe("OnboardingForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /back/i });
|
||||
@@ -206,7 +300,7 @@ describe("OnboardingForm", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 2
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Verify we're on step 2 (2 progress bars filled)
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("StepContent", () => {
|
||||
|
||||
const defaultProps = {
|
||||
options: mockOptions,
|
||||
selectedOptionId: null,
|
||||
selectedOptionIds: [],
|
||||
onSelectOption: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("StepContent", () => {
|
||||
});
|
||||
|
||||
it("should mark the selected option as selected", () => {
|
||||
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
|
||||
render(<StepContent {...defaultProps} selectedOptionIds={["option1"]} />);
|
||||
|
||||
const selectedOption = screen.getByTestId("step-option-option1");
|
||||
const unselectedOption = screen.getByTestId("step-option-option2");
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StepInput } from "#/components/features/onboarding/step-input";
|
||||
|
||||
describe("StepInput", () => {
|
||||
const defaultProps = {
|
||||
id: "test-input",
|
||||
label: "Test Label",
|
||||
value: "",
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render with correct test id", () => {
|
||||
render(<StepInput {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the label", () => {
|
||||
render(<StepInput {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the provided value", () => {
|
||||
render(<StepInput {...defaultProps} value="Hello World" />);
|
||||
|
||||
const input = screen.getByTestId("step-input-test-input");
|
||||
expect(input).toHaveValue("Hello World");
|
||||
});
|
||||
|
||||
it("should call onChange when user types", async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByTestId("step-input-test-input");
|
||||
await user.type(input, "a");
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("should call onChange with the full input value on each keystroke", async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByTestId("step-input-test-input");
|
||||
await user.type(input, "abc");
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(3);
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(1, "a");
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(2, "b");
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(3, "c");
|
||||
});
|
||||
|
||||
it("should use the id prop for data-testid", () => {
|
||||
render(<StepInput {...defaultProps} id="org_name" />);
|
||||
|
||||
expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render as a text input", () => {
|
||||
render(<StepInput {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("step-input-test-input");
|
||||
expect(input).toHaveAttribute("type", "text");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConfirmRemoveMemberModal } from "#/components/features/org/confirm-remove-member-modal";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
Trans: ({
|
||||
values,
|
||||
components,
|
||||
}: {
|
||||
values: { email: string };
|
||||
components: { email: React.ReactElement };
|
||||
}) => React.cloneElement(components.email, {}, values.email),
|
||||
}));
|
||||
|
||||
describe("ConfirmRemoveMemberModal", () => {
|
||||
it("should display the member email in the confirmation message", () => {
|
||||
// Arrange
|
||||
const memberEmail = "test@example.com";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail={memberEmail}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(memberEmail)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onConfirm when the confirm button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onConfirmMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={onConfirmMock}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Assert
|
||||
expect(onConfirmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onCancel when the cancel button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onCancelMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancelMock}
|
||||
memberEmail="test@example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Assert
|
||||
expect(onCancelMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should disable buttons and show loading spinner when isLoading is true", () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("confirm-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConfirmUpdateRoleModal } from "#/components/features/org/confirm-update-role-modal";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
Trans: ({
|
||||
values,
|
||||
components,
|
||||
}: {
|
||||
values: { email: string; role: string };
|
||||
components: { email: React.ReactElement; role: React.ReactElement };
|
||||
}) => (
|
||||
<>
|
||||
{React.cloneElement(components.email, {}, values.email)}
|
||||
{React.cloneElement(components.role, {}, values.role)}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ConfirmUpdateRoleModal", () => {
|
||||
it("should display the member email and new role in the confirmation message", () => {
|
||||
// Arrange
|
||||
const memberEmail = "test@example.com";
|
||||
const newRole = "admin";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail={memberEmail}
|
||||
newRole={newRole}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(memberEmail)).toBeInTheDocument();
|
||||
expect(screen.getByText(newRole)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onConfirm when the confirm button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onConfirmMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={onConfirmMock}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Assert
|
||||
expect(onConfirmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onCancel when the cancel button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onCancelMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancelMock}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Assert
|
||||
expect(onCancelMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should disable buttons and show loading spinner when isLoading is true", () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("confirm-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user