Compare commits

...

198 Commits

Author SHA1 Message Date
openhands
a52e5bdc26 Merge main into PR 13306
Resolve settings-related merge conflicts while preserving split condenser and verification pages.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-26 07:42:33 +00:00
Hiep Le
6794603963 feat(frontend): update settings UI with section headers and dividers (org project) (#13584) 2026-03-26 12:37:53 +07:00
Hiep Le
9be60bc286 fix: make MCP settings user-specific within organization (#13591) 2026-03-26 11:42:08 +07:00
openhands
e9b0f7b5c1 Align org LLM settings with main behavior
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-26 00:08:09 +00:00
Xingyao Wang
f7b53283b5 fix(frontend): guard against undefined matcher.hooks in hooks modal (#13589)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-25 18:20:46 +00:00
Tim O'Farrell
3cd85a07b7 APP-1093 fix(frontend): display 'Starting' status when server reports STARTING on conversation resume (#13580)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-25 08:55:39 -04:00
Hiep Le
0b935669f3 fix(backend): clean up orphaned Keycloak users on duplicate email rejection (#13495) 2026-03-25 16:46:20 +07:00
Hiep Le
889754abfd fix: use API key's org_id when creating conversations via API key auth (#13568) 2026-03-25 16:46:06 +07:00
Tim O'Farrell
06cd53d752 APP-1113 fix: Increase polling time for SetTitleCallbackProcessor (#13577)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 17:40:40 -06:00
Tim O'Farrell
eb189144f2 APP-1115 Fix for AWS config (Minio) for feature branches (#13579)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 17:40:04 -06:00
statxc
c9b2ce2fb9 feat: add user-configurable enable/disable of default global skills w… (#13046)
Co-authored-by: intelliking <intelliking@users.noreply.github.com>
2026-03-24 14:48:22 -06:00
HeyItsChloe
abdc58cd28 feat(frontend): lead capture form (#13496)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-24 13:41:35 -07:00
aivong-openhands
9f47727da5 PLTF-330: add timestamp to enterprise JSON logger formatter (#13555)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 14:53:14 -05:00
Ash Clarke
19da63aae6 Log all terminal states (error, stuck) in V1 callback processors (#13549)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 13:04:39 -05:00
Rohit Malhotra
f1b65d9534 Rename env name (#13570) 2026-03-24 16:38:49 +00:00
aivong-openhands
3516c3cdbe chore(deps): make pythonnet Windows-only dependency (#13515)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 11:21:25 -05:00
openhands
36ff8bcb9c fix: restore CI-critical settings PR files
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 10:39:05 +00:00
openhands
8d9841e95a chore: trim unrelated settings PR changes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 08:24:21 +00:00
openhands
0ca9528afe Require newer Poetry for builds
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 07:54:21 +00:00
openhands
78f877aca5 Address settings review feedback
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 06:46:39 +00:00
openhands
9e6f5bae9c Remove legacy org settings columns
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 06:30:43 +00:00
openhands
b104c35075 Fix enterprise org settings test expectations
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 06:05:20 +00:00
openhands
c1958bef4d Use canonical agent settings for enterprise orgs
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 05:44:23 +00:00
openhands
80dc1b9a38 Merge remote-tracking branch 'origin/main' into pr-13306 2026-03-24 05:21:33 +00:00
openhands
3df73ea5eb Merge main into settings schema branch
Resolve saas_settings_store key-generation conflict while keeping canonical snapshot settings handling.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 04:22:43 +00:00
Tim O'Farrell
1f275a7cfe fix: reuse db session in migrate_customer call causing FK violation (#13558)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 21:10:45 -06:00
openhands
1f88750ade Simplify org member settings snapshots
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 02:03:30 +00:00
openhands
c3341e3a0e Centralize BYOR secret handling on UserSettings
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 01:14:31 +00:00
openhands
14a234cdbe Use settings snapshots as enterprise source of truth
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 00:49:52 +00:00
openhands
806da849c5 enterprise: persist full org member settings 2026-03-23 23:29:36 +00:00
openhands
5a47e52176 settings: canonicalize SDK agent settings
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 23:06:36 +00:00
Tim O'Farrell
ff240c968b fix: add 30s timeout to LiteLlmManager HTTP client (#13557)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 21:43:02 +00:00
aivong-openhands
36039d2bb8 upgrade setuptools in /enterprise for updated wheel CVE-2026-24049 (#13509)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 16:37:20 -05:00
openhands
cdf0ac8421 enterprise: combine agent_settings migrations
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 21:30:19 +00:00
Tim O'Farrell
45529fa451 Added Falsy check for base url (#13553) 2026-03-23 13:06:25 -06:00
Tim O'Farrell
0fc4b0fb55 Add infinite scroll pagination and filesystem storage support to public share page (#13545)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 12:18:07 -06:00
Tim O'Farrell
810fc340fc Fix count endpoint 500 error (#13548)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 17:40:56 +00:00
Tim O'Farrell
33a0f95dac Small typo fix (#13546) 2026-03-23 15:36:17 +00:00
aivong-openhands
bdd0214266 chore: increase dependabot open-pull-requests-limit to 5 (#13538)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:28:32 -05:00
Saurya Velagapudi
7fbb499f03 feat: switch default base image to nikolaik slim variant (#13244)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:26:04 -05:00
aivong-openhands
abbfbda450 chore(frontend): update flatted to 3.4.2 (#13503)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 10:26:30 -04:00
openhands
1dd4d0fc9d Stabilize websocket sendMessage state reads
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 13:50:55 +00:00
openhands
c07a85aec0 Preserve hidden sentinel during settings serialization
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 13:32:33 +00:00
openhands
13f244e6e7 Merge origin/main into canonical settings migration branch
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 13:21:29 +00:00
openhands
5477028bc8 Fix canonical settings test coverage and secret masking
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 13:20:10 +00:00
openhands
e930a51f05 Use canonical agent_settings across OpenHands settings flows
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 12:40:14 +00:00
John-Mason P. Shackelford
7774f43ca1 feat(frontend): Add /launch route for starting conversations with plugins (#12699)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-03-23 15:06:42 +07:00
Vasco Schiavo
b705b015fa fix(frontend): rounded corners on diff viewer bottom in Changes tab (#13521) 2026-03-23 14:06:23 +07:00
Jathin Sreenivas
1581b95ab9 fix(frontend): Ensure error and status messages wrap correctly within containers (#13522)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
2026-03-23 13:55:49 +07:00
openhands
cf877b5628 Require newer Poetry in app image
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:31:26 +00:00
openhands
fb9958aff8 Normalize migration style
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:22:34 +00:00
openhands
c1f5861eaf Fix migration lint
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:16:37 +00:00
openhands
fa7f58b7c5 Consolidate enterprise user settings
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:08:45 +00:00
openhands
a691bec7fc fix(settings): keep persistence user-facing
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 23:08:16 +00:00
openhands
7eb77c131d Fix frozen settings normalization
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:54:38 +00:00
openhands
858870a095 Format enterprise settings changes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:42:57 +00:00
openhands
d65e5b5e46 Fix post-merge lint regressions
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:35:51 +00:00
openhands
2b0816f53a Merge main into settings schema PR
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:10:16 +00:00
openhands
ab9536dc6b Scope SaaS agent settings to member fields
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:37:24 +00:00
openhands
9f5888315a Harden SaaS settings schema migration
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:31:39 +00:00
openhands
fcefb872b6 Normalize canonical settings fields in frontend
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:13:31 +00:00
openhands
b91cd0570e Fix enterprise settings schema for preview auth
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 00:33:22 +00:00
aivong-openhands
94b45c6c36 PLTF-327: upgrade enterprise nodejs to v24 LTS (#13507)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 14:42:03 -05:00
dependabot[bot]
cbc380fe49 chore(deps): bump node from 25.2-trixie-slim to 25.8-trixie-slim in /containers/app (#13316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-20 14:40:23 -05:00
Vasco Schiavo
fb776ef650 feat(frontend): Add copy button to code blocks (#13458)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 18:20:25 +07:00
Abi
a75b576f1c fix: treat llm_base_url="" as explicit clear in store_llm_settings (#13471)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:14:15 +01:00
Rohit Malhotra
63956c3292 Fix FastAPI Query parameter validation: lte -> le (#13502)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 20:27:10 -04:00
chuckbutkus
f75141af3e fix: prevent secrets deletion across organizations when storing secrets (#13500)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 19:34:12 -04:00
openhands
e18775b391 fix: avoid websocket send fallback race in frontend tests
- send via the websocket hook's live ref-backed sender
- treat OPEN connection state as authoritative for socket delivery
- prevent flaky fallback to pending-message REST queue during sends

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

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

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 21:59:17 +00:00
dependabot[bot]
e4515b21eb chore(deps): bump socket.io-parser from 4.2.5 to 4.2.6 in /frontend in the security-all group across 1 directory (#13474)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 17:28:15 -04:00
aivong-openhands
a8f6a35341 fix: patch GLib CVE-2025-14087 in runtime Docker images (#13403)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 16:21:24 -05:00
Joe Laverty
f706a217d0 fix: Use commit SHA instead of mutable branch tag for enterprise base (#13498) 2026-03-19 16:24:07 -04:00
Graham Neubig
2890b8c6ff fix: default settings entry point to LLM page instead of Integrations
Home page 'Settings' buttons (ConnectToProviderMessage, task-suggestions)
linked to /settings/integrations. Change to /settings so users land on the
LLM settings page (first nav item in OSS mode).

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

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 04:57:50 +09:00
aivong-openhands
0137201903 fix: remove vulnerable VSCode extensions in build_from_scratch path (#13399)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-19 19:36:22 +00:00
aivong-openhands
49a98885ab chore: Update OpenSSL in Debian images for security patches (#13401)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 14:33:23 -05:00
Hiep Le
38648bddb3 fix(frontend): use correct git path based on sandbox grouping strategy (#13488) 2026-03-20 00:13:02 +07:00
Hiep Le
b44774d2be refactor(frontend): extract AddCreditsModal into separate component file (#13490) 2026-03-20 00:12:48 +07:00
Hiep Le
04330898b6 refactor(frontend): add delay before closing user context menu (#13491) 2026-03-20 00:12:38 +07:00
Chris Bagwell
120fd7516a Fix: Prevent auto-logout on 401 errors in oss mode (#13466) 2026-03-19 16:33:01 +01:00
chuckbutkus
2224127ac3 Fix when budgets are None (#13482)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 10:14:48 -05:00
aivong-openhands
2d1e9fa35b Fix CVE-2026-33123: Update pypdf to 6.9.1 (#13473)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-19 11:05:30 -04:00
MkDev11
0ec962e96b feat: add /clear endpoint for V1 conversations (#12786)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-19 21:13:58 +07:00
Engel Nyst
3a9f00aa37 Keep VSCode accessible when agent errors (#13492)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 14:46:56 +01:00
Hiep Le
e02dbb8974 fix(backend): validate API key org_id during authorization to prevent cross-org access (org project) (#13468) 2026-03-19 16:09:37 +07:00
Hiep Le
8039807c3f fix(frontend): scope organization data queries by organization ID (org project) (#13459) 2026-03-19 14:18:29 +07:00
openhands
cae7b6e72f chore: refresh SDK lockfile refs again
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:46:06 +00:00
openhands
7ca41486be chore: refresh SDK lockfile refs
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:39:41 +00:00
openhands
81c02623a1 merge: update settings schema branch with main and SDK
- merge latest main into the GUI settings schema branch
- keep schema-driven LLM settings page and tests after conflict resolution
- update lockfiles to SDK branch head c333aedd

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:35:30 +00:00
Saurya Velagapudi
a96760eea7 fix: ensure LiteLLM user exists before generating API keys (#12667)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:16:43 -07:00
Saurya Velagapudi
dcb2e21b87 feat: Auto-forward LLM_* env vars to agent-server and fix host network config (#13192)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:07:19 -07:00
Tim O'Farrell
7edebcbc0c fix: use atomic write in LocalFileStore to prevent race conditions (#13480)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-18 16:49:32 -06:00
HeyItsChloe
abd1f9948f fix: return empty skills list instead of 404 for stopped sandboxes (#13429)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:46:00 -06:00
aivong-openhands
2879e58781 Fix CVE-2026-30922: Update pyasn1 to 0.6.3 (#13452)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-18 16:00:06 -04:00
Rohit Malhotra
1d1ffc2be0 feat(enterprise): Add service API for automation API key creation (#13467)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 19:07:36 +00:00
Hiep Le
db41148396 feat(backend): expose API key org_id via new GET /api/keys/current endpoint (org project) (#13469) 2026-03-19 01:46:23 +07:00
Robert Brennan
39a4ca422f fix: use sentence case for 'Waiting for sandbox' text (#12958)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:42:46 -04:00
Varun Chawla
6d86803f41 Add loading feedback to git changes refresh button (#12792)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-19 01:26:27 +07:00
Jordi Mas
8e0386c416 feat: add Catalan translation (#13299) 2026-03-18 13:17:43 -04:00
Nelson Spence
48cd85e47e fix(security): add sleep to container wait loop (#12869)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:04:36 -04:00
不做了睡大觉
c62b47dcb1 fix: handle empty body in GitHub issue resolver (#13039)
Co-authored-by: User <user@example.com>
2026-03-18 12:36:52 -04:00
Jamie Chicago
eb9a822d4c Update CONTRIBUTING.md (#13463) 2026-03-18 12:10:22 -04:00
Engel Nyst
fb7333aa62 fix: stop calling agent-server /generate_title (#13093)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:10:07 +01:00
aivong-openhands
fb23418803 clarify docstring for provider token reference (#13386)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 12:03:56 -04:00
Xingyao Wang
991585c05d docs: add cross-repo testing skill for SDK ↔ OH Cloud e2e workflow (#13446)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 16:00:23 +00:00
Chris Bagwell
35a40ddee8 fix: handle containers with tagless images in DockerSandboxService (#13238) 2026-03-18 11:55:48 -04:00
Hiep Le
5d1f9f815a fix(frontend): preserve settings page route on browser refresh (org project) (#13462) 2026-03-18 22:50:42 +07:00
Hiep Le
d3bf989e77 feat(frontend): improve conversation access error message with workspace hint (org project) (#13461) 2026-03-18 22:50:30 +07:00
Hiep Le
6589e592e3 feat(frontend): add contextual info messages on LLM settings page (org project) (#13460) 2026-03-18 22:50:16 +07:00
Chris Bagwell
fe4c0569f7 Remove unused WORK_HOSTS_SKILL_FOOTER (#12594) 2026-03-18 21:57:23 +07:00
openhands
38dcf959bc fix: restore search_api_key check in hasAdvancedSettingsSet lost during merge
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:41:56 +00:00
openhands
ef3acf726c style: apply ruff-format fixes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:35:25 +00:00
openhands
017d758a76 fix: skip frozen fields when applying settings payload
The secrets_store field on Settings is frozen, so setattr() raised a
validation error when the POST /api/settings payload included that key.

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

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:04:46 +00:00
openhands
3ed37e18ac Merge main into openhands/issue-2228-gui-settings-schema
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:00:39 +00:00
Xingyao Wang
28ecf06404 Render V1 paired tool summaries (#13451)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 10:52:05 +00:00
dependabot[bot]
26fa1185a4 chore(deps): bump mcp from 1.25.0 to 1.26.0 in the mcp-packages group (#13314)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-17 17:44:35 -05:00
HeyItsChloe
d3a8b037f2 feat(frontend): home page cta (#13339)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-18 03:44:36 +07:00
HeyItsChloe
af1fa8961a feat(frontend): login page cta (#13337)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 03:14:59 +07:00
HeyItsChloe
3b215c4ad1 feat(frontend): context menu cta (#13338)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 02:52:02 +07:00
HeyItsChloe
7516b53f5a feat(frontend): self hosted new user questions (#13367)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-18 02:51:40 +07:00
aivong-openhands
855ef7ba5f PLTF-309: disable budget enforcement when ENABLE_BILLING=false (#13440)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 14:26:13 -05:00
Rohit Malhotra
09ca1b882f (Hotfix): use direct attrib for file download result (#13448) 2026-03-17 14:48:46 -04:00
Jamie Chicago
79cfffce60 docs: Improve Development.md and CONTRIBUTING.md with OS-specific setup guides (#13432)
Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 17:03:33 +01:00
Saurya Velagapudi
b68c75252d Add architecture diagrams explaining system components and WebSocket flow (#12542)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Saurya <saurya@openhands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-17 08:52:40 -07:00
aivong-openhands
d58e12ad74 Fix CVE-2026-27962: Update authlib to 1.6.9 (#13439)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-17 10:13:08 -05:00
Engel Nyst
bd837039dd chore: update skills path comments (#12794) 2026-03-17 10:45:50 -04:00
Kooltek68
8a7779068a docs: fix typo in README.md (#13444) 2026-03-17 10:16:31 -04:00
Neha Prasad
38099934b6 fix : planner PLAN.md rendering and search labels (#13418)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-17 20:59:02 +07:00
Xingyao Wang
75c823c486 feat: expose_secrets param on /users/me + sandbox-scoped secrets API (#13383)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 12:54:57 +00:00
openhands
1322f944be refactor: settings are transparent — no backend auto-fill
critic_server_url and critic_model_name are now user-facing settings
exposed in the GUI. The backend no longer mutates them based on proxy
detection. _get_agent_settings is now a pure pass-through with model
override only.

SDK pin: b69f7cee

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 22:49:37 +00:00
openhands
71d5aa5aa8 Merge remote-tracking branch 'origin/main' into openhands/issue-2228-gui-settings-schema 2026-03-16 22:44:22 +00:00
ankit kumar
59dd1da7d6 fix: update deprecated libtmux API calls (#12596)
Co-authored-by: ANKIT <ankit@ANKITs-MacBook-Air.local>
2026-03-16 18:21:05 -04:00
openhands
90d2681e34 fix: handle git-based SDK deps in enterprise Docker build
Strip git-based openhands SDK dependencies from the exported
requirements.txt in the enterprise Dockerfile. These packages are
already installed via the base app image and cannot have their hashes
verified by pip when using git branch references.

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

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 19:04:55 +00:00
Rohit Malhotra
934fbe93c2 Feat: enterprise banner option during device oauth (#13361)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 18:54:36 +00:00
openhands
4b9097068d chore: regenerate lockfiles for Docker build compatibility
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 18:50:50 +00:00
Xingyao Wang
55e4f07200 fix: add missing params to TestLoadHooksFromWorkspace setup (#13424)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 14:49:36 -04:00
openhands
c9a5834164 Merge main and resolve conflicts for SDK settings schema PR
- Resolved merge conflicts in 5 files keeping both PR and main changes
- Fixed TestLoadHooksFromWorkspace missing pending_message_service and
  max_num_conversations_per_sandbox constructor args
- Removed unused UUID import flagged by ruff

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

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 17:55:03 +00:00
Xingyao Wang
00daaa41d3 feat: Load workspace hooks for V1 conversations and add hooks viewer UI (#12773)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: Alona King <alona@all-hands.dev>
2026-03-17 00:55:23 +08:00
HeyItsChloe
a0e777503e fix(frontend): prevent auto sandbox resume behavior (#13133)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 10:22:23 -06:00
Hiep Le
238cab4d08 fix(frontend): prevent chat message loss during websocket disconnections or page refresh (#13380) 2026-03-16 22:25:44 +07:00
Tim O'Farrell
aec95ecf3b feat(frontend): update stop sandbox dialog to display conversations in sandbox (#13388)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 05:20:10 -06:00
Tim O'Farrell
d591b140c8 feat: Add configurable sandbox reuse with grouping strategies (#11922)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 05:19:31 -06:00
openhands
19a089aa4b Merge main and fix settings schema CI
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-15 19:51:27 +00:00
Rohit Malhotra
4dfcd68153 (Hotfix): followup messages for slack conversations (#13411)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-15 14:23:06 -04:00
aivong-openhands
f7ca32126f Fix CVE-2026-32597: Update pyjwt to 2.12.0 (#13405)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-14 09:35:56 -05:00
Hiep Le
c66a112bf5 fix(frontend): add rendering support for GlobObservation and GrepObservation events (#13379) 2026-03-14 19:56:57 +07:00
Ray Myers
a8ff720b40 chore: Update imagemagick in Debian images for security patches (#13397) 2026-03-13 22:48:50 -05:00
chuckbutkus
a14158e818 fix: use query params for file upload path (#13376)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 21:08:23 -04:00
John-Mason P. Shackelford
0c51089ab6 Upgrade the SDK to 1.14.0 (#13398)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 21:07:20 -04:00
chuckbutkus
8189d21445 Fix async call to await return (#13395) 2026-03-13 19:13:18 -04:00
chuckbutkus
b7e5c9d25b Use a flag to indicate if new users should use V1 (#13393)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 22:39:07 +00:00
chuckbutkus
873dc6628f Add Enterprise SSO login button to V1 login page (#13390)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 16:57:34 -04:00
chuckbutkus
f5d0af15d9 Add default initial budget for teams/users (#13389)
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 16:57:03 -04:00
chuckbutkus
922e3a2431 Add AwsSharedEventService for shared conversations (#13141)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 14:32:58 -04:00
Tim O'Farrell
0527c46bba Add sandbox_id__eq filter to AppConversationService search and count methods (#13387)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 11:24:58 -06:00
Hiep Le
b4f00379b8 fix(frontend): auto-scroll not working in Planner tab when plan content updates (#13355) 2026-03-13 23:47:03 +07:00
sp.wack
cd2d0ee9a5 feat(frontend): Organizational support (#9496)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: Abhay Mishra <grabhaymishra@gmail.com>
Co-authored-by: Hyun Han <62870362+smosco@users.noreply.github.com>
Co-authored-by: Nhan Nguyen <nhan13574@gmail.com>
Co-authored-by: Bharath A V <avbharath1221@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: Chloe <chloe@openhands.com>
Co-authored-by: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com>
2026-03-13 23:38:54 +07:00
Tim O'Farrell
8e6d05fc3a Add sandbox_id__eq filter parameter to search/count conversation methods (#13385)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 15:30:16 +00:00
Hiep Le
9d82f97a82 fix(frontend): address the responsive issue on the integrations page (#13354) 2026-03-13 21:28:38 +07:00
Hiep Le
2c7b25ab1c fix(frontend): address the responsive issue on the home page (#13353) 2026-03-13 21:28:15 +07:00
openhands
918c44d164 Merge main and align settings schema with latest SDK
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 13:40:07 +00:00
aivong-openhands
e82bf44324 Fix CVE-2025-67221: Update orjson to 3.11.6+ (#13371)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-13 06:58:56 -05:00
Xingyao Wang
8799c07027 fix: add PR creation instructions to V1 issue comment template and fix summary prompt (#13377)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 14:35:22 +08:00
Tim O'Farrell
8b8ed5be96 fix: Revert on_conversation_update to load conversation inside method (#13368)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 19:08:04 -06:00
Tim O'Farrell
c1328f512d Upgrade the SDK to 1.13.0 (#13365) 2026-03-12 13:28:19 -06:00
Tim O'Farrell
e2805dea75 Fix pagination bug in event_service_base.search_events causing duplicate events in exports (#13364)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 12:24:06 -06:00
aivong-openhands
127e611706 Fix GHSA-78cv-mqj4-43f7: Update tornado to 6.5.5 (#13362)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-12 13:22:39 -05:00
Hiep Le
a176a135da fix: sdk conversations not appearing in cloud ui (#13296) 2026-03-12 22:23:08 +07:00
Tim O'Farrell
ab78d7d6e8 fix: Set correct user context in webhook callbacks based on sandbox owner (#13340)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 09:11:35 -06:00
mamoodi
4eb6e4da09 Release 1.5.0 (#13336) 2026-03-11 14:50:13 -04:00
dependabot[bot]
7e66304746 chore(deps): bump pypdf from 6.7.5 to 6.8.0 (#13348)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 12:09:09 -05:00
Graham Neubig
a8b12e8eb8 Remove Common Room sync scripts (#13347)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 10:48:37 -04:00
Xingyao Wang
53bb82fe2e fix: use project_dir consistently for workspace.working_dir, setup.sh, and git hooks (#13329)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 15:26:34 +08:00
Tim O'Farrell
db40eb1e94 Using the web_url where it is configured rather than the request.url (#13319)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-10 13:11:33 -06:00
Hiep Le
debbaae385 fix(backend): inherit organization llm settings for new members (#13330) 2026-03-11 01:28:46 +07:00
openhands
e06e20a5ba fix: refresh SDK locks and settings schema coverage
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 17:55:59 +00:00
openhands
430ee1c9fd Point OpenHands dependencies at the SDK branch
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 01:32:57 +00:00
openhands
a03377698c Consume SDK AgentSettings schema in OpenHands
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 01:18:53 +00:00
openhands
9dab5b1bbf test: stub SDK schema in settings API coverage (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 21:43:35 +00:00
openhands
135d5fbd38 settings: fix schema-driven settings follow-ups (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 21:09:10 +00:00
openhands
ad615ebc8b settings: use generic sdk settings values in OpenHands
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 20:42:35 +00:00
openhands
424f6b30d1 settings: expose SDK settings schema to OpenHands (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 20:10:48 +00:00
557 changed files with 51865 additions and 10417 deletions

View File

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

View File

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

View File

@@ -40,11 +40,11 @@ jobs:
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
@@ -219,11 +219,9 @@ jobs:
- name: Determine app image tag
shell: bash
run: |
# Duplicated with build.sh
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
# Use the commit SHA to pin the exact app image built by ghcr_build_app,
# rather than a mutable branch tag like "main" which can serve stale cached layers.
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
with:

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

@@ -0,0 +1,136 @@
---
name: PR Artifacts
on:
workflow_dispatch: # Manual trigger for testing
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
pull_request_review:
types: [submitted]
jobs:
# Auto-remove .pr/ directory when a reviewer approves
cleanup-on-approval:
concurrency:
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
cancel-in-progress: false
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check if fork PR
id: check-fork
run: |
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
echo "is_fork=true" >> $GITHUB_OUTPUT
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
else
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@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
View File

@@ -234,6 +234,8 @@ yarn-error.log*
logs
ralph/
# agent
.envrc
/workspace

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.2-trixie-slim AS frontend-builder
FROM node:25.8-trixie-slim AS frontend-builder
WORKDIR /app
@@ -22,7 +22,7 @@ ENV POETRY_NO_INTERACTION=1 \
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential jq gettext \
&& python3 -m pip install poetry --break-system-packages
&& python3 -m pip install "poetry>=2.3.0" --break-system-packages
COPY pyproject.toml poetry.lock ./
RUN touch README.md

View File

@@ -10,7 +10,7 @@ LABEL com.datadoghq.tags.env="${DD_ENV}"
# Apply security updates to fix CVEs
RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
apt-get install -y nodejs && \
apt-get install -y jq gettext && \
# Apply security updates for packages with available fixes
@@ -33,7 +33,8 @@ RUN cd /tmp/enterprise && \
# Export only main dependencies with hashes for supply chain security
/app/.venv/bin/poetry export --only main -o requirements.txt && \
# Remove the local path dependency (openhands-ai is already in base image)
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
# and git-based SDK dependencies (already installed via the base app image)
sed -i '/^-e /d; /openhands-ai/d; /^openhands-.*@ git+/d' requirements.txt && \
# Install pinned dependencies from lock file
/app/.venv/bin/pip install -r requirements.txt && \
# Cleanup - return to /app before removing /tmp/enterprise

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,16 +106,18 @@ async def summarize_issue_solvability(
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
)
if user_settings.llm_api_key is None:
agent_settings = user_settings.agent_settings
llm_settings = agent_settings.llm
if llm_settings.api_key is None:
raise ValueError(
f'[Solvability] No LLM API key found for user {github_view.user_info.user_id}'
)
try:
llm_config = LLMConfig(
model=user_settings.llm_model,
api_key=user_settings.llm_api_key.get_secret_value(),
base_url=user_settings.llm_base_url,
model=llm_settings.model,
api_key=llm_settings.api_key.get_secret_value(),
base_url=llm_settings.base_url,
)
except ValidationError as e:
raise ValueError(

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,27 +100,25 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
return bool(payment_methods.data)
async def migrate_customer(user_id: str, org: Org):
async with a_session_maker() as session:
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
async def migrate_customer(session, user_id: str, org: Org):
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
await session.commit()
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)

View File

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

View File

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

View File

@@ -0,0 +1,296 @@
"""Add agent_settings columns to enterprise settings tables.
Revision ID: 102
Revises: 101
Create Date: 2026-03-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '102'
down_revision: Union[str, None] = '101'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_EMPTY_JSON = sa.text("'{}'::json")
def upgrade() -> None:
op.add_column(
'user_settings',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'org_member',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'org',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.execute(
sa.text(
"""
UPDATE user_settings
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'agent', agent,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'verification.confirmation_mode', confirmation_mode,
'verification.security_analyzer', security_analyzer,
'condenser.enabled', enable_default_condenser,
'condenser.max_size', condenser_max_size,
'max_iterations', max_iterations
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.execute(
sa.text(
"""
UPDATE org_member
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'max_iterations', max_iterations
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.execute(
sa.text(
"""
UPDATE org
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'agent', agent,
'llm.model', default_llm_model,
'llm.base_url', default_llm_base_url,
'verification.confirmation_mode', confirmation_mode,
'verification.security_analyzer', security_analyzer,
'condenser.enabled', enable_default_condenser,
'condenser.max_size', condenser_max_size,
'max_iterations', default_max_iterations,
'mcp_config', mcp_config
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.execute(
sa.text(
"""
UPDATE user_settings AS us
SET llm_api_key_for_byor = om._llm_api_key_for_byor
FROM "user" AS u
JOIN org_member AS om
ON om.user_id = u.id
AND om.org_id = u.current_org_id
WHERE us.keycloak_user_id = u.id::text
AND us.llm_api_key_for_byor IS NULL
AND om._llm_api_key_for_byor IS NOT NULL
"""
)
)
op.execute(
sa.text(
"""
INSERT INTO user_settings (keycloak_user_id, llm_api_key_for_byor, agent_settings)
SELECT u.id::text, om._llm_api_key_for_byor, '{}'::json
FROM "user" AS u
JOIN org_member AS om
ON om.user_id = u.id
AND om.org_id = u.current_org_id
LEFT JOIN user_settings AS us
ON us.keycloak_user_id = u.id::text
WHERE us.id IS NULL
AND om._llm_api_key_for_byor IS NOT NULL
"""
)
)
op.alter_column('user_settings', 'agent_settings', server_default=None)
op.alter_column('org_member', 'agent_settings', server_default=None)
op.alter_column('org', 'agent_settings', server_default=None)
op.drop_column('user_settings', 'agent')
op.drop_column('user_settings', 'max_iterations')
op.drop_column('user_settings', 'security_analyzer')
op.drop_column('user_settings', 'confirmation_mode')
op.drop_column('user_settings', 'llm_model')
op.drop_column('user_settings', 'llm_base_url')
op.drop_column('user_settings', 'enable_default_condenser')
op.drop_column('user_settings', 'condenser_max_size')
op.drop_column('org_member', 'max_iterations')
op.drop_column('org_member', 'llm_model')
op.drop_column('org_member', '_llm_api_key_for_byor')
op.drop_column('org_member', 'llm_base_url')
op.drop_column('org', 'agent')
op.drop_column('org', 'default_max_iterations')
op.drop_column('org', 'security_analyzer')
op.drop_column('org', 'confirmation_mode')
op.drop_column('org', 'default_llm_model')
op.drop_column('org', 'default_llm_base_url')
op.drop_column('org', 'enable_default_condenser')
op.drop_column('org', 'mcp_config')
op.drop_column('org', 'condenser_max_size')
def downgrade() -> None:
op.add_column('user_settings', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('security_analyzer', sa.String(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('confirmation_mode', sa.Boolean(), nullable=True)
)
op.add_column('user_settings', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('llm_base_url', sa.String(), nullable=True)
)
op.add_column(
'user_settings',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column(
'user_settings', sa.Column('condenser_max_size', sa.Integer(), nullable=True)
)
op.add_column('org_member', sa.Column('llm_base_url', sa.String(), nullable=True))
op.add_column(
'org_member', sa.Column('_llm_api_key_for_byor', sa.String(), nullable=True)
)
op.add_column('org_member', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'org_member', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'org', sa.Column('default_max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org', sa.Column('security_analyzer', sa.String(), nullable=True))
op.add_column('org', sa.Column('confirmation_mode', sa.Boolean(), nullable=True))
op.add_column('org', sa.Column('default_llm_model', sa.String(), nullable=True))
op.add_column('org', sa.Column('default_llm_base_url', sa.String(), nullable=True))
op.add_column(
'org',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
op.execute(
sa.text(
"""
UPDATE user_settings
SET
agent = agent_settings ->> 'agent',
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer,
security_analyzer =
agent_settings ->> 'verification.security_analyzer',
confirmation_mode = CASE
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
ELSE NULL
END,
llm_model = agent_settings ->> 'llm.model',
llm_base_url = agent_settings ->> 'llm.base_url',
enable_default_condenser = CASE
WHEN agent_settings::jsonb ? 'condenser.enabled'
THEN (agent_settings ->> 'condenser.enabled')::boolean
ELSE TRUE
END,
condenser_max_size =
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
"""
)
)
op.execute(
sa.text(
"""
UPDATE org_member
SET
llm_model = agent_settings ->> 'llm.model',
llm_base_url = agent_settings ->> 'llm.base_url',
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer
"""
)
)
op.execute(
sa.text(
"""
UPDATE org
SET
agent = agent_settings ->> 'agent',
default_max_iterations =
NULLIF(agent_settings ->> 'max_iterations', '')::integer,
security_analyzer =
agent_settings ->> 'verification.security_analyzer',
confirmation_mode = CASE
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
ELSE NULL
END,
default_llm_model = agent_settings ->> 'llm.model',
default_llm_base_url = agent_settings ->> 'llm.base_url',
enable_default_condenser = CASE
WHEN agent_settings::jsonb ? 'condenser.enabled'
THEN (agent_settings ->> 'condenser.enabled')::boolean
ELSE TRUE
END,
mcp_config = agent_settings -> 'mcp_config',
condenser_max_size =
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
"""
)
)
op.execute(
sa.text(
"""
UPDATE org_member AS om
SET _llm_api_key_for_byor = us.llm_api_key_for_byor
FROM "user" AS u
JOIN user_settings AS us
ON us.keycloak_user_id = u.id::text
WHERE om.user_id = u.id
AND om.org_id = u.current_org_id
AND us.llm_api_key_for_byor IS NOT NULL
"""
)
)
op.drop_column('org', 'agent_settings')
op.drop_column('org_member', 'agent_settings')
op.drop_column('user_settings', 'agent_settings')

View File

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

View File

@@ -0,0 +1,41 @@
"""Add mcp_config to org_member for user-specific MCP settings.
Revision ID: 103
Revises: 102
Create Date: 2026-03-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '103'
down_revision: Union[str, None] = '102'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
# Migrate existing org-level MCP configs to all members in each org.
# This preserves existing configurations while transitioning to user-specific settings.
conn = op.get_bind()
orgs_with_config = conn.execute(
sa.text('SELECT id, mcp_config FROM org WHERE mcp_config IS NOT NULL')
).fetchall()
for org_id, mcp_config in orgs_with_config:
conn.execute(
sa.text(
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
),
{'config': mcp_config, 'org_id': str(org_id)},
)
def downgrade() -> None:
op.drop_column('org_member', 'mcp_config')

3984
enterprise/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,7 @@ pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
flake8 = "*"
freezegun = "^1.5.1"
openai = "*"
opencv-python = "*"
pandas = "*"

View File

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

View File

@@ -35,7 +35,7 @@ Usage:
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
@@ -214,6 +214,19 @@ def has_permission(user_role: Role, permission: Permission) -> bool:
return permission in permissions
async def get_api_key_org_id_from_request(request: Request) -> UUID | None:
"""Get the org_id bound to the API key used for authentication.
Returns None if:
- Not authenticated via API key (cookie auth)
- API key is a legacy key without org binding
"""
user_auth = getattr(request.state, 'user_auth', None)
if user_auth and hasattr(user_auth, 'get_api_key_org_id'):
return user_auth.get_api_key_org_id()
return None
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
@@ -221,8 +234,9 @@ def require_permission(permission: Permission):
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
3. Validates API key org binding (if using API key auth)
4. Checks if the user has the required permission in the organization
5. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
@@ -240,6 +254,7 @@ def require_permission(permission: Permission):
"""
async def permission_checker(
request: Request,
org_id: UUID | None = None,
user_id: str | None = Depends(get_user_id),
) -> str:
@@ -249,6 +264,23 @@ def require_permission(permission: Permission):
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None and org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
user_role = await get_user_org_role(user_id, org_id)
if not user_role:

View File

@@ -1,6 +1,7 @@
import time
from dataclasses import dataclass
from types import MappingProxyType
from uuid import UUID
import jwt
from fastapi import Request
@@ -59,6 +60,19 @@ class SaasUserAuth(UserAuth):
_secrets: Secrets | None = None
accepted_tos: bool | None = None
auth_type: AuthType = AuthType.COOKIE
# API key context fields - populated when authenticated via API key
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
Returns:
The org_id if authenticated via API key with org binding, None otherwise
(cookie auth or legacy API keys without org binding).
"""
return self.api_key_org_id
async def get_user_id(self) -> str | None:
return self.user_id
@@ -283,14 +297,19 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
return None
api_key_store = ApiKeyStore.get_instance()
user_id = await api_key_store.validate_api_key(api_key)
if not user_id:
validation_result = await api_key_store.validate_api_key(api_key)
if not validation_result:
return None
offline_token = await token_manager.load_offline_token(user_id)
offline_token = await token_manager.load_offline_token(
validation_result.user_id
)
saas_user_auth = SaasUserAuth(
user_id=user_id,
user_id=validation_result.user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
api_key_org_id=validation_result.org_id,
api_key_id=validation_result.key_id,
api_key_name=validation_result.key_name,
)
await saas_user_auth.refresh()
return saas_user_auth

View File

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

View File

@@ -84,6 +84,8 @@ def setup_json_logger(
style='{',
rename_fields={'levelname': 'severity'},
json_serializer=custom_json_serializer,
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)
timestamp='ts' if not LOG_JSON_FOR_CONSOLE else False,
)
handler.setFormatter(formatter)

View File

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

View File

@@ -1,55 +1,49 @@
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 sqlalchemy import select
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
from storage.database import a_session_maker
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.org_service import OrgService
from storage.user_settings import UserSettings
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
async def get_byor_key_from_db(user_id: str) -> str | None:
"""Get the BYOR key from the database for a user."""
user = await UserStore.get_user_by_id(user_id)
if not user:
return None
async with a_session_maker() as session:
result = await session.execute(
select(UserSettings).filter(UserSettings.keycloak_user_id == user_id)
)
user_settings = result.scalars().first()
current_org_id = user.current_org_id
current_org_member: OrgMember | None = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
if current_org_member.llm_api_key_for_byor:
return current_org_member.llm_api_key_for_byor.get_secret_value()
return None
byor_key = user_settings.llm_api_key_for_byor_secret if user_settings else None
return byor_key.get_secret_value() if byor_key else None
async def store_byor_key_in_db(user_id: str, key: str) -> None:
"""Store the BYOR key in the database for a user."""
user = await UserStore.get_user_by_id(user_id)
if not user:
return None
async with a_session_maker() as session:
result = await session.execute(
select(UserSettings).filter(UserSettings.keycloak_user_id == user_id)
)
user_settings = result.scalars().first()
if not user_settings:
user_settings = UserSettings(keycloak_user_id=user_id)
session.add(user_settings)
current_org_id = user.current_org_id
current_org_member: OrgMember | None = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
current_org_member.llm_api_key_for_byor = key
await OrgMemberStore.update_org_member(current_org_member)
user_settings.llm_api_key_for_byor_secret = key
await session.commit()
async def generate_byor_key(user_id: str) -> str | None:
@@ -150,6 +144,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 +266,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),

View File

@@ -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
(
@@ -208,6 +172,23 @@ async def keycloak_callback(
authorization = await user_authorizer.authorize_user(user_info)
if not authorization.success:
# For duplicate_email errors, clean up the newly created Keycloak user
# (only if they're not already in our UserStore, i.e., they're a new user)
if authorization.error_detail == 'duplicate_email':
try:
existing_user = await UserStore.get_user_by_id(user_info.sub)
if not existing_user:
# New user created during OAuth should be deleted from Keycloak
await token_manager.delete_keycloak_user(user_info.sub)
logger.info(
f'Deleted orphaned Keycloak user {user_info.sub} '
'after duplicate_email rejection'
)
except Exception as e:
# Log but don't fail - user should still get 401 response
logger.warning(
f'Failed to clean up orphaned Keycloak user {user_info.sub}: {e}'
)
# Return unauthorized
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -313,7 +294,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 +457,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 +489,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 +513,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 +542,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 +567,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 +598,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 +615,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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from typing import Annotated
from typing import Annotated, Any
from pydantic import (
BaseModel,
@@ -8,6 +8,10 @@ from pydantic import (
StringConstraints,
field_validator,
)
from storage.agent_settings_utils import (
get_org_agent_settings,
get_org_member_agent_settings,
)
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
@@ -144,21 +148,13 @@ class OrgResponse(BaseModel):
contact_name: str
contact_email: str
conversation_expiration: int | None = None
agent: str | None = None
default_max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
enable_default_condenser: bool = True
billing_margin: float | None = None
enable_proactive_conversation_starters: bool = True
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
org_version: int = 0
mcp_config: dict | None = None
agent_settings: dict[str, Any] = Field(default_factory=dict)
search_api_key: str | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = None
@@ -171,33 +167,14 @@ class OrgResponse(BaseModel):
def from_org(
cls, org: Org, credits: float | None = None, user_id: str | None = None
) -> 'OrgResponse':
"""Create an OrgResponse from an Org entity.
Args:
org: The organization entity to convert
credits: Optional credits value (defaults to None)
user_id: Optional user ID to determine if org is personal (defaults to None)
Returns:
OrgResponse: The response model instance
"""
"""Create an OrgResponse from an Org entity."""
return cls(
id=str(org.id),
name=org.name,
contact_name=org.contact_name,
contact_email=org.contact_email,
conversation_expiration=org.conversation_expiration,
agent=org.agent,
default_max_iterations=org.default_max_iterations,
security_analyzer=org.security_analyzer,
confirmation_mode=org.confirmation_mode,
default_llm_model=org.default_llm_model,
default_llm_api_key_for_byor=None,
default_llm_base_url=org.default_llm_base_url,
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
billing_margin=org.billing_margin,
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
if org.enable_proactive_conversation_starters is not None
@@ -205,7 +182,7 @@ class OrgResponse(BaseModel):
sandbox_base_container_image=org.sandbox_base_container_image,
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
org_version=org.org_version if org.org_version is not None else 0,
mcp_config=org.mcp_config,
agent_settings=get_org_agent_settings(org),
search_api_key=None,
sandbox_api_key=None,
max_budget_per_task=org.max_budget_per_task,
@@ -227,7 +204,6 @@ class OrgPage(BaseModel):
class OrgUpdate(BaseModel):
"""Request model for updating an organization."""
# Basic organization information (any authenticated user can update)
name: Annotated[
str | None,
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
@@ -235,42 +211,24 @@ class OrgUpdate(BaseModel):
contact_name: str | None = None
contact_email: EmailStr | None = None
conversation_expiration: int | None = None
default_max_iterations: int | None = Field(default=None, gt=0)
remote_runtime_resource_factor: int | None = Field(default=None, gt=0)
billing_margin: float | None = Field(default=None, ge=0, le=1)
enable_proactive_conversation_starters: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
mcp_config: dict | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = Field(default=None, gt=0)
enable_solvability_analysis: bool | None = None
v1_enabled: bool | None = None
# LLM settings (require admin/owner role)
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
search_api_key: str | None = None
security_analyzer: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
agent_settings: dict[str, Any] | None = None
class OrgLLMSettingsResponse(BaseModel):
"""Response model for organization LLM settings."""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
agent_settings: dict[str, Any] = Field(default_factory=dict)
search_api_key: str | None = None # Masked in response
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool = True
condenser_max_size: int | None = None
default_max_iterations: int | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str | None:
@@ -288,83 +246,45 @@ class OrgLLMSettingsResponse(BaseModel):
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
"""Create response from Org entity."""
return cls(
default_llm_model=org.default_llm_model,
default_llm_base_url=org.default_llm_base_url,
agent_settings=get_org_agent_settings(org),
search_api_key=cls._mask_key(org.search_api_key),
agent=org.agent,
confirmation_mode=org.confirmation_mode,
security_analyzer=org.security_analyzer,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
condenser_max_size=org.condenser_max_size,
default_max_iterations=org.default_max_iterations,
)
class OrgMemberLLMSettings(BaseModel):
"""LLM settings to propagate to organization members.
"""Shared LLM settings that may be propagated to organization members."""
Field names match OrgMember DB columns.
"""
llm_model: str | None = None
llm_base_url: str | None = None
max_iterations: int | None = None
agent_settings: dict[str, Any] | None = None
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
return any(
getattr(self, field) is not None for field in type(self).model_fields
)
class OrgLLMSettingsUpdate(BaseModel):
"""Request model for updating organization LLM settings.
"""Request model for updating organization LLM settings."""
Field names match Org DB columns exactly.
"""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
agent_settings: dict[str, Any] | None = None
search_api_key: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
default_max_iterations: int | None = Field(default=None, gt=0)
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
return any(
getattr(self, field) is not None for field in type(self).model_fields
)
def apply_to_org(self, org: Org) -> None:
"""Apply non-None settings to the organization model.
Args:
org: Organization entity to update in place
"""
for field_name in self.model_fields:
value = getattr(self, field_name)
# Skip llm_api_key - it's only for member propagation, not org-level
if value is not None and field_name != 'llm_api_key':
setattr(org, field_name, value)
"""Apply non-None settings to the organization model."""
if self.search_api_key is not None:
org.search_api_key = self.search_api_key
def get_member_updates(self) -> OrgMemberLLMSettings | None:
"""Get updates that need to be propagated to org members.
Returns:
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
default_max_iterations → max_iterations, llm_api_key → llm_api_key
"""
member_settings = OrgMemberLLMSettings(
llm_model=self.default_llm_model,
llm_base_url=self.default_llm_base_url,
max_iterations=self.default_max_iterations,
llm_api_key=self.llm_api_key,
)
"""Get updates that need to be propagated to org members."""
member_settings = OrgMemberLLMSettings(llm_api_key=self.llm_api_key)
return member_settings if member_settings.has_updates() else None
@@ -401,18 +321,15 @@ class MeResponse(BaseModel):
email: str
role: str
llm_api_key: str
max_iterations: int | None = None
llm_model: str | None = None
llm_api_key_for_byor: str | None = None
llm_base_url: str | None = None
agent_settings: dict[str, Any] = Field(default_factory=dict)
status: str | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str:
def _mask_key(secret: str | SecretStr | None) -> str:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return ''
raw = secret.get_secret_value()
raw = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
if not raw:
return ''
if len(raw) <= 4:
@@ -420,27 +337,20 @@ class MeResponse(BaseModel):
return '****' + raw[-4:]
@classmethod
def from_org_member(cls, member: OrgMember, role: Role, email: str) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email.
Args:
member: The OrgMember entity
role: The Role entity (provides role name)
email: The user's email address
Returns:
MeResponse with masked API keys
"""
def from_org_member(
cls,
member: OrgMember,
role: Role,
email: str,
) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email."""
return cls(
org_id=str(member.org_id),
user_id=str(member.user_id),
email=email,
role=role.name,
llm_api_key=cls._mask_key(member.llm_api_key),
max_iterations=member.max_iterations,
llm_model=member.llm_model,
llm_api_key_for_byor=cls._mask_key(member.llm_api_key_for_byor) or None,
llm_base_url=member.llm_base_url,
agent_settings=get_org_member_agent_settings(member),
status=member.status,
)

View File

@@ -68,7 +68,7 @@ async def list_user_orgs(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
Query(title='The max number of results in the page', gt=0, le=100),
] = 100,
user_id: str = Depends(get_user_id),
) -> OrgPage:
@@ -734,7 +734,7 @@ async def get_org_members(
Query(
title='The max number of results in the page',
gt=0,
lte=100,
le=100,
),
] = 10,
email: Annotated[

View 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_KEY environment variable.
"""
import os
from uuid import UUID
from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel, field_validator
from storage.api_key_store import ApiKeyStore
from storage.org_member_store import OrgMemberStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
# Environment variable for the service API key
AUTOMATIONS_SERVICE_KEY = os.getenv('AUTOMATIONS_SERVICE_KEY', '').strip()
service_router = APIRouter(prefix='/api/service', tags=['Service'])
class CreateUserApiKeyRequest(BaseModel):
"""Request model for creating an API key on behalf of a user."""
name: str # Required - used to identify the key
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('name is required and cannot be empty')
return v.strip()
class CreateUserApiKeyResponse(BaseModel):
"""Response model for created API key."""
key: str
user_id: str
org_id: str
name: str
class ServiceInfoResponse(BaseModel):
"""Response model for service info endpoint."""
service: str
authenticated: bool
async def validate_service_api_key(
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> str:
"""
Validate the service API key from the request header.
Args:
x_service_api_key: The service API key from the X-Service-API-Key header
Returns:
str: Service identifier for audit logging
Raises:
HTTPException: 401 if key is missing or invalid
HTTPException: 503 if service auth is not configured
"""
if not AUTOMATIONS_SERVICE_KEY:
logger.warning(
'Service authentication not configured (AUTOMATIONS_SERVICE_KEY not set)'
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Service authentication not configured',
)
if not x_service_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='X-Service-API-Key header is required',
)
if x_service_api_key != AUTOMATIONS_SERVICE_KEY:
logger.warning('Invalid service API key attempted')
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid service API key',
)
return 'automations-service'
@service_router.get('/health')
async def service_health() -> dict:
"""Health check endpoint for the service API.
This endpoint does not require authentication and can be used
to verify the service routes are accessible.
"""
return {
'status': 'ok',
'service_auth_configured': bool(AUTOMATIONS_SERVICE_KEY),
}
@service_router.post('/users/{user_id}/orgs/{org_id}/api-keys')
async def get_or_create_api_key_for_user(
user_id: str,
org_id: UUID,
request: CreateUserApiKeyRequest,
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> CreateUserApiKeyResponse:
"""
Get or create an API key for a user on behalf of the automations service.
If a key with the given name already exists for the user/org and is not expired,
returns the existing key. Otherwise, creates a new key.
The created/returned keys are system keys and are:
- Not visible to the user in their API keys list
- Not deletable by the user
- Never expire
Args:
user_id: The user ID
org_id: The organization ID
request: Request body containing name (required)
x_service_api_key: Service API key header for authentication
Returns:
CreateUserApiKeyResponse: The API key and metadata
Raises:
HTTPException: 401 if service key is invalid
HTTPException: 404 if user not found
HTTPException: 403 if user is not a member of the specified org
"""
# Validate service API key
service_id = await validate_service_api_key(x_service_api_key)
# Verify user exists
user = await UserStore.get_user_by_id(user_id)
if not user:
logger.warning(
'Service attempted to create key for non-existent user',
extra={'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'User {user_id} not found',
)
# Verify user is a member of the specified org
org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id))
if not org_member:
logger.warning(
'Service attempted to create key for user not in org',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'User {user_id} is not a member of org {org_id}',
)
# Get or create the system API key
api_key_store = ApiKeyStore.get_instance()
try:
api_key = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name=request.name,
)
except Exception as e:
logger.exception(
'Failed to get or create system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to get or create API key',
)
logger.info(
'Service created API key for user',
extra={
'service_id': service_id,
'user_id': user_id,
'org_id': str(org_id),
'key_name': request.name,
},
)
return CreateUserApiKeyResponse(
key=api_key,
user_id=user_id,
org_id=str(org_id),
name=request.name,
)
@service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}')
async def delete_user_api_key(
user_id: str,
org_id: UUID,
key_name: str,
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> dict:
"""
Delete a system API key created by the service.
This endpoint allows the automations service to clean up API keys
it previously created for users.
Args:
user_id: The user ID
org_id: The organization ID
key_name: The name of the key to delete (without __SYSTEM__: prefix)
x_service_api_key: Service API key header for authentication
Returns:
dict: Success message
Raises:
HTTPException: 401 if service key is invalid
HTTPException: 404 if key not found
"""
# Validate service API key
service_id = await validate_service_api_key(x_service_api_key)
api_key_store = ApiKeyStore.get_instance()
# Delete the key by name (wrap with system key prefix since service creates system keys)
system_key_name = api_key_store.make_system_key_name(key_name)
success = await api_key_store.delete_api_key_by_name(
user_id=user_id,
org_id=org_id,
name=system_key_name,
allow_system=True,
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}',
)
logger.info(
'Service deleted API key for user',
extra={
'service_id': service_id,
'user_id': user_id,
'org_id': str(org_id),
'key_name': key_name,
},
)
return {'message': 'API key deleted successfully'}

View File

@@ -522,10 +522,9 @@ class SaasNestedConversationManager(ConversationManager):
mcp_config = await self._get_mcp_config(user_id)
if mcp_config:
# Merge with any MCP config from settings
if settings.mcp_config:
mcp_config = mcp_config.merge(settings.mcp_config)
# Check again since theoretically merge could return None.
settings_mcp_config = settings.to_legacy_mcp_config()
if settings_mcp_config:
mcp_config = mcp_config.merge(settings_mcp_config)
if mcp_config:
init_conversation['mcp_config'] = mcp_config.model_dump()
@@ -855,7 +854,7 @@ class SaasNestedConversationManager(ConversationManager):
user_id=user_id,
)
llm_registry.retry_listner = session._notify_on_llm_retry
agent_cls = settings.agent or self.config.default_agent
agent_cls = settings.agent_settings.agent or self.config.default_agent
agent_config = self.config.get_agent_config(agent_cls)
agent = Agent.get_cls(agent_cls)(agent_config, llm_registry)

View File

@@ -365,17 +365,15 @@ 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
# 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')
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)
# Get the llm_api_key as string (it's SecretStr | None in Settings)
# Step 5: Add user to organization with inherited org LLM settings
llm_api_key_secret = settings.get_secret_agent_setting('llm.api_key')
llm_api_key = (
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
llm_api_key_secret.get_secret_value() if llm_api_key_secret else ''
)
await OrgMemberStore.add_user_to_org(
@@ -384,6 +382,7 @@ class OrgInvitationService:
role_id=invitation.role_id,
llm_api_key=llm_api_key,
status='active',
agent_settings=OrgStore.get_agent_settings_from_org(org),
)
# Step 6: Mark invitation as accepted

View File

@@ -55,7 +55,6 @@ class OrgMemberService:
if role is None:
raise RoleNotFoundError(org_member.role_id)
# Get user email
user = await UserStore.get_user_by_id(str(user_id))
email = user.email if user and user.email else ''

View 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

View File

@@ -0,0 +1,143 @@
"""Implementation of SharedEventService.
This implementation provides read-only access to events from shared conversations:
- Validates that the conversation is shared before returning events
- Uses existing EventService for actual event retrieval
- Uses SharedConversationInfoService for shared conversation validation
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoService,
)
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.config import get_global_config
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event.filesystem_event_service import FilesystemEventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
logger = logging.getLogger(__name__)
@dataclass
class FilesystemSharedEventService(SharedEventService):
"""Implementation of SharedEventService that validates shared access."""
shared_conversation_info_service: SharedConversationInfoService
persistence_dir: Path
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
return None
return FilesystemEventService(
prefix=self.persistence_dir,
user_id=shared_conversation_info.created_by_user_id,
app_conversation_info_service=None,
app_conversation_info_load_tasks={},
)
async def get_shared_event(
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
return None
# If conversation is shared, get the event
return await event_service.get_event(conversation_id, event_id)
async def search_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await event_service.search_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
async def count_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return 0
# If conversation is shared, count events for this conversation
return await event_service.count_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
)
class FilesystemSharedEventServiceInjector(SharedEventServiceInjector):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import get_db_session
async with get_db_session(state, request) as db_session:
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
service = FilesystemSharedEventService(
shared_conversation_info_service=shared_conversation_info_service,
persistence_dir=get_global_config().persistence_dir,
)
yield service

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
@@ -60,7 +60,7 @@ async def search_shared_conversations(
Query(
title='The max number of results in the page',
gt=0,
lte=100,
le=100,
),
] = 100,
include_sub_conversations: Annotated[
@@ -72,8 +72,6 @@ async def search_shared_conversations(
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> SharedConversationPage:
"""Search / List shared conversations."""
assert limit > 0
assert limit <= 100
return await shared_conversation_service.search_shared_conversation_info(
title__contains=title__contains,
created_at__gte=created_at__gte,
@@ -127,7 +125,11 @@ async def batch_get_shared_conversations(
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> list[SharedConversation | None]:
"""Get a batch of shared conversations given their ids. Return None for any missing or non-shared."""
assert len(ids) <= 100
if len(ids) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 conversations at once, got {len(ids)}',
)
uuids = [UUID(id_) for id_ in ids]
shared_conversation_info = (
await shared_conversation_service.batch_get_shared_conversation_info(uuids)

View File

@@ -4,20 +4,52 @@ from datetime import datetime
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 fastapi import APIRouter, Depends, HTTPException, Query
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()
elif provider == StorageProvider.FILESYSTEM:
from server.sharing.filesystem_shared_event_service import (
FilesystemSharedEventServiceInjector,
)
return FilesystemSharedEventServiceInjector()
else:
# GCP is the default for shared events (including filesystem fallback)
from server.sharing.google_cloud_shared_event_service import (
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
@@ -51,13 +83,11 @@ async def search_shared_events(
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
Query(title='The max number of results in the page', gt=0, le=100),
] = 100,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> EventPage:
"""Search / List events for a shared conversation."""
assert limit > 0
assert limit <= 100
return await shared_event_service.search_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
@@ -108,7 +138,11 @@ async def batch_get_shared_events(
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> list[Event | None]:
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
assert len(id) <= 100
if len(id) > 100:
raise HTTPException(
status_code=400,
detail=f'Cannot request more than 100 events at once, got {len(id)}',
)
event_ids = [UUID(id_) for id_ in id]
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids

View File

@@ -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)
@@ -343,6 +354,15 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
user = result.scalar_one_or_none()
assert user
# Determine org_id: prefer API key's org_id if authenticated via API key
org_id = user.current_org_id # Default fallback
if hasattr(self.user_context, 'user_auth'):
user_auth = self.user_context.user_auth
if hasattr(user_auth, 'get_api_key_org_id'):
api_key_org_id = user_auth.get_api_key_org_id()
if api_key_org_id is not None:
org_id = api_key_org_id
# Check if SAAS metadata already exists
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)
@@ -351,16 +371,15 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
existing_saas_metadata = result.scalar_one_or_none()
assert existing_saas_metadata is None or (
existing_saas_metadata.user_id == user_id_uuid
and existing_saas_metadata.org_id == user.current_org_id
and existing_saas_metadata.org_id == org_id
)
if not existing_saas_metadata:
# Create new SAAS metadata
# Set org_id to user_id as specified in requirements
# Create new SAAS metadata with the determined org_id
saas_metadata = StoredConversationMetadataSaas(
conversation_id=str(info.id),
user_id=user_id_uuid,
org_id=user.current_org_id,
org_id=org_id,
)
self.db_session.add(saas_metadata)

View 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

View 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'
)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any, Mapping
from storage.org import Org
from storage.org_member import OrgMember
_SCHEMA_VERSION = 1
def ensure_schema_version(agent_settings: Mapping[str, Any] | None) -> dict[str, Any]:
normalized = dict(agent_settings or {})
if normalized and 'schema_version' not in normalized:
normalized['schema_version'] = _SCHEMA_VERSION
return normalized
def merge_agent_settings(
base: Mapping[str, Any] | None,
updates: Mapping[str, Any] | None,
) -> dict[str, Any]:
merged = dict(base or {})
for key, value in (updates or {}).items():
if key == 'schema_version':
continue
if value is None:
merged.pop(key, None)
else:
merged[key] = value
return ensure_schema_version(merged)
def get_org_agent_settings(org: Org) -> dict[str, Any]:
return ensure_schema_version(dict(getattr(org, 'agent_settings', {}) or {}))
def get_org_member_agent_settings(org_member: OrgMember) -> dict[str, Any]:
return ensure_schema_version(dict(getattr(org_member, 'agent_settings', {}) or {}))

View File

@@ -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 organization info."""
user_id: str
org_id: UUID | None # None for legacy API keys without org binding
key_id: int
key_name: str | None
@dataclass
class ApiKeyStore:
API_KEY_PREFIX = 'sk-oh-'
# Prefix for system keys created by internal services (e.g., automations)
# Keys with this prefix are hidden from users and cannot be deleted by users
SYSTEM_KEY_NAME_PREFIX = '__SYSTEM__:'
def generate_api_key(self, length: int = 32) -> str:
"""Generate a random API key with the sk-oh- prefix."""
@@ -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:
@@ -60,8 +87,120 @@ 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.
Returns:
ApiKeyValidationResult if the key is valid, None otherwise.
The org_id may be None for legacy API keys that weren't bound to an organization.
"""
now = datetime.now(UTC)
async with a_session_maker() as session:
@@ -89,7 +228,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."""
@@ -105,8 +249,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()
@@ -114,13 +268,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}')
@@ -129,11 +296,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)
@@ -163,17 +336,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()

View File

@@ -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
@@ -157,11 +216,11 @@ class LiteLlmManager:
None,
)
oss_settings.agent = 'CodeActAgent'
oss_settings.set_agent_setting('agent', 'CodeActAgent')
# Use the model corresponding to the current user settings version
oss_settings.llm_model = get_default_litellm_model()
oss_settings.llm_api_key = SecretStr(key)
oss_settings.llm_base_url = LITE_LLM_API_URL
oss_settings.set_agent_setting('llm.model', get_default_litellm_model())
oss_settings.set_agent_setting('llm.api_key', SecretStr(key))
oss_settings.set_agent_setting('llm.base_url', LITE_LLM_API_URL)
return oss_settings
@staticmethod
@@ -295,10 +354,15 @@ class LiteLlmManager:
# Check if the database key exists in LiteLLM
# If not, generate a new key to prevent verification failures later
db_key = None
llm_base_url = (
user_settings.agent_settings.get('llm.base_url')
if user_settings and user_settings.agent_settings
else None
)
if (
user_settings
and user_settings.llm_api_key
and user_settings.llm_base_url == LITE_LLM_API_URL
and llm_base_url == LITE_LLM_API_URL
):
db_key = user_settings.llm_api_key
if hasattr(db_key, 'get_secret_value'):
@@ -334,7 +398,7 @@ class LiteLlmManager:
)
# Update user_settings with the new key so it gets stored in org_member
user_settings.llm_api_key = SecretStr(new_key)
user_settings.llm_api_key_for_byor = SecretStr(new_key)
user_settings.llm_api_key_for_byor_secret = SecretStr(new_key)
logger.info(
'LiteLlmManager:migrate_lite_llm_entries:complete',
@@ -516,25 +580,40 @@ 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
json_data: dict[str, Any] = {
'team_id': team_id,
'team_alias': team_alias,
'models': [],
'spend': 0,
'metadata': {
'version': ORG_SETTINGS_VERSION,
'model': get_default_litellm_model(),
},
}
if max_budget is not None:
json_data['max_budget'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/new',
json={
'team_id': team_id,
'team_alias': team_alias,
'models': [],
'max_budget': max_budget,
'spend': 0,
'metadata': {
'version': ORG_SETTINGS_VERSION,
'model': get_default_litellm_model(),
},
},
json=json_data,
)
# Team failed to create in litellm - this is an unforseen error state...
if not response.is_success:
if (
@@ -611,15 +690,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 +784,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,19 +1037,34 @@ 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
json_data: dict[str, Any] = {
'team_id': team_id,
'member': {'user_id': keycloak_user_id, 'role': 'user'},
}
if max_budget is not None:
json_data['max_budget_in_team'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/member_add',
json={
'team_id': team_id,
'member': {'user_id': keycloak_user_id, 'role': 'user'},
'max_budget_in_team': max_budget,
},
json=json_data,
)
# Failed to add user to team - this is an unforseen error state...
if not response.is_success:
if (
@@ -989,19 +1132,34 @@ 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
json_data: dict[str, Any] = {
'team_id': team_id,
'user_id': keycloak_user_id,
}
if max_budget is not None:
json_data['max_budget_in_team'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/member_update',
json={
'team_id': team_id,
'user_id': keycloak_user_id,
'max_budget_in_team': max_budget,
},
json=json_data,
)
# Failed to update user in team - this is an unforseen error state...
if not response.is_success:
logger.error(
@@ -1378,7 +1536,8 @@ class LiteLlmManager:
@functools.wraps(internal_fn)
async def wrapper(*args, **kwargs):
async with httpx.AsyncClient(
headers={'x-goog-api-key': LITE_LLM_API_KEY}
headers={'x-goog-api-key': LITE_LLM_API_KEY},
timeout=httpx.Timeout(30.0),
) as client:
return await internal_fn(client, *args, **kwargs)
@@ -1388,6 +1547,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))

View File

@@ -21,14 +21,7 @@ class Org(Base): # type: ignore
name = Column(String, nullable=False, unique=True)
contact_name = Column(String, nullable=True)
contact_email = Column(String, nullable=True)
agent = Column(String, nullable=True)
default_max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
default_llm_model = Column(String, nullable=True)
default_llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
enable_proactive_conversation_starters = Column(
Boolean, nullable=False, default=True
@@ -36,7 +29,7 @@ class Org(Base): # type: ignore
sandbox_base_container_image = Column(String, nullable=True)
sandbox_runtime_container_image = Column(String, nullable=True)
org_version = Column(Integer, nullable=False, default=0)
mcp_config = Column(JSON, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
# encrypted column, don't set directly, set without the underscore
_search_api_key = Column(String, nullable=True)
# encrypted column, don't set directly, set without the underscore
@@ -45,8 +38,8 @@ class Org(Base): # type: ignore
enable_solvability_analysis = Column(Boolean, nullable=True, default=False)
v1_enabled = Column(Boolean, nullable=True)
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')

View File

@@ -13,6 +13,7 @@ from server.constants import (
from server.routes.org_models import OrgAppSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.agent_settings_utils import get_org_agent_settings, merge_agent_settings
from storage.org import Org
from storage.user import User
@@ -65,8 +66,13 @@ class OrgAppSettingsStore:
"""
if org.org_version < ORG_SETTINGS_VERSION:
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
org.llm_base_url = LITE_LLM_API_URL
org.agent_settings = merge_agent_settings(
get_org_agent_settings(org),
{
'llm.model': get_default_litellm_model(),
'llm.base_url': LITE_LLM_API_URL,
},
)
await self.db_session.flush()
await self.db_session.refresh(org)

View File

@@ -9,6 +9,7 @@ from uuid import UUID
from server.routes.org_models import OrgLLMSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.agent_settings_utils import get_org_agent_settings, merge_agent_settings
from storage.org import Org
from storage.org_member_store import OrgMemberStore
from storage.user import User
@@ -67,8 +68,12 @@ class OrgLLMSettingsStore:
if not org:
return None
# Apply updates to org (excludes llm_api_key which is member-only)
update_data.apply_to_org(org)
if update_data.agent_settings is not None:
org.agent_settings = merge_agent_settings(
get_org_agent_settings(org),
update_data.agent_settings,
)
# Propagate relevant settings to all org members
member_updates = update_data.get_member_updates()

View File

@@ -1,9 +1,7 @@
"""
SQLAlchemy model for Organization-Member relationship.
"""
"""SQLAlchemy model for organization-member relationships."""
from pydantic import SecretStr
from sqlalchemy import UUID, Column, ForeignKey, Integer, String
from sqlalchemy import JSON, UUID, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from storage.base import Base
from storage.encrypt_utils import decrypt_value, encrypt_value
@@ -18,50 +16,30 @@ class OrgMember(Base): # type: ignore
user_id = Column(UUID(as_uuid=True), ForeignKey('user.id'), primary_key=True)
role_id = Column(Integer, ForeignKey('role.id'), nullable=False)
_llm_api_key = Column(String, nullable=False)
max_iterations = Column(Integer, nullable=True)
llm_model = Column(String, nullable=True)
_llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
status = Column(String, nullable=True)
mcp_config = Column(JSON, nullable=True)
# Relationships
org = relationship('Org', back_populates='org_members')
user = relationship('User', back_populates='org_members')
role = relationship('Role', back_populates='org_members')
def __init__(self, **kwargs):
# Handle known SQLAlchemy columns directly
for key in list(kwargs):
if hasattr(self.__class__, key):
setattr(self, key, kwargs.pop(key))
# Handle custom property-style fields
if 'llm_api_key' in kwargs:
self.llm_api_key = kwargs.pop('llm_api_key')
if 'llm_api_key_for_byor' in kwargs:
self.llm_api_key_for_byor = kwargs.pop('llm_api_key_for_byor')
if kwargs:
raise TypeError(f'Unexpected keyword arguments: {list(kwargs.keys())}')
@property
def llm_api_key(self) -> SecretStr:
decrypted = decrypt_value(self._llm_api_key)
return SecretStr(decrypted)
return SecretStr(decrypt_value(self._llm_api_key))
@llm_api_key.setter
def llm_api_key(self, value: str | SecretStr):
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
self._llm_api_key = encrypt_value(raw)
@property
def llm_api_key_for_byor(self) -> SecretStr | None:
if self._llm_api_key_for_byor:
decrypted = decrypt_value(self._llm_api_key_for_byor)
return SecretStr(decrypted)
return None
@llm_api_key_for_byor.setter
def llm_api_key_for_byor(self, value: str | SecretStr | None):
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
self._llm_api_key_for_byor = encrypt_value(raw) if raw else None

View File

@@ -6,11 +6,14 @@ from typing import Optional
from uuid import UUID
from server.routes.org_models import OrgMemberLLMSettings
from sqlalchemy import func, select, update
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from storage.agent_settings_utils import (
get_org_member_agent_settings,
merge_agent_settings,
)
from storage.database import a_session_maker
from storage.encrypt_utils import encrypt_value
from storage.org_member import OrgMember
from storage.user import User
from storage.user_settings import UserSettings
@@ -28,8 +31,11 @@ class OrgMemberStore:
role_id: int,
llm_api_key: str,
status: Optional[str] = None,
agent_settings: Optional[dict] = None,
) -> OrgMember:
"""Add a user to an organization with a specific role."""
agent_settings = dict(agent_settings or {})
async with a_session_maker() as session:
org_member = OrgMember(
org_id=org_id,
@@ -37,6 +43,7 @@ class OrgMemberStore:
role_id=role_id,
llm_api_key=llm_api_key,
status=status,
agent_settings=agent_settings,
)
session.add(org_member)
await session.commit()
@@ -142,23 +149,28 @@ class OrgMemberStore:
await session.commit()
return True
@staticmethod
def get_agent_settings_from_org_member(org_member: OrgMember) -> dict[str, object]:
return get_org_member_agent_settings(org_member)
@staticmethod
def get_kwargs_from_settings(settings: Settings):
kwargs = {
normalized: getattr(settings, normalized)
for c in OrgMember.__table__.columns
if (normalized := c.name.lstrip('_')) and hasattr(settings, normalized)
return {
'llm_api_key': settings.get_secret_agent_setting('llm.api_key'),
'agent_settings': settings.normalized_agent_settings(
strip_secret_values=True
),
}
return kwargs
@staticmethod
def get_kwargs_from_user_settings(user_settings: UserSettings):
kwargs = {
normalized: getattr(user_settings, normalized)
for c in OrgMember.__table__.columns
if (normalized := c.name.lstrip('_')) and hasattr(user_settings, normalized)
agent_settings = dict(user_settings.agent_settings or {})
if agent_settings and 'schema_version' not in agent_settings:
agent_settings['schema_version'] = 1
return {
'llm_api_key': user_settings.llm_api_key,
'agent_settings': agent_settings,
}
return kwargs
@staticmethod
async def get_org_members_count(
@@ -238,21 +250,34 @@ class OrgMemberStore:
org_id: UUID,
member_settings: OrgMemberLLMSettings,
) -> None:
"""Update LLM settings for all members of an organization.
"""Update shared LLM settings for all members of an organization.
Args:
session: Database session (passed from caller for transaction)
org_id: Organization ID
member_settings: Typed LLM settings to apply to all members
member_settings: Shared settings to apply to all members
"""
# Build update values from non-None fields
values = member_settings.model_dump(exclude_none=True)
if not values:
return
# Handle encrypted llm_api_key field - map to _llm_api_key column with encryption
if 'llm_api_key' in values:
raw_key = values.pop('llm_api_key')
values['_llm_api_key'] = encrypt_value(raw_key)
result = await session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
)
org_members = list(result.scalars().all())
if values:
stmt = update(OrgMember).where(OrgMember.org_id == org_id).values(**values)
await session.execute(stmt)
raw_key = values.pop('llm_api_key', None)
agent_settings_updates = values.pop('agent_settings', None)
for org_member in org_members:
if raw_key is not None:
org_member.llm_api_key = raw_key
if agent_settings_updates is not None:
org_member.agent_settings = merge_agent_settings(
get_org_member_agent_settings(org_member),
agent_settings_updates,
)
for key, value in values.items():
setattr(org_member, key, value)

View File

@@ -113,7 +113,10 @@ class OrgService:
contact_name=contact_name,
contact_email=contact_email,
org_version=ORG_SETTINGS_VERSION,
default_llm_model=get_default_litellm_model(),
agent_settings={
'schema_version': 1,
'llm.model': get_default_litellm_model(),
},
)
@staticmethod
@@ -467,42 +470,6 @@ class OrgService:
)
return False
@staticmethod
def _get_llm_settings_fields() -> set[str]:
"""
Get the set of organization fields that are considered LLM settings
and require admin/owner role to update.
Returns:
set[str]: Set of field names that require elevated permissions
"""
return {
'default_llm_model',
'default_llm_api_key_for_byor',
'default_llm_base_url',
'search_api_key',
'security_analyzer',
'agent',
'confirmation_mode',
'enable_default_condenser',
'condenser_max_size',
}
@staticmethod
def _has_llm_settings_updates(update_data: OrgUpdate) -> set[str]:
"""
Check if the update contains any LLM settings fields.
Args:
update_data: The organization update data
Returns:
set[str]: Set of LLM fields being updated (empty if none)
"""
llm_fields = OrgService._get_llm_settings_fields()
update_dict = update_data.model_dump(exclude_none=True)
return llm_fields.intersection(update_dict.keys())
@staticmethod
async def update_org_with_permissions(
org_id: UUID,
@@ -571,33 +538,6 @@ class OrgService:
)
raise OrgNameExistsError(update_data.name)
# Check if update contains any LLM settings
llm_fields_being_updated = OrgService._has_llm_settings_updates(update_data)
if llm_fields_being_updated:
# Verify user has admin or owner role
has_permission = await OrgService.has_admin_or_owner_role(user_id, org_id)
if not has_permission:
logger.warning(
'User attempted to update LLM settings without permission',
extra={
'user_id': user_id,
'org_id': str(org_id),
'attempted_fields': list(llm_fields_being_updated),
},
)
raise PermissionError(
'Admin or owner role required to update LLM settings'
)
logger.debug(
'User has permission to update LLM settings',
extra={
'user_id': user_id,
'org_id': str(org_id),
'llm_fields': list(llm_fields_being_updated),
},
)
# Convert to dict for OrgStore (excluding None values)
update_dict = update_data.model_dump(exclude_none=True)
if not update_dict:
@@ -607,6 +547,24 @@ class OrgService:
)
return existing_org
restricted_fields = {'agent_settings', 'search_api_key', 'sandbox_api_key'}
if restricted_fields.intersection(
update_dict
) and not await OrgService.has_admin_or_owner_role(user_id, org_id):
logger.warning(
'Insufficient role for restricted organization settings update',
extra={
'user_id': user_id,
'org_id': str(org_id),
'restricted_fields': sorted(
restricted_fields.intersection(update_dict)
),
},
)
raise PermissionError(
'Admin or owner role required to update organization agent settings'
)
# Perform the update
try:
updated_org = await OrgStore.update_org(org_id, update_dict)

View File

@@ -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,
@@ -13,6 +14,10 @@ from server.constants import (
from server.routes.org_models import OrgLLMSettingsUpdate, OrphanedUserError
from sqlalchemy import select, text
from sqlalchemy.orm import joinedload
from storage.agent_settings_utils import (
get_org_agent_settings,
merge_agent_settings,
)
from storage.database import a_session_maker
from storage.lite_llm_manager import LiteLlmManager
from storage.org import Org
@@ -23,10 +28,32 @@ from storage.user_settings import UserSettings
from openhands.core.logger import openhands_logger as logger
from openhands.storage.data_models.settings import Settings
_ORG_SETTINGS_EXCLUDED_FIELDS = {
'id',
'name',
'contact_name',
'contact_email',
'org_version',
'agent_settings',
}
_ORG_SETTINGS_FIELDS = {
normalized
for column in Org.__table__.columns
if (normalized := column.name.lstrip('_')) not in _ORG_SETTINGS_EXCLUDED_FIELDS
}
class OrgStore:
"""Store for managing organizations."""
@staticmethod
def get_agent_settings_from_org(org: Org) -> dict[str, object]:
return get_org_agent_settings(org)
@staticmethod
def sync_agent_settings(org: Org) -> None:
org.agent_settings = get_org_agent_settings(org)
@staticmethod
async def create_org(
kwargs: dict,
@@ -35,7 +62,15 @@ class OrgStore:
async with a_session_maker() as session:
org = Org(**kwargs)
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
org.agent_settings = merge_agent_settings(
org.agent_settings,
{
'llm.model': get_org_agent_settings(org).get('llm.model')
or 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)
@@ -89,8 +124,10 @@ class OrgStore:
org.id,
{
'org_version': ORG_SETTINGS_VERSION,
'default_llm_model': get_default_litellm_model(),
'llm_base_url': LITE_LLM_API_URL,
'agent_settings': {
'llm.model': get_default_litellm_model(),
'llm.base_url': LITE_LLM_API_URL,
},
},
)
return org
@@ -177,57 +214,45 @@ class OrgStore:
if 'id' in kwargs:
kwargs.pop('id')
agent_settings_updates = kwargs.pop('agent_settings', None)
for key, value in kwargs.items():
if hasattr(org, key):
setattr(org, key, value)
if agent_settings_updates is not None:
org.agent_settings = merge_agent_settings(
get_org_agent_settings(org),
agent_settings_updates,
)
await session.commit()
await session.refresh(org)
return org
@staticmethod
def get_kwargs_from_settings(settings: Settings):
kwargs = {}
for c in Org.__table__.columns:
# Normalize for lookup
normalized = (
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
)
if not hasattr(settings, normalized):
continue
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
key = c.name
if key.startswith('_'):
key = key[1:] # remove only the very first leading underscore
kwargs[key] = getattr(settings, normalized)
kwargs = {
field: getattr(settings, field)
for field in _ORG_SETTINGS_FIELDS
if hasattr(settings, field)
}
kwargs['agent_settings'] = settings.normalized_agent_settings(
strip_secret_values=True
)
return kwargs
@staticmethod
def get_kwargs_from_user_settings(user_settings: UserSettings):
kwargs = {}
for c in Org.__table__.columns:
# Normalize for lookup
normalized = (
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
)
if not hasattr(user_settings, normalized):
continue
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
key = c.name
if key.startswith('_'):
key = key[1:] # remove only the very first leading underscore
kwargs[key] = getattr(user_settings, normalized)
kwargs = {
field: getattr(user_settings, field)
for field in _ORG_SETTINGS_FIELDS
if hasattr(user_settings, field)
}
kwargs['org_version'] = user_settings.user_version
kwargs['agent_settings'] = (
user_settings.to_settings().normalized_agent_settings(strip_secret_values=True)
)
return kwargs
@staticmethod
@@ -428,8 +453,12 @@ class OrgStore:
if not org:
return None
# Apply updates to org
llm_settings.apply_to_org(org)
if llm_settings.agent_settings is not None:
org.agent_settings = merge_agent_settings(
get_org_agent_settings(org),
llm_settings.agent_settings,
)
# Propagate relevant settings to all org members
member_updates = llm_settings.get_member_updates()

View File

@@ -15,25 +15,27 @@ class SaasConversationValidator(ConversationValidator):
async def _validate_api_key(self, api_key: str) -> str | None:
"""
Validate an API key and return the user_id and github_user_id if valid.
Validate an API key and return the user_id if valid.
Args:
api_key: The API key to validate
Returns:
A tuple of (user_id, github_user_id) if the API key is valid, None otherwise
The user_id if the API key is valid, None otherwise
"""
try:
token_manager = TokenManager()
# 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:

View File

@@ -59,12 +59,15 @@ class SaasSecretsStore(SecretsStore):
async with a_session_maker() as session:
# Incoming secrets are always the most updated ones
# Delete all existing records and override with incoming ones
await session.execute(
delete(StoredCustomSecrets).filter(
StoredCustomSecrets.keycloak_user_id == self.user_id
)
# Delete existing records for this user AND organization only
delete_query = delete(StoredCustomSecrets).filter(
StoredCustomSecrets.keycloak_user_id == self.user_id
)
if org_id is not None:
delete_query = delete_query.filter(StoredCustomSecrets.org_id == org_id)
else:
delete_query = delete_query.filter(StoredCustomSecrets.org_id.is_(None))
await session.execute(delete_query)
# Prepare the new secrets data
kwargs = item.model_dump(context={'expose_secrets': True})

View File

@@ -1,12 +1,8 @@
from __future__ import annotations
import binascii
import hashlib
import uuid
from base64 import b64decode, b64encode
from dataclasses import dataclass
from cryptography.fernet import Fernet
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from server.constants import LITE_LLM_API_URL
@@ -14,10 +10,10 @@ from server.logger import logger
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
from storage.org_member_store import OrgMemberStore
from storage.org_store import OrgStore
from storage.user import User
from storage.user_settings import UserSettings
@@ -33,7 +29,6 @@ from openhands.utils.llm import is_openhands_model
class SaasSettingsStore(SettingsStore):
user_id: str
config: OpenHandsConfig
ENCRYPT_VALUES = ['llm_api_key', 'llm_api_key_for_byor', 'search_api_key']
async def _get_user_settings_by_keycloak_id_async(
self, keycloak_user_id: str, session=None
@@ -69,6 +64,33 @@ class SaasSettingsStore(SettingsStore):
)
return result.scalars().first()
async def _persist_agent_settings_async(
self, org_id: uuid.UUID, agent_settings: dict
) -> None:
async with a_session_maker() as session:
stmt = (
update(OrgMember)
.where(
OrgMember.org_id == org_id,
OrgMember.user_id == uuid.UUID(self.user_id),
)
.values(agent_settings=agent_settings)
)
await session.execute(stmt)
await session.commit()
async def _persist_org_agent_settings_async(
self, org_id: uuid.UUID, agent_settings: dict
) -> None:
async with a_session_maker() as session:
stmt = (
update(Org)
.where(Org.id == org_id)
.values(agent_settings=agent_settings)
)
await session.execute(stmt)
await session.commit()
async def load(self) -> Settings | None:
user = await UserStore.get_user_by_id(self.user_id)
if not user:
@@ -89,6 +111,11 @@ class SaasSettingsStore(SettingsStore):
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
)
return None
org_agent_settings = OrgStore.get_agent_settings_from_org(org)
member_agent_settings = OrgMemberStore.get_agent_settings_from_org_member(
org_member
)
kwargs = {
**{
normalized: getattr(org, c.name)
@@ -107,18 +134,26 @@ class SaasSettingsStore(SettingsStore):
},
}
kwargs['llm_api_key'] = org_member.llm_api_key
if org_member.max_iterations:
kwargs['max_iterations'] = org_member.max_iterations
if org_member.llm_model:
kwargs['llm_model'] = org_member.llm_model
if org_member.llm_api_key_for_byor:
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
if org_member.llm_base_url:
kwargs['llm_base_url'] = org_member.llm_base_url
if org_member.mcp_config is not None:
kwargs['mcp_config'] = org_member.mcp_config
effective_member_agent_settings = {
**org_agent_settings,
**member_agent_settings,
}
kwargs['agent_settings'] = effective_member_agent_settings
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)
if org_agent_settings != (org.agent_settings or {}):
await self._persist_org_agent_settings_async(org_id, org_agent_settings)
if effective_member_agent_settings != (org_member.agent_settings or {}):
await self._persist_agent_settings_async(
org_id, effective_member_agent_settings
)
return settings
async def store(self, item: Settings):
@@ -175,41 +210,52 @@ class SaasSettingsStore(SettingsStore):
)
return None
llm_model = item.get_agent_setting('llm.model')
llm_base_url = item.get_agent_setting('llm.base_url')
uses_managed_llm_key = not llm_base_url or llm_base_url == LITE_LLM_API_URL
# Check if we need to generate an LLM key.
if item.llm_base_url == LITE_LLM_API_URL:
if uses_managed_llm_key:
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
item, str(org_id), openhands_type=is_openhands_model(llm_model)
)
normalized_agent_settings = item.normalized_agent_settings(
strip_secret_values=True
)
shared_agent_settings = {
key: value
for key, value in normalized_agent_settings.items()
if key not in {'llm.api_key', 'mcp_config'}
}
current_member_llm_api_key = item.get_secret_agent_setting('llm.api_key')
shared_llm_api_key = (
current_member_llm_api_key.get_secret_value()
if current_member_llm_api_key and not uses_managed_llm_key
else None
)
kwargs = item.model_dump(context={'expose_secrets': True})
for model in (user, org, org_member):
for key, value in kwargs.items():
if hasattr(model, key):
setattr(model, key, value)
kwargs.pop('agent_settings', None)
for key, value in kwargs.items():
if hasattr(user, key):
setattr(user, key, value)
if key != 'mcp_config' and hasattr(org, key):
setattr(org, key, value)
if key == 'mcp_config' and hasattr(org_member, key):
setattr(org_member, key, value)
# 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()
)
org.agent_settings = shared_agent_settings
if member_update_values:
stmt = (
update(OrgMember)
.where(OrgMember.org_id == org_id)
.values(**member_update_values)
)
await session.execute(stmt)
result = await session.execute(select(OrgMember).filter(OrgMember.org_id == org_id))
org_members = list(result.scalars().all())
for member in org_members:
member.agent_settings = dict(shared_agent_settings)
if shared_llm_api_key is not None:
member.llm_api_key = shared_llm_api_key
if current_member_llm_api_key is not None:
org_member.llm_api_key = current_member_llm_api_key
await session.commit()
@@ -222,52 +268,6 @@ class SaasSettingsStore(SettingsStore):
logger.debug(f'saas_settings_store.get_instance::{user_id}')
return SaasSettingsStore(user_id, config)
def _should_encrypt(self, key):
return key in self.ENCRYPT_VALUES
def _decrypt_kwargs(self, kwargs: dict):
fernet = self._fernet()
for key, value in kwargs.items():
try:
if value is None:
continue
if self._should_encrypt(key):
if isinstance(value, SecretStr):
value = fernet.decrypt(
b64decode(value.get_secret_value().encode())
).decode()
else:
value = fernet.decrypt(b64decode(value.encode())).decode()
kwargs[key] = value
except binascii.Error:
pass # Key is in legacy format...
def _encrypt_kwargs(self, kwargs: dict):
fernet = self._fernet()
for key, value in kwargs.items():
if value is None:
continue
if isinstance(value, dict):
self._encrypt_kwargs(value)
continue
if self._should_encrypt(key):
if isinstance(value, SecretStr):
value = b64encode(
fernet.encrypt(value.get_secret_value().encode())
).decode()
else:
value = b64encode(fernet.encrypt(value.encode())).decode()
kwargs[key] = value
def _fernet(self):
if not self.config.jwt_secret:
raise ValueError('jwt_secret must be defined on config')
jwt_secret = self.config.jwt_secret.get_secret_value()
fernet_key = b64encode(hashlib.sha256(jwt_secret.encode()).digest())
return Fernet(fernet_key)
async def _ensure_api_key(
self, item: Settings, org_id: str, openhands_type: bool = False
) -> None:
@@ -277,9 +277,11 @@ class SaasSettingsStore(SettingsStore):
is valid in LiteLLM. If valid, reuses it. Otherwise, generates a new key.
"""
llm_api_key = item.get_secret_agent_setting('llm.api_key')
# First, check if our current key is valid
if item.llm_api_key and not await LiteLlmManager.verify_existing_key(
item.llm_api_key.get_secret_value(),
if llm_api_key and not await LiteLlmManager.verify_existing_key(
llm_api_key.get_secret_value(),
self.user_id,
org_id,
openhands_type=openhands_type,
@@ -302,7 +304,7 @@ class SaasSettingsStore(SettingsStore):
None,
)
item.llm_api_key = SecretStr(generated_key)
item.set_agent_setting('llm.api_key', SecretStr(generated_key))
logger.info(
'saas_settings_store:store:generated_openhands_key',
extra={'user_id': self.user_id},

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
from __future__ import annotations
from pydantic import SecretStr
from server.constants import DEFAULT_BILLING_MARGIN
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Identity, Integer, String
from storage.base import Base
from storage.encrypt_utils import decrypt_legacy_value, encrypt_legacy_value
class UserSettings(Base): # type: ignore
@@ -8,17 +12,9 @@ class UserSettings(Base): # type: ignore
id = Column(Integer, Identity(), primary_key=True)
keycloak_user_id = Column(String, nullable=True, index=True)
language = Column(String, nullable=True)
agent = Column(String, nullable=True)
max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
llm_model = Column(String, nullable=True)
llm_api_key = Column(String, nullable=True)
llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
condenser_max_size = Column(Integer, nullable=True)
user_consents_to_analytics = Column(Boolean, nullable=True)
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
@@ -27,9 +23,11 @@ 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)
disabled_skills = Column(JSON, nullable=True)
search_api_key = Column(String, nullable=True)
sandbox_api_key = Column(String, nullable=True)
max_budget_per_task = Column(Float, nullable=True)
@@ -39,6 +37,31 @@ class UserSettings(Base): # type: ignore
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
v1_enabled = Column(Boolean, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
@property
def llm_api_key_for_byor_secret(self) -> SecretStr | None:
raw = self.llm_api_key_for_byor
if not raw:
return None
try:
return SecretStr(decrypt_legacy_value(raw))
except Exception:
return SecretStr(raw)
@llm_api_key_for_byor_secret.setter
def llm_api_key_for_byor_secret(self, value: str | SecretStr | None) -> None:
if value is None:
self.llm_api_key_for_byor = None
return
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
self.llm_api_key_for_byor = encrypt_legacy_value(raw)
already_migrated = Column(
Boolean, nullable=True, default=False
) # False = not migrated, True = migrated
def to_settings(self):
from openhands.storage.data_models.settings import Settings
return Settings(agent_settings=dict(self.agent_settings or {}))

View File

@@ -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,
@@ -90,9 +91,6 @@ class UserStore:
from storage.org_member_store import OrgMemberStore
org_member_kwargs = OrgMemberStore.get_kwargs_from_settings(settings)
# avoid setting org member llm fields to use org defaults on user creation
del org_member_kwargs['llm_model']
del org_member_kwargs['llm_base_url']
org_member = OrgMember(
org_id=org.id,
user_id=user.id,
@@ -213,14 +211,15 @@ class UserStore:
decrypted_user_settings, user_settings.user_version
)
# avoids circular reference. This migrate method is temprorary until all users are migrated.
# Migrate stripe customer (pass session to avoid FK violation)
# avoids circular reference. This migrate method is temporary until all users are migrated.
from integrations.stripe_service import migrate_customer
logger.debug(
'user_store:migrate_user:calling_stripe_migrate_customer',
extra={'user_id': user_id},
)
await migrate_customer(user_id, org)
await migrate_customer(session, user_id, org)
logger.debug(
'user_store:migrate_user:done_stripe_migrate_customer',
extra={'user_id': user_id},
@@ -231,16 +230,23 @@ class UserStore:
org_kwargs = OrgStore.get_kwargs_from_user_settings(decrypted_user_settings)
org_kwargs.pop('id', None)
# if user has custom settings, set org defaults to current version
# If the user has custom settings, keep the org defaults minimal.
if custom_settings:
org_kwargs['default_llm_model'] = get_default_litellm_model()
org_kwargs['llm_base_url'] = LITE_LLM_API_URL
org_kwargs['agent_settings'] = {
'schema_version': 1,
'llm.model': get_default_litellm_model(),
'llm.base_url': LITE_LLM_API_URL,
}
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
for key, value in org_kwargs.items():
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
)
@@ -270,12 +276,10 @@ class UserStore:
org_member_kwargs = OrgMemberStore.get_kwargs_from_user_settings(
decrypted_user_settings
)
# if the user did not have custom settings in the old model,
# then use the org defaults by not setting org_member fields
if not custom_settings:
del org_member_kwargs['llm_model']
del org_member_kwargs['llm_base_url']
org_member_kwargs['agent_settings'] = (
OrgStore.get_agent_settings_from_org(org)
)
org_member = OrgMember(
org_id=org.id,
@@ -461,13 +465,6 @@ class UserStore:
user_settings.llm_api_key = encrypt_legacy_value(
org_member.llm_api_key.get_secret_value()
)
if (
org_member.llm_api_key_for_byor
and org_member.llm_api_key_for_byor.get_secret_value()
):
user_settings.llm_api_key_for_byor = encrypt_legacy_value(
org_member.llm_api_key_for_byor.get_secret_value()
)
logger.info(
'user_store:downgrade_user:updated_user_settings_from_org_member',
extra={'user_id': user_id},
@@ -892,6 +889,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(
@@ -943,44 +942,20 @@ class UserStore:
Returns:
A new UserSettings object populated from the entities
"""
# Mapping from OrgMember fields to corresponding Org "default_" fields
org_member_to_org_default = {
'llm_model': 'default_llm_model',
'llm_base_url': 'default_llm_base_url',
'max_iterations': 'default_max_iterations',
}
from storage.org_member_store import OrgMemberStore
from storage.org_store import OrgStore
def get_value_with_org_fallback(field_name: str, org_member_value):
"""Get value from OrgMember, falling back to Org default if None."""
if org_member_value is not None:
return org_member_value
org_default_field = org_member_to_org_default.get(field_name)
if org_default_field and hasattr(org, org_default_field):
return getattr(org, org_default_field)
return None
# Get values from OrgMember with Org fallback for fields with default_ prefix
llm_model = get_value_with_org_fallback('llm_model', org_member.llm_model)
llm_base_url = get_value_with_org_fallback(
'llm_base_url', org_member.llm_base_url
)
max_iterations = get_value_with_org_fallback(
'max_iterations', org_member.max_iterations
member_agent_settings = OrgMemberStore.get_agent_settings_from_org_member(
org_member
)
org_agent_settings = OrgStore.get_agent_settings_from_org(org)
agent_settings = {**org_agent_settings, **member_agent_settings}
return UserSettings(
keycloak_user_id=user_id,
# OrgMember fields
llm_api_key=org_member.llm_api_key.get_secret_value()
if org_member.llm_api_key
else None,
llm_api_key_for_byor=org_member.llm_api_key_for_byor.get_secret_value()
if org_member.llm_api_key_for_byor
else None,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
# User fields
accepted_tos=user.accepted_tos,
enable_sound_notifications=user.enable_sound_notifications,
language=user.language,
@@ -989,18 +964,12 @@ class UserStore:
email_verified=user.email_verified,
git_user_name=user.git_user_name,
git_user_email=user.git_user_email,
# Org fields
agent=org.agent,
security_analyzer=org.security_analyzer,
confirmation_mode=org.confirmation_mode,
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
enable_default_condenser=org.enable_default_condenser,
billing_margin=org.billing_margin,
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters,
sandbox_base_container_image=org.sandbox_base_container_image,
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
user_version=org.org_version,
mcp_config=org.mcp_config,
search_api_key=org.search_api_key.get_secret_value()
if org.search_api_key
else None,
@@ -1010,7 +979,8 @@ class UserStore:
max_budget_per_task=org.max_budget_per_task,
enable_solvability_analysis=org.enable_solvability_analysis,
v1_enabled=org.v1_enabled,
condenser_max_size=org.condenser_max_size,
sandbox_grouping_strategy=org.sandbox_grouping_strategy,
agent_settings=agent_settings,
already_migrated=False,
)
@@ -1028,16 +998,17 @@ class UserStore:
Returns:
True if user has custom settings, False if using old defaults
"""
# Normalize values
user_model = (
user_settings.llm_model.strip() or None if user_settings.llm_model else None
persisted_agent_settings = user_settings.agent_settings or {}
user_model = persisted_agent_settings.get('llm.model') or getattr(
user_settings, 'llm_model', None
)
user_base_url = (
user_settings.llm_base_url.strip() or None
if user_settings.llm_base_url
else None
user_base_url = persisted_agent_settings.get('llm.base_url') or getattr(
user_settings, 'llm_base_url', None
)
user_model = user_model.strip() or None if user_model else None
user_base_url = user_base_url.strip() or None if user_base_url else None
# Custom base_url = definitely custom settings (BYOK)
if user_base_url and user_base_url != LITE_LLM_API_URL:
return True

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import os
import uuid
from datetime import datetime
from uuid import UUID
@@ -28,6 +29,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,
@@ -35,6 +37,20 @@ from storage.stored_conversation_metadata_saas import (
from storage.stored_offline_token import StoredOfflineToken
from storage.stripe_customer import StripeCustomer
from storage.user import User
from storage.user_settings import UserSettings # noqa: F401
@pytest.fixture(autouse=True)
def allow_short_context_windows():
old = os.environ.get('ALLOW_SHORT_CONTEXT_WINDOWS')
os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = 'true'
try:
yield
finally:
if old is None:
os.environ.pop('ALLOW_SHORT_CONTEXT_WINDOWS', None)
else:
os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = old
@pytest.fixture
@@ -170,7 +186,6 @@ def add_minimal_fixtures(session_maker):
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
name='mock-org',
org_version=ORG_SETTINGS_VERSION,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
)

View File

View File

@@ -0,0 +1,325 @@
"""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_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_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_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_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_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_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_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_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_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_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_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_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

View File

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

View File

@@ -106,8 +106,10 @@ async def test_create_org_success(mock_app):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
enable_default_condenser=True,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
},
enable_proactive_conversation_starters=True,
)
@@ -140,7 +142,9 @@ async def test_create_org_success(mock_app):
assert response_data['contact_email'] == 'john@example.com'
assert response_data['credits'] == 100.0
assert response_data['org_version'] == 5
assert response_data['default_llm_model'] == 'claude-opus-4-5-20251101'
assert (
response_data['agent_settings']['llm.model'] == 'claude-opus-4-5-20251101'
)
@pytest.mark.asyncio
@@ -427,8 +431,11 @@ async def test_create_org_sensitive_fields_not_exposed(mock_app):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
enable_default_condenser=True,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
'condenser.enabled': True,
},
enable_proactive_conversation_starters=True,
)
@@ -507,7 +514,10 @@ async def test_list_user_orgs_success(mock_app_list):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
},
)
mock_user = MagicMock()
mock_user.current_org_id = org_id
@@ -918,20 +928,23 @@ async def test_list_user_orgs_all_fields_present(mock_app_list):
contact_name='John Doe',
contact_email='john@example.com',
conversation_expiration=3600,
agent='CodeActAgent',
default_max_iterations=50,
security_analyzer='enabled',
confirmation_mode=True,
default_llm_model='claude-opus-4-5-20251101',
default_llm_base_url='https://api.example.com',
agent_settings={
'schema_version': 1,
'agent': 'CodeActAgent',
'max_iterations': 50,
'verification.security_analyzer': 'enabled',
'verification.confirmation_mode': True,
'llm.model': 'claude-opus-4-5-20251101',
'llm.base_url': 'https://api.example.com',
'condenser.enabled': True,
'mcp_config': {'key': 'value'},
},
remote_runtime_resource_factor=2,
enable_default_condenser=True,
billing_margin=0.15,
enable_proactive_conversation_starters=True,
sandbox_base_container_image='test-image',
sandbox_runtime_container_image='test-runtime',
org_version=5,
mcp_config={'key': 'value'},
max_budget_per_task=1000.0,
enable_solvability_analysis=True,
v1_enabled=True,
@@ -962,20 +975,18 @@ async def test_list_user_orgs_all_fields_present(mock_app_list):
assert org_data['contact_name'] == 'John Doe'
assert org_data['contact_email'] == 'john@example.com'
assert org_data['conversation_expiration'] == 3600
assert org_data['agent'] == 'CodeActAgent'
assert org_data['default_max_iterations'] == 50
assert org_data['security_analyzer'] == 'enabled'
assert org_data['confirmation_mode'] is True
assert org_data['default_llm_model'] == 'claude-opus-4-5-20251101'
assert org_data['default_llm_base_url'] == 'https://api.example.com'
assert org_data['agent_settings']['agent'] == 'CodeActAgent'
assert org_data['agent_settings']['max_iterations'] == 50
assert org_data['agent_settings']['verification.security_analyzer'] == 'enabled'
assert org_data['agent_settings']['verification.confirmation_mode'] is True
assert org_data['agent_settings']['llm.model'] == 'claude-opus-4-5-20251101'
assert org_data['agent_settings']['llm.base_url'] == 'https://api.example.com'
assert org_data['remote_runtime_resource_factor'] == 2
assert org_data['enable_default_condenser'] is True
assert org_data['billing_margin'] == 0.15
assert org_data['enable_proactive_conversation_starters'] is True
assert org_data['sandbox_base_container_image'] == 'test-image'
assert org_data['sandbox_runtime_container_image'] == 'test-runtime'
assert org_data['org_version'] == 5
assert org_data['mcp_config'] == {'key': 'value'}
assert org_data['max_budget_per_task'] == 1000.0
assert org_data['enable_solvability_analysis'] is True
assert org_data['v1_enabled'] is True
@@ -1020,8 +1031,11 @@ async def test_get_org_success(mock_app_with_get_user_id, mock_owner_role):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
enable_default_condenser=True,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
'condenser.enabled': True,
},
enable_proactive_conversation_starters=True,
)
@@ -1298,8 +1312,11 @@ async def test_get_org_with_credits_none(mock_app_with_get_user_id, mock_owner_r
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
enable_default_condenser=True,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
'condenser.enabled': True,
},
enable_proactive_conversation_starters=True,
)
@@ -1345,10 +1362,13 @@ async def test_get_org_sensitive_fields_not_exposed(
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
'condenser.enabled': True,
},
search_api_key='secret-search-key-123', # Should not be exposed
sandbox_api_key='secret-sandbox-key-123', # Should not be exposed
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
@@ -1872,7 +1892,7 @@ async def test_update_org_permission_denied_llm_settings(
"""
# Arrange
org_id = uuid.uuid4()
update_data = {'default_llm_model': 'claude-opus-4-5-20251101'}
update_data = {'agent_settings': {'llm.model': 'claude-opus-4-5-20251101'}}
with (
patch(
@@ -2032,13 +2052,13 @@ async def test_update_org_invalid_uuid_format(mock_update_app):
@pytest.mark.asyncio
async def test_update_org_invalid_field_values(mock_update_app, mock_owner_role):
"""
GIVEN: Update request with invalid field values (e.g., negative max_iterations)
GIVEN: Update request with invalid field values (e.g., negative billing margin)
WHEN: PATCH /api/organizations/{org_id} is called
THEN: 422 validation error is returned
"""
# Arrange
org_id = uuid.uuid4()
update_data = {'default_max_iterations': -1} # Invalid: must be > 0
update_data = {'billing_margin': -1} # Invalid: must be >= 0
with patch(
'server.auth.authorization.get_user_org_role',
@@ -2995,20 +3015,24 @@ class TestGetMeEndpoint:
llm_model='gpt-4',
llm_base_url='https://api.example.com',
max_iterations=50,
llm_api_key_for_byor=None,
status_val='active',
):
"""Create a MeResponse for testing."""
agent_settings = {'schema_version': 1}
if llm_model is not None:
agent_settings['llm.model'] = llm_model
if llm_base_url is not None:
agent_settings['llm.base_url'] = llm_base_url
if max_iterations is not None:
agent_settings['max_iterations'] = max_iterations
return MeResponse(
org_id=str(org_id),
user_id=str(user_id),
email=email,
role=role,
llm_api_key=llm_api_key,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
llm_api_key_for_byor=llm_api_key_for_byor,
agent_settings=agent_settings,
status=status_val,
)
@@ -3043,9 +3067,9 @@ class TestGetMeEndpoint:
assert data['user_id'] == test_user_id
assert data['email'] == 'owner@example.com'
assert data['role'] == 'owner'
assert data['llm_model'] == 'gpt-4'
assert data['llm_base_url'] == 'https://api.example.com'
assert data['max_iterations'] == 50
assert data['agent_settings']['llm.model'] == 'gpt-4'
assert data['agent_settings']['llm.base_url'] == 'https://api.example.com'
assert data['agent_settings']['max_iterations'] == 50
assert data['status'] == 'active'
@pytest.mark.asyncio
@@ -3169,9 +3193,7 @@ class TestGetMeEndpoint:
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data['llm_model'] is None
assert data['llm_base_url'] is None
assert data['max_iterations'] is None
assert data['agent_settings'] == {'schema_version': 1}
@pytest.mark.asyncio
async def test_get_me_with_admin_role(self, mock_me_app, test_user_id, test_org_id):
@@ -3200,35 +3222,6 @@ class TestGetMeEndpoint:
data = response.json()
assert data['role'] == 'admin'
@pytest.mark.asyncio
async def test_get_me_masks_byor_api_key(
self, mock_me_app, test_user_id, test_org_id
):
"""GIVEN: User has an llm_api_key_for_byor set
WHEN: GET /api/organizations/{org_id}/me is called
THEN: The llm_api_key_for_byor field is also masked
"""
me_response = self._make_me_response(
org_id=test_org_id,
user_id=test_user_id,
llm_api_key_for_byor='****-key', # Masked key
)
with patch(
'server.routes.orgs.OrgMemberService.get_me',
new_callable=AsyncMock,
return_value=me_response,
):
client = TestClient(mock_me_app)
response = client.get(f'/api/organizations/{test_org_id}/me')
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data['llm_api_key_for_byor'] != 'sk-byor-secret-key'
assert (
data['llm_api_key_for_byor'] is None or '**' in data['llm_api_key_for_byor']
)
@pytest.mark.asyncio
async def test_get_me_role_not_found_returns_500(self, mock_me_app, test_org_id):
"""GIVEN: Role lookup fails (data integrity issue)
@@ -3315,7 +3308,10 @@ async def test_switch_org_success(mock_app_with_get_user_id):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
},
)
with (

View File

@@ -34,15 +34,15 @@ def mock_org(org_id):
"""Create a mock organization with LLM settings."""
org = MagicMock(spec=Org)
org.id = org_id
org.default_llm_model = 'claude-3'
org.default_llm_base_url = 'https://api.anthropic.com'
org.agent_settings = {
'schema_version': 1,
'llm.model': 'claude-3',
'llm.base_url': 'https://api.anthropic.com',
'agent': 'CodeActAgent',
'verification.confirmation_mode': True,
'max_iterations': 50,
}
org.search_api_key = None
org.agent = 'CodeActAgent'
org.confirmation_mode = True
org.security_analyzer = None
org.enable_default_condenser = True
org.condenser_max_size = None
org.default_max_iterations = 50
return org
@@ -78,8 +78,8 @@ async def test_get_org_llm_settings_success(
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'claude-3'
assert result.agent == 'CodeActAgent'
assert result.agent_settings['llm.model'] == 'claude-3'
assert result.agent_settings['agent'] == 'CodeActAgent'
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
@@ -134,20 +134,21 @@ async def test_update_org_llm_settings_success(
# Arrange
updated_org = MagicMock(spec=Org)
updated_org.id = mock_org.id
updated_org.default_llm_model = 'new-model'
updated_org.default_llm_base_url = None
updated_org.agent_settings = {
'schema_version': 1,
'llm.model': 'new-model',
'agent': 'CodeActAgent',
'verification.confirmation_mode': False,
'max_iterations': 100,
}
updated_org.search_api_key = None
updated_org.agent = 'CodeActAgent'
updated_org.confirmation_mode = False
updated_org.security_analyzer = None
updated_org.enable_default_condenser = True
updated_org.condenser_max_size = None
updated_org.default_max_iterations = 100
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
confirmation_mode=False,
default_max_iterations=100,
agent_settings={
'llm.model': 'new-model',
'verification.confirmation_mode': False,
'max_iterations': 100,
}
)
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
@@ -159,9 +160,9 @@ async def test_update_org_llm_settings_success(
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'new-model'
assert result.confirmation_mode is False
assert result.default_max_iterations == 100
assert result.agent_settings['llm.model'] == 'new-model'
assert result.agent_settings['verification.confirmation_mode'] is False
assert result.agent_settings['max_iterations'] == 100
mock_store.update_org_llm_settings.assert_called_once_with(
org_id=mock_org.id,
update_data=update_data,
@@ -189,7 +190,7 @@ async def test_update_org_llm_settings_no_changes(
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'claude-3'
assert result.agent_settings['llm.model'] == 'claude-3'
mock_store.update_org_llm_settings.assert_not_called()
@@ -203,7 +204,7 @@ async def test_update_org_llm_settings_org_not_found(
THEN: OrgNotFoundError is raised
"""
# Arrange
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
update_data = OrgLLMSettingsUpdate(agent_settings={'llm.model': 'new-model'})
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)

View File

@@ -2224,10 +2224,12 @@ class TestOrgMemberServiceGetMe:
member.user_id = current_user_id
member.role_id = 1
member.llm_api_key = SecretStr('sk-test-key-12345')
member.llm_api_key_for_byor = None
member.llm_model = 'gpt-4'
member.llm_base_url = 'https://api.example.com'
member.max_iterations = 50
member.agent_settings = {
'schema_version': 1,
'llm.model': 'gpt-4',
'llm.base_url': 'https://api.example.com',
'max_iterations': 50,
}
member.status = 'active'
return member
@@ -2275,8 +2277,8 @@ class TestOrgMemberServiceGetMe:
assert result.user_id == str(current_user_id)
assert result.email == 'test@example.com'
assert result.role == 'owner'
assert result.llm_model == 'gpt-4'
assert result.max_iterations == 50
assert result.agent_settings['llm.model'] == 'gpt-4'
assert result.agent_settings['max_iterations'] == 50
assert result.status == 'active'
@pytest.mark.asyncio

View 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

View File

@@ -47,7 +47,10 @@ async def test_get_current_org_by_user_id_success(async_session_maker):
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='claude-3')
org = Org(
name='test-org',
agent_settings={'schema_version': 1, 'llm.model': 'claude-3'},
)
session.add(org)
await session.flush()
@@ -63,7 +66,7 @@ async def test_get_current_org_by_user_id_success(async_session_maker):
# Assert
assert result is not None
assert result.name == 'test-org'
assert result.default_llm_model == 'claude-3'
assert result.agent_settings['llm.model'] == 'claude-3'
@pytest.mark.asyncio
@@ -94,15 +97,20 @@ async def test_update_org_llm_settings_success(async_session_maker):
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='old-model')
org = Org(
name='test-org',
agent_settings={'schema_version': 1, 'llm.model': 'old-model'},
)
session.add(org)
await session.commit()
org_id = org.id
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
agent='CodeActAgent',
confirmation_mode=True,
agent_settings={
'llm.model': 'new-model',
'agent': 'CodeActAgent',
'verification.confirmation_mode': True,
}
)
# Act
@@ -115,9 +123,9 @@ async def test_update_org_llm_settings_success(async_session_maker):
# Assert
assert result is not None
assert result.default_llm_model == 'new-model'
assert result.agent == 'CodeActAgent'
assert result.confirmation_mode is True
assert result.agent_settings['llm.model'] == 'new-model'
assert result.agent_settings['agent'] == 'CodeActAgent'
assert result.agent_settings['verification.confirmation_mode'] is True
@pytest.mark.asyncio
@@ -129,7 +137,7 @@ async def test_update_org_llm_settings_org_not_found(async_session_maker):
"""
# Arrange
non_existent_org_id = uuid.uuid4()
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
update_data = OrgLLMSettingsUpdate(agent_settings={'llm.model': 'new-model'})
# Act
async with async_session_maker() as session:
@@ -149,13 +157,16 @@ async def test_update_org_llm_settings_propagates_to_members(async_session_maker
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='old-model')
org = Org(
name='test-org',
agent_settings={'schema_version': 1, 'llm.model': 'old-model'},
)
session.add(org)
await session.commit()
org_id = org.id
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
agent_settings={'llm.model': 'new-model'},
llm_api_key='new-api-key',
)
@@ -171,5 +182,5 @@ async def test_update_org_llm_settings_propagates_to_members(async_session_maker
mock_update_members.assert_called_once()
call_args = mock_update_members.call_args
member_settings = call_args[0][2]
assert member_settings.llm_model == 'new-model'
assert member_settings.agent_settings is None
assert member_settings.llm_api_key == 'new-api-key'

View File

@@ -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,
)
@@ -76,13 +76,11 @@ async def async_session_with_users(async_engine) -> AsyncGenerator[AsyncSession,
org1 = Org(
id=ORG1_ID,
name='test-org-1',
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
org2 = Org(
id=ORG2_ID,
name='test-org-2',
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
db_session.add(org1)
@@ -663,3 +661,644 @@ 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
class TestApiKeyOrgIdHandling:
"""Test suite for API key organization ID handling in save_app_conversation_info.
These tests verify that when a conversation is created using API key authentication,
the conversation is associated with the API key's bound organization, not the user's
currently selected organization.
"""
@pytest.mark.asyncio
async def test_api_key_org_id_used_when_available(
self,
async_session_with_users: AsyncSession,
):
"""Test that API key's org_id is used when saving conversation via API key auth.
This tests the main bug fix: when a user creates an API key in Personal Workspace,
then switches to OpenHands org in browser, and uses the API key to create a
conversation, the conversation should be saved in Personal Workspace (API key's org),
not OpenHands (user's current org).
"""
from dataclasses import dataclass
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
# Create a mock UserAuth with API key org_id
@dataclass
class MockUserAuth:
user_id: str
api_key_org_id: UUID | None = None
async def get_user_id(self) -> str:
return self.user_id
def get_api_key_org_id(self) -> UUID | None:
return self.api_key_org_id
# Create a mock UserContext that wraps the MockUserAuth
@dataclass
class MockAuthUserContext:
user_auth: MockUserAuth
async def get_user_id(self) -> str | None:
return await self.user_auth.get_user_id()
# Simulate: User1's current org is ORG2, but API key is bound to ORG1
# First, update user1's current_org_id to ORG2
result = await async_session_with_users.execute(
select(User).where(User.id == USER1_ID)
)
user_to_update = result.scalars().first()
user_to_update.current_org_id = ORG2_ID # User is viewing ORG2
await async_session_with_users.commit()
async_session_with_users.expire_all()
# Create service with mock auth context where API key is bound to ORG1
mock_user_auth = MockUserAuth(
user_id=str(USER1_ID),
api_key_org_id=ORG1_ID, # API key created in ORG1
)
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
# Create and save a conversation
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_api_key_test',
title='API Key Created Conversation',
)
await service.save_app_conversation_info(conv_info)
# Verify: SAAS metadata should have ORG1 (API key's org), not ORG2 (user's current org)
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
assert (
saas_metadata.org_id == ORG1_ID
), 'Conversation should be in API key org (ORG1), not user current org (ORG2)'
@pytest.mark.asyncio
async def test_legacy_api_key_without_org_uses_user_current_org(
self,
async_session_with_users: AsyncSession,
):
"""Test that legacy API keys (without org_id) fall back to user's current org.
Legacy API keys created before the org_id feature was added will have
api_key_org_id = None. In this case, we should fall back to the user's
current_org_id.
"""
from dataclasses import dataclass
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
# Create a mock UserAuth with API key but NO org_id (legacy key)
@dataclass
class MockUserAuth:
user_id: str
api_key_org_id: UUID | None = None
async def get_user_id(self) -> str:
return self.user_id
def get_api_key_org_id(self) -> UUID | None:
return self.api_key_org_id
@dataclass
class MockAuthUserContext:
user_auth: MockUserAuth
async def get_user_id(self) -> str | None:
return await self.user_auth.get_user_id()
# Create service with mock auth context where API key has NO org_id
mock_user_auth = MockUserAuth(
user_id=str(USER1_ID),
api_key_org_id=None, # Legacy key without org binding
)
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
# Create and save a conversation
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_legacy_key_test',
title='Legacy API Key Conversation',
)
await service.save_app_conversation_info(conv_info)
# Verify: SAAS metadata should use user's current org (ORG1) as fallback
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
assert (
saas_metadata.org_id == ORG1_ID
), 'Legacy key should fall back to user current org (ORG1)'
@pytest.mark.asyncio
async def test_cookie_auth_without_api_key_uses_user_current_org(
self,
async_session_with_users: AsyncSession,
):
"""Test that cookie auth (no API key) uses user's current org.
When authenticated via browser cookie (no API key), there's no
get_api_key_org_id method, so we use user's current_org_id.
This is already tested by other tests using SpecifyUserContext,
but we explicitly test the case where user_context doesn't have user_auth.
"""
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
# Use SpecifyUserContext which doesn't have user_auth attribute
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
# Create and save a conversation
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_cookie_auth_test',
title='Cookie Auth Conversation',
)
await service.save_app_conversation_info(conv_info)
# Verify: SAAS metadata should use user's current org (ORG1)
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
assert (
saas_metadata.org_id == ORG1_ID
), 'Cookie auth should use user current org (ORG1)'
@pytest.mark.asyncio
async def test_api_key_org_isolation_cross_org_visibility(
self,
async_session_with_users: AsyncSession,
):
"""Test end-to-end: conversation created via API key is visible in correct org.
Simulates the full bug scenario:
1. Create conversation via API key (bound to ORG1)
2. User switches to ORG2
3. User should NOT see the conversation in ORG2
4. User switches back to ORG1
5. User should see the conversation in ORG1
"""
from dataclasses import dataclass
@dataclass
class MockUserAuth:
user_id: str
api_key_org_id: UUID | None = None
async def get_user_id(self) -> str:
return self.user_id
def get_api_key_org_id(self) -> UUID | None:
return self.api_key_org_id
@dataclass
class MockAuthUserContext:
user_auth: MockUserAuth
async def get_user_id(self) -> str | None:
return await self.user_auth.get_user_id()
# Step 1: Create conversation via API key bound to ORG1
mock_user_auth = MockUserAuth(
user_id=str(USER1_ID),
api_key_org_id=ORG1_ID,
)
mock_context = MockAuthUserContext(user_auth=mock_user_auth)
api_key_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_e2e_api_key',
title='E2E API Key Conversation',
)
await api_key_service.save_app_conversation_info(conv_info)
# Step 2: Switch user to ORG2 in browser session
result = await async_session_with_users.execute(
select(User).where(User.id == USER1_ID)
)
user_to_update = result.scalars().first()
user_to_update.current_org_id = ORG2_ID
await async_session_with_users.commit()
async_session_with_users.expire_all()
# Step 3: User in ORG2 should NOT see the conversation
user_service_org2 = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
page_org2 = await user_service_org2.search_app_conversation_info()
assert (
len(page_org2.items) == 0
), 'User in ORG2 should not see conversation created via API key in ORG1'
# Also verify get_app_conversation_info returns None
conv_from_org2 = await user_service_org2.get_app_conversation_info(conv_id)
assert (
conv_from_org2 is None
), 'User in ORG2 should not access conversation from ORG1'
# Step 4: Switch user back to ORG1
result = await async_session_with_users.execute(
select(User).where(User.id == USER1_ID)
)
user_to_update = result.scalars().first()
user_to_update.current_org_id = ORG1_ID
await async_session_with_users.commit()
async_session_with_users.expire_all()
# Step 5: User in ORG1 should see the conversation
user_service_org1 = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
page_org1 = await user_service_org1.search_app_conversation_info()
assert (
len(page_org1.items) == 1
), 'User in ORG1 should see conversation created via API key in ORG1'
assert page_org1.items[0].id == conv_id
assert page_org1.items[0].title == 'E2E API Key Conversation'
# Also verify get_app_conversation_info works
conv_from_org1 = await user_service_org1.get_app_conversation_info(conv_id)
assert conv_from_org1 is not None
assert conv_from_org1.id == conv_id

View File

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

View File

@@ -5,7 +5,7 @@ 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
from storage.api_key_store import ApiKeyStore, ApiKeyValidationResult
@pytest.fixture
@@ -110,8 +110,8 @@ async def test_create_api_key(
@pytest.mark.asyncio
async def test_validate_api_key_valid(api_key_store, async_session_maker):
"""Test validating a valid API key."""
# Setup - create an API key in the database
"""Test validating a valid API key returns user_id and org_id."""
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
api_key_value = 'test-api-key'
@@ -126,13 +126,19 @@ 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
# Act
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
# Assert
assert isinstance(result, 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
@@ -197,7 +203,7 @@ async def test_validate_api_key_valid_timezone_naive(
api_key_store, async_session_maker
):
"""Test validating a valid API key with timezone-naive datetime from database."""
# Setup - create a valid API key with timezone-naive datetime (future date)
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
api_key_value = 'test-valid-naive-key'
@@ -214,12 +220,44 @@ async def test_validate_api_key_valid_timezone_naive(
session.add(key_record)
await session.commit()
# Execute - patch a_session_maker to use test's async session maker
# Act
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
# Assert
assert isinstance(result, ApiKeyValidationResult)
assert result.user_id == user_id
assert result.org_id == org_id
@pytest.mark.asyncio
async def test_validate_api_key_legacy_without_org_id(
api_key_store, async_session_maker
):
"""Test validating a legacy API key without org_id returns None for org_id."""
# Arrange
user_id = str(uuid.uuid4())
api_key_value = 'test-legacy-key-no-org'
async with async_session_maker() as session:
key_record = ApiKey(
key=api_key_value,
user_id=user_id,
org_id=None, # Legacy key without org binding
name='Legacy Key',
)
session.add(key_record)
await session.commit()
# Act
with patch('storage.api_key_store.a_session_maker', async_session_maker):
result = await api_key_store.validate_api_key(api_key_value)
# Assert
assert isinstance(result, ApiKeyValidationResult)
assert result is not None
assert result.user_id == user_id
assert result.org_id is None
@pytest.mark.asyncio

View File

@@ -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,
@@ -846,10 +846,108 @@ async def test_keycloak_callback_duplicate_email_detected(
assert exc_info.value.detail == 'duplicate_email'
# Note: test_keycloak_callback_duplicate_email_deletion_fails was removed as part of
# the user authorization refactor. The Keycloak user deletion logic for duplicate emails
# has been removed from keycloak_callback. If this behavior needs to be restored,
# it should be implemented in the DefaultUserAuthorizer or handled separately.
@pytest.mark.asyncio
async def test_keycloak_callback_duplicate_email_deletes_new_keycloak_user(
mock_request, create_keycloak_user_info
):
"""Test that new Keycloak user is deleted when duplicate email is detected.
When a user attempts to sign up with a +modifier email (e.g., joe+1@example.com)
and an account with the base email already exists, the newly created Keycloak
user should be deleted to prevent orphaned accounts from blocking future sign-ins.
"""
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.UserStore') as mock_user_store,
):
# Arrange
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value=create_keycloak_user_info(
sub='new_user_id',
preferred_username='test_user',
email='joe+1@example.com',
identity_provider='github',
)
)
mock_token_manager.delete_keycloak_user = AsyncMock(return_value=True)
# User does NOT exist in UserStore (new signup attempt)
mock_user_store.get_user_by_id = AsyncMock(return_value=None)
# Create mock authorizer that returns duplicate_email error
mock_authorizer = create_mock_user_authorizer(
success=False, error_detail='duplicate_email'
)
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await keycloak_callback(
code='test_code',
state='test_state',
request=mock_request,
user_authorizer=mock_authorizer,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert exc_info.value.detail == 'duplicate_email'
# Keycloak user should be deleted since user doesn't exist in UserStore
mock_token_manager.delete_keycloak_user.assert_called_once_with('new_user_id')
@pytest.mark.asyncio
async def test_keycloak_callback_duplicate_email_preserves_existing_user(
mock_request, create_keycloak_user_info
):
"""Test that existing users are not deleted when duplicate email is detected.
When an existing user signs in and duplicate email is detected (e.g., because
another account with the same base email was created while duplicate checking
was disabled), the existing user's Keycloak account should NOT be deleted.
"""
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.UserStore') as mock_user_store,
):
# Arrange
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value=create_keycloak_user_info(
sub='existing_user_id',
preferred_username='test_user',
email='joe@example.com',
identity_provider='github',
)
)
mock_token_manager.delete_keycloak_user = AsyncMock(return_value=True)
# User EXISTS in UserStore (legitimate existing user)
mock_existing_user = MagicMock()
mock_existing_user.id = 'existing_user_id'
mock_user_store.get_user_by_id = AsyncMock(return_value=mock_existing_user)
# Create mock authorizer that returns duplicate_email error
mock_authorizer = create_mock_user_authorizer(
success=False, error_detail='duplicate_email'
)
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await keycloak_callback(
code='test_code',
state='test_state',
request=mock_request,
user_authorizer=mock_authorizer,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert exc_info.value.detail == 'duplicate_email'
# Keycloak user should NOT be deleted since user exists in UserStore
mock_token_manager.delete_keycloak_user.assert_not_called()
@pytest.mark.asyncio
@@ -1036,79 +1134,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()."""

View File

@@ -13,6 +13,7 @@ from server.auth.authorization import (
ROLE_PERMISSIONS,
Permission,
RoleName,
get_api_key_org_id_from_request,
get_role_permissions,
get_user_org_role,
has_permission,
@@ -444,6 +445,15 @@ class TestGetUserOrgRole:
# =============================================================================
def _create_mock_request(api_key_org_id=None):
"""Helper to create a mock request with optional api_key_org_id."""
mock_request = MagicMock()
mock_user_auth = MagicMock()
mock_user_auth.get_api_key_org_id.return_value = api_key_org_id
mock_request.state.user_auth = mock_user_auth
return mock_request
class TestRequirePermission:
"""Tests for require_permission dependency factory."""
@@ -456,6 +466,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -465,7 +476,9 @@ class TestRequirePermission:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
@@ -476,10 +489,11 @@ class TestRequirePermission:
THEN: 401 Unauthorized is raised
"""
org_id = uuid4()
mock_request = _create_mock_request()
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=None)
await permission_checker(request=mock_request, org_id=org_id, user_id=None)
assert exc_info.value.status_code == 401
assert 'not authenticated' in exc_info.value.detail.lower()
@@ -493,6 +507,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
with patch(
'server.auth.authorization.get_user_org_role',
@@ -500,7 +515,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'not a member' in exc_info.value.detail.lower()
@@ -514,6 +531,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -524,7 +542,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'delete_organization' in exc_info.value.detail.lower()
@@ -538,6 +558,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'owner'
@@ -547,7 +568,9 @@ class TestRequirePermission:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
@@ -559,6 +582,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -569,7 +593,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
@@ -582,6 +608,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -595,7 +622,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException):
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
mock_logger.warning.assert_called()
call_args = mock_logger.warning.call_args
@@ -611,6 +640,7 @@ class TestRequirePermission:
THEN: User ID is returned
"""
user_id = str(uuid4())
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -620,7 +650,9 @@ class TestRequirePermission:
AsyncMock(return_value=mock_role),
) as mock_get_role:
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(org_id=None, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=None, user_id=user_id
)
assert result == user_id
mock_get_role.assert_called_once_with(user_id, None)
@@ -632,6 +664,7 @@ class TestRequirePermission:
THEN: HTTPException with 403 status is raised
"""
user_id = str(uuid4())
mock_request = _create_mock_request()
with patch(
'server.auth.authorization.get_user_org_role',
@@ -639,7 +672,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=None, user_id=user_id)
await permission_checker(
request=mock_request, org_id=None, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'not a member' in exc_info.value.detail
@@ -662,6 +697,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -671,7 +707,9 @@ class TestPermissionScenarios:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.MANAGE_SECRETS)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
@@ -683,6 +721,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -695,7 +734,9 @@ class TestPermissionScenarios:
Permission.INVITE_USER_TO_ORGANIZATION
)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
@@ -708,6 +749,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -719,7 +761,9 @@ class TestPermissionScenarios:
permission_checker = require_permission(
Permission.INVITE_USER_TO_ORGANIZATION
)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
@@ -731,6 +775,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -741,7 +786,9 @@ class TestPermissionScenarios:
):
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
@@ -754,6 +801,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'owner'
@@ -763,5 +811,200 @@ class TestPermissionScenarios:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
# =============================================================================
# Tests for API key organization validation
# =============================================================================
class TestApiKeyOrgValidation:
"""Tests for API key organization binding validation in require_permission."""
@pytest.mark.asyncio
async def test_allows_access_when_api_key_org_matches_target_org(self):
"""
GIVEN: API key with org_id that matches the target org_id in the request
WHEN: Permission checker is called
THEN: User ID is returned (access allowed)
"""
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=org_id)
mock_role = MagicMock()
mock_role.name = 'admin'
# Act & Assert
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
async def test_denies_access_when_api_key_org_mismatches_target_org(self):
"""
GIVEN: API key created for Org A, but user tries to access Org B
WHEN: Permission checker is called
THEN: 403 Forbidden is raised with org mismatch message
"""
# Arrange
user_id = str(uuid4())
api_key_org_id = uuid4() # Org A - where API key was created
target_org_id = uuid4() # Org B - where user is trying to access
mock_request = _create_mock_request(api_key_org_id=api_key_org_id)
# Act & Assert
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(
request=mock_request, org_id=target_org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert (
'API key is not authorized for this organization' in exc_info.value.detail
)
@pytest.mark.asyncio
async def test_allows_access_for_legacy_api_key_without_org_binding(self):
"""
GIVEN: Legacy API key without org_id binding (org_id is None)
WHEN: Permission checker is called
THEN: Falls through to normal permission check (backward compatible)
"""
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=None)
mock_role = MagicMock()
mock_role.name = 'admin'
# Act & Assert
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
async def test_allows_access_for_cookie_auth_without_api_key_org_id(self):
"""
GIVEN: Cookie-based authentication (no api_key_org_id in user_auth)
WHEN: Permission checker is called
THEN: Falls through to normal permission check
"""
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=None)
mock_role = MagicMock()
mock_role.name = 'admin'
# Act & Assert
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
async def test_logs_warning_on_api_key_org_mismatch(self):
"""
GIVEN: API key org_id doesn't match target org_id
WHEN: Permission checker is called
THEN: Warning is logged with org mismatch details
"""
# Arrange
user_id = str(uuid4())
api_key_org_id = uuid4()
target_org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=api_key_org_id)
# Act & Assert
with patch('server.auth.authorization.logger') as mock_logger:
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException):
await permission_checker(
request=mock_request, org_id=target_org_id, user_id=user_id
)
mock_logger.warning.assert_called()
call_args = mock_logger.warning.call_args
assert call_args[1]['extra']['user_id'] == user_id
assert call_args[1]['extra']['api_key_org_id'] == str(api_key_org_id)
assert call_args[1]['extra']['target_org_id'] == str(target_org_id)
class TestGetApiKeyOrgIdFromRequest:
"""Tests for get_api_key_org_id_from_request helper function."""
@pytest.mark.asyncio
async def test_returns_org_id_when_user_auth_has_api_key_org_id(self):
"""
GIVEN: Request with user_auth that has api_key_org_id
WHEN: get_api_key_org_id_from_request is called
THEN: Returns the api_key_org_id
"""
# Arrange
org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=org_id)
# Act
result = await get_api_key_org_id_from_request(mock_request)
# Assert
assert result == org_id
@pytest.mark.asyncio
async def test_returns_none_when_user_auth_has_no_api_key_org_id(self):
"""
GIVEN: Request with user_auth that has no api_key_org_id (cookie auth)
WHEN: get_api_key_org_id_from_request is called
THEN: Returns None
"""
# Arrange
mock_request = _create_mock_request(api_key_org_id=None)
# Act
result = await get_api_key_org_id_from_request(mock_request)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_no_user_auth_in_request(self):
"""
GIVEN: Request without user_auth in state
WHEN: get_api_key_org_id_from_request is called
THEN: Returns None
"""
# Arrange
mock_request = MagicMock()
mock_request.state.user_auth = None
# Act
result = await get_api_key_org_id_from_request(mock_request)
# Assert
assert result is None

View File

@@ -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
@@ -75,7 +75,6 @@ async def test_org(async_session_maker):
id=org_id,
name=f'test-org-{org_id}',
org_version=ORG_SETTINGS_VERSION,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
session.add(org)
@@ -264,7 +263,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 +280,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 +318,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 +390,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 +444,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 +501,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 +516,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 +587,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 +612,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',
)

View File

@@ -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,107 @@ from storage.user_settings import UserSettings
from openhands.server.settings import Settings
def _agent_value(settings: Settings, key: str):
return settings.get_agent_setting(key)
def _secret_value(settings: Settings, key: str):
secret = settings.get_secret_agent_setting(key)
return secret.get_secret_value() if secret else None
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."""
@@ -28,20 +131,22 @@ class TestLiteLlmManager:
def mock_settings(self):
"""Create a mock Settings object."""
settings = Settings()
settings.agent = 'TestAgent'
settings.llm_model = 'test-model'
settings.llm_api_key = SecretStr('test-key')
settings.llm_base_url = 'http://test.com'
settings.set_agent_setting('agent', 'TestAgent')
settings.set_agent_setting('llm.model', 'test-model')
settings.set_agent_setting('llm.api_key', SecretStr('test-key'))
settings.set_agent_setting('llm.base_url', 'http://test.com')
return settings
@pytest.fixture
def mock_user_settings(self):
"""Create a mock UserSettings object."""
user_settings = UserSettings()
user_settings.agent = 'TestAgent'
user_settings.llm_model = 'test-model'
user_settings.agent_settings = {
'agent': 'TestAgent',
'llm.model': 'test-model',
'llm.base_url': 'http://test.com',
}
user_settings.llm_api_key = SecretStr('test-key')
user_settings.llm_base_url = 'http://test.com'
user_settings.user_version = 4 # Set version to avoid None comparison
return user_settings
@@ -134,10 +239,14 @@ class TestLiteLlmManager:
)
assert result is not None
assert result.agent == 'CodeActAgent'
assert result.llm_model == get_default_litellm_model()
assert result.llm_api_key.get_secret_value() == 'test-key'
assert result.llm_base_url == 'http://test.com'
assert _agent_value(result, 'agent') == 'CodeActAgent'
assert _agent_value(
result, 'llm.model'
) == get_default_litellm_model().replace(
'litellm_proxy/', 'openhands/'
)
assert _secret_value(result, 'llm.api_key') == 'test-key'
assert _agent_value(result, 'llm.base_url') == 'http://test.com'
@pytest.mark.asyncio
async def test_create_entries_cloud_deployment(self, mock_settings, mock_response):
@@ -145,6 +254,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 +271,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()
@@ -175,13 +290,15 @@ class TestLiteLlmManager:
)
assert result is not None
assert result.agent == 'CodeActAgent'
assert result.llm_model == get_default_litellm_model()
assert result.llm_api_key.get_secret_value() == 'test-api-key'
assert result.llm_base_url == 'http://test.com'
assert _agent_value(result, 'agent') == 'CodeActAgent'
assert _agent_value(
result, 'llm.model'
) == get_default_litellm_model().replace('litellm_proxy/', 'openhands/')
assert _secret_value(result, 'llm.api_key') == 'test-api-key'
assert _agent_value(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 +317,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 +351,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 +367,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 +404,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 +412,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."""
@@ -358,10 +547,14 @@ class TestLiteLlmManager:
# migrate_entries returns the user_settings unchanged
assert result is not None
assert result.agent == 'TestAgent'
assert result.llm_model == 'test-model'
effective_settings = result.to_settings()
assert _agent_value(effective_settings, 'agent') == 'TestAgent'
assert _agent_value(effective_settings, 'llm.model') == 'test-model'
assert result.llm_api_key.get_secret_value() == 'test-key'
assert result.llm_base_url == 'http://test.com'
assert (
_agent_value(effective_settings, 'llm.base_url')
== 'http://test.com'
)
@pytest.mark.asyncio
async def test_migrate_entries_no_user_found(self, mock_user_settings):
@@ -482,10 +675,19 @@ class TestLiteLlmManager:
# migrate_entries returns the user_settings unchanged
assert result is not None
assert result.agent == 'TestAgent'
assert result.llm_model == 'test-model'
effective_settings = result.to_settings()
assert (
_agent_value(effective_settings, 'agent') == 'TestAgent'
)
assert (
_agent_value(effective_settings, 'llm.model')
== 'test-model'
)
assert result.llm_api_key.get_secret_value() == 'test-key'
assert result.llm_base_url == 'http://test.com'
assert (
_agent_value(effective_settings, 'llm.base_url')
== 'http://test.com'
)
# Verify migration steps were called:
# - 2 GET requests: _get_user, _get_user_keys
@@ -567,8 +769,9 @@ class TestLiteLlmManager:
result.llm_api_key.get_secret_value()
== 'new-generated-key'
)
assert result.llm_api_key_for_byor_secret is not None
assert (
result.llm_api_key_for_byor.get_secret_value()
result.llm_api_key_for_byor_secret.get_secret_value()
== 'new-generated-key'
)
@@ -687,15 +890,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 +908,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 +920,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 +1006,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 +1153,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 +1165,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'},
@@ -1665,7 +2062,10 @@ class TestLiteLlmManager:
# downgrade_entries returns the user_settings
assert result is not None
assert result.agent == 'TestAgent'
assert (
_agent_value(result.to_settings(), 'agent')
== 'TestAgent'
)
# Verify downgrade steps were called:
# GET requests:
@@ -1699,7 +2099,7 @@ class TestLiteLlmManager:
# In local deployment, should return user_settings without
# making any LiteLLM calls
assert result is not None
assert result.agent == 'TestAgent'
assert _agent_value(result.to_settings(), 'agent') == 'TestAgent'
class TestGetAllKeysForUser:
@@ -2018,3 +2418,195 @@ class TestVerifyExistingKey:
openhands_type=True,
)
assert result is False
class TestBudgetPayloadHandling:
"""Test cases for budget field handling in API payloads.
These tests verify that when max_budget is None, the budget field is NOT
included in the JSON payload (which tells LiteLLM to disable budget
enforcement), and when max_budget has a value, it IS included.
"""
@pytest.mark.asyncio
async def test_create_team_excludes_max_budget_when_none(self):
"""Test that _create_team does NOT include max_budget when it is None."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._create_team(
mock_client,
team_alias='test-team',
team_id='test-team-id',
max_budget=None, # None = no budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify URL
assert call_args[0][0] == 'http://test.com/team/new'
# Verify that max_budget is NOT in the JSON payload
json_payload = call_args[1]['json']
assert 'max_budget' not in json_payload, (
'max_budget should NOT be in payload when None '
'(omitting it tells LiteLLM to disable budget enforcement)'
)
@pytest.mark.asyncio
async def test_create_team_includes_max_budget_when_set(self):
"""Test that _create_team includes max_budget when it has a value."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._create_team(
mock_client,
team_alias='test-team',
team_id='test-team-id',
max_budget=100.0, # Explicit budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify that max_budget IS in the JSON payload with the correct value
json_payload = call_args[1]['json']
assert (
'max_budget' in json_payload
), 'max_budget should be in payload when set to a value'
assert json_payload['max_budget'] == 100.0
@pytest.mark.asyncio
async def test_add_user_to_team_excludes_max_budget_when_none(self):
"""Test that _add_user_to_team does NOT include max_budget_in_team when None."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._add_user_to_team(
mock_client,
keycloak_user_id='test-user-id',
team_id='test-team-id',
max_budget=None, # None = no budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify URL
assert call_args[0][0] == 'http://test.com/team/member_add'
# Verify that max_budget_in_team is NOT in the JSON payload
json_payload = call_args[1]['json']
assert 'max_budget_in_team' not in json_payload, (
'max_budget_in_team should NOT be in payload when None '
'(omitting it tells LiteLLM to disable budget enforcement)'
)
@pytest.mark.asyncio
async def test_add_user_to_team_includes_max_budget_when_set(self):
"""Test that _add_user_to_team includes max_budget_in_team when set."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._add_user_to_team(
mock_client,
keycloak_user_id='test-user-id',
team_id='test-team-id',
max_budget=50.0, # Explicit budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify that max_budget_in_team IS in the JSON payload
json_payload = call_args[1]['json']
assert (
'max_budget_in_team' in json_payload
), 'max_budget_in_team should be in payload when set to a value'
assert json_payload['max_budget_in_team'] == 50.0
@pytest.mark.asyncio
async def test_update_user_in_team_excludes_max_budget_when_none(self):
"""Test that _update_user_in_team does NOT include max_budget_in_team when None."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._update_user_in_team(
mock_client,
keycloak_user_id='test-user-id',
team_id='test-team-id',
max_budget=None, # None = no budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify URL
assert call_args[0][0] == 'http://test.com/team/member_update'
# Verify that max_budget_in_team is NOT in the JSON payload
json_payload = call_args[1]['json']
assert 'max_budget_in_team' not in json_payload, (
'max_budget_in_team should NOT be in payload when None '
'(omitting it tells LiteLLM to disable budget enforcement)'
)
@pytest.mark.asyncio
async def test_update_user_in_team_includes_max_budget_when_set(self):
"""Test that _update_user_in_team includes max_budget_in_team when set."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._update_user_in_team(
mock_client,
keycloak_user_id='test-user-id',
team_id='test-team-id',
max_budget=75.0, # Explicit budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify that max_budget_in_team IS in the JSON payload
json_payload = call_args[1]['json']
assert (
'max_budget_in_team' in json_payload
), 'max_budget_in_team should be in payload when set to a value'
assert json_payload['max_budget_in_team'] == 75.0

View File

@@ -5,10 +5,15 @@ from io import StringIO
from unittest.mock import patch
import pytest
from freezegun import freeze_time
from server.logger import format_stack, setup_json_logger
from openhands.core.logger import openhands_logger
FROZEN_TIMESTAMP = '2024-01-15T10:30:00+00:00'
# datetime.now().isoformat() doesn't include timezone info
FROZEN_TIMESTAMP_NO_TZ = '2024-01-15T10:30:00'
@pytest.fixture
def log_output():
@@ -21,20 +26,31 @@ def log_output():
class TestLogOutput:
@freeze_time(FROZEN_TIMESTAMP)
def test_info(self, log_output):
logger, string_io = log_output
logger.info('Test message')
output = json.loads(string_io.getvalue())
assert output == {'message': 'Test message', 'severity': 'INFO'}
assert output == {
'message': 'Test message',
'severity': 'INFO',
'ts': FROZEN_TIMESTAMP,
}
@freeze_time(FROZEN_TIMESTAMP)
def test_error(self, log_output):
logger, string_io = log_output
logger.error('Test message')
output = json.loads(string_io.getvalue())
assert output == {'message': 'Test message', 'severity': 'ERROR'}
assert output == {
'message': 'Test message',
'severity': 'ERROR',
'ts': FROZEN_TIMESTAMP,
}
@freeze_time(FROZEN_TIMESTAMP)
def test_extra_fields(self, log_output):
logger, string_io = log_output
@@ -44,6 +60,7 @@ class TestLogOutput:
'key': '..val..',
'message': 'Test message',
'severity': 'INFO',
'ts': FROZEN_TIMESTAMP,
}
def test_format_stack(self):
@@ -257,6 +274,7 @@ class TestLogOutput:
]
assert formatted == expected
@freeze_time(FROZEN_TIMESTAMP)
def test_filtering(self):
# Ensure that secret values are still filtered
string_io = StringIO()
@@ -266,4 +284,62 @@ class TestLogOutput:
):
openhands_logger.info('The secret key was supersecretvalue')
output = json.loads(string_io.getvalue())
assert output == {'message': 'The secret key was ******', 'severity': 'INFO'}
assert output == {
'message': 'The secret key was ******',
'severity': 'INFO',
'ts': FROZEN_TIMESTAMP,
}
@freeze_time(FROZEN_TIMESTAMP)
def test_console_serializer_uses_ts_not_timestamp(self):
"""When LOG_JSON_FOR_CONSOLE=1, use 'ts' from custom_json_serializer, not 'timestamp'."""
import server.logger as logger_module
string_io = StringIO()
logger = logging.Logger('test_console')
# Patch LOG_JSON_FOR_CONSOLE to 1 for both setup_json_logger and custom_json_serializer
with patch.object(logger_module, 'LOG_JSON_FOR_CONSOLE', 1):
setup_json_logger(logger, 'INFO', _out=string_io)
logger.info('Test console message')
# Parse output - LOG_JSON_FOR_CONSOLE pretty-prints JSON across multiple lines
output = json.loads(string_io.getvalue())
# Should have 'ts' from custom_json_serializer but NOT 'timestamp'
assert 'ts' in output
assert 'timestamp' not in output
assert output['message'] == 'Test console message'
assert output['severity'] == 'INFO'
@freeze_time(FROZEN_TIMESTAMP)
def test_ts_not_duplicated_when_both_json_modes_enabled(self):
"""When both LOG_JSON=1 and LOG_JSON_FOR_CONSOLE=1, 'ts' should appear only once."""
import server.logger as logger_module
string_io = StringIO()
logger = logging.Logger('test_both_modes')
# Patch both LOG_JSON and LOG_JSON_FOR_CONSOLE to 1
with (
patch.object(logger_module, 'LOG_JSON', True),
patch.object(logger_module, 'LOG_JSON_FOR_CONSOLE', 1),
):
setup_json_logger(logger, 'INFO', _out=string_io)
logger.info('Test both modes message')
raw_output = string_io.getvalue()
output = json.loads(raw_output)
# Should have exactly one 'ts' field (not duplicated)
assert 'ts' in output
assert 'timestamp' not in output
# Verify 'ts' appears only once in the raw output (not duplicated as key)
assert (
raw_output.count('"ts"') == 1
), f"'ts' should appear exactly once, found in: {raw_output}"
assert output['message'] == 'Test both modes message'
assert output['severity'] == 'INFO'
# When LOG_JSON_FOR_CONSOLE=1, custom_json_serializer uses datetime.now().isoformat()
# which doesn't include timezone info
assert output['ts'] == FROZEN_TIMESTAMP_NO_TZ

View File

@@ -10,6 +10,7 @@ from server.routes.org_invitation_models import (
)
from server.services.org_invitation_service import OrgInvitationService
from storage.org_invitation import OrgInvitation
from storage.org_store import OrgStore
class TestAcceptInvitationEmailValidation:
@@ -98,6 +99,9 @@ class TestAcceptInvitationEmailValidation:
mock_keycloak_user_info = {'email': 'alice@example.com'} # Email from Keycloak
mock_org = MagicMock()
mock_org.agent_settings = {'schema_version': 1, 'llm.model': 'test-model'}
with (
patch(
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
@@ -121,6 +125,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 +153,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 +223,9 @@ class TestAcceptInvitationEmailValidation:
mock_invitation.email = 'alice@example.com' # Lowercase in invitation
mock_org = MagicMock()
mock_org.agent_settings = {'schema_version': 1, 'llm.model': 'test-model'}
with (
patch(
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
@@ -234,6 +246,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 +266,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 +275,80 @@ 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.agent_settings = {
'schema_version': 1,
'llm.model': 'claude-sonnet-4',
'llm.base_url': 'https://api.anthropic.com',
'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.get_secret_agent_setting.return_value = 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 snapshots the org defaults onto
# the new membership row's canonical agent_settings blob.
mock_add_user.assert_called_once()
call_kwargs = mock_add_user.call_args.kwargs
assert call_kwargs['llm_api_key'] == 'test-key'
assert call_kwargs[
'agent_settings'
] == OrgStore.get_agent_settings_from_org(mock_org)
class TestCreateInvitationsBatch:
"""Test cases for batch invitation creation."""

View File

@@ -10,6 +10,69 @@ from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.user import User
from storage.user_settings import UserSettings
def test_get_kwargs_from_user_settings_uses_agent_settings_as_source_of_truth():
user_settings = UserSettings(
llm_api_key='legacy-secret',
agent_settings={
'schema_version': 1,
'agent': 'CodeActAgent',
'verification.confirmation_mode': True,
'verification.security_analyzer': 'llm',
'condenser.enabled': False,
'condenser.max_size': 128,
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
'llm.base_url': 'https://api.example.com',
'max_iterations': 42,
},
)
kwargs = OrgMemberStore.get_kwargs_from_user_settings(user_settings)
assert kwargs['llm_api_key'] == 'legacy-secret'
assert (
kwargs['agent_settings']
| {
'schema_version': 1,
'agent': 'CodeActAgent',
'verification.confirmation_mode': True,
'verification.security_analyzer': 'llm',
'condenser.enabled': False,
'condenser.max_size': 128,
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
'llm.base_url': 'https://api.example.com',
'max_iterations': 42,
}
== kwargs['agent_settings']
)
def test_get_agent_settings_from_org_member_uses_canonical_snapshot_json():
org_member = OrgMember(
org_id=uuid.uuid4(),
user_id=uuid.uuid4(),
role_id=1,
llm_api_key='legacy-secret',
agent_settings={
'schema_version': 1,
'agent': 'CodeActAgent',
'llm.model': 'member-model',
'llm.base_url': 'https://member.example.com',
'max_iterations': 42,
'verification.confirmation_mode': True,
},
)
assert OrgMemberStore.get_agent_settings_from_org_member(org_member) == {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm.model': 'member-model',
'llm.base_url': 'https://member.example.com',
'max_iterations': 42,
'verification.confirmation_mode': True,
}
@pytest.fixture
@@ -246,6 +309,46 @@ 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',
agent_settings={
'schema_version': 1,
'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.agent_settings['llm.model'] == 'claude-sonnet-4'
assert org_member.agent_settings['llm.base_url'] == 'https://api.example.com'
assert org_member.agent_settings['max_iterations'] == 50
@pytest.mark.asyncio
async def test_update_user_role_in_org(async_session_maker):
# Test updating user role in org
@@ -940,8 +1043,11 @@ async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
user_id=user.id,
role_id=role.id,
llm_api_key='test-key',
llm_model='old-model',
max_iterations=10,
agent_settings={
'schema_version': 1,
'llm.model': 'old-model',
'max_iterations': 10,
},
status='active',
)
session.add(org_member)
@@ -950,9 +1056,11 @@ async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
# Act
member_settings = OrgMemberLLMSettings(
llm_model='new-model',
llm_base_url='https://new-url.com',
max_iterations=50,
agent_settings={
'llm.model': 'new-model',
'llm.base_url': 'https://new-url.com',
'max_iterations': 50,
}
)
async with async_session_maker() as session:
@@ -970,9 +1078,9 @@ async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
)
updated_member = result.scalars().first()
assert updated_member.llm_model == 'new-model'
assert updated_member.llm_base_url == 'https://new-url.com'
assert updated_member.max_iterations == 50
assert updated_member.agent_settings['llm.model'] == 'new-model'
assert updated_member.agent_settings['llm.base_url'] == 'https://new-url.com'
assert updated_member.agent_settings['max_iterations'] == 50
@pytest.mark.asyncio
@@ -1005,7 +1113,10 @@ async def test_update_all_members_llm_settings_async_with_empty_settings(
user_id=user.id,
role_id=role.id,
llm_api_key='original-key',
llm_model='original-model',
agent_settings={
'schema_version': 1,
'llm.model': 'original-model',
},
status='active',
)
session.add(org_member)
@@ -1030,7 +1141,7 @@ async def test_update_all_members_llm_settings_async_with_empty_settings(
)
member = result.scalars().first()
assert member.llm_model == 'original-model'
assert member.agent_settings['llm.model'] == 'original-model'
# Original key should still be there (encrypted)
assert member._llm_api_key is not None
@@ -1078,9 +1189,9 @@ def test_org_member_llm_settings_has_updates_empty():
def test_org_llm_settings_update_apply_to_org_skips_llm_api_key():
"""
GIVEN: OrgLLMSettingsUpdate with llm_api_key and other fields set
GIVEN: OrgLLMSettingsUpdate with search_api_key and llm_api_key set
WHEN: apply_to_org() is called
THEN: llm_api_key is NOT applied to org, but other fields are
THEN: search_api_key is applied to org, but llm_api_key is not
"""
from unittest.mock import MagicMock
@@ -1088,18 +1199,17 @@ def test_org_llm_settings_update_apply_to_org_skips_llm_api_key():
# Arrange
settings = OrgLLMSettingsUpdate(
default_llm_model='claude-3',
search_api_key='applied-to-org',
llm_api_key='should-not-be-applied',
)
mock_org = MagicMock()
mock_org.default_llm_model = None
mock_org.search_api_key = None
# Act
settings.apply_to_org(mock_org)
# Assert
assert mock_org.default_llm_model == 'claude-3'
# llm_api_key should NOT be set on org (it's member-only)
assert mock_org.search_api_key == 'applied-to-org'
assert (
not hasattr(mock_org, 'llm_api_key')
or mock_org.llm_api_key != 'should-not-be-applied'
@@ -1116,7 +1226,7 @@ def test_org_llm_settings_update_get_member_updates_includes_llm_api_key():
# Arrange
settings = OrgLLMSettingsUpdate(
default_llm_model='claude-3',
agent_settings={'llm.model': 'claude-3'},
llm_api_key='new-member-key',
)
@@ -1126,7 +1236,7 @@ def test_org_llm_settings_update_get_member_updates_includes_llm_api_key():
# Assert
assert member_updates is not None
assert member_updates.llm_api_key == 'new-member-key'
assert member_updates.llm_model == 'claude-3'
assert member_updates.agent_settings is None
def test_org_llm_settings_update_get_member_updates_only_llm_api_key():
@@ -1146,7 +1256,7 @@ def test_org_llm_settings_update_get_member_updates_only_llm_api_key():
# Assert
assert member_updates is not None
assert member_updates.llm_api_key == 'member-key-only'
assert member_updates.llm_model is None
assert member_updates.agent_settings is None
def test_org_llm_settings_update_has_updates_with_llm_api_key():

View File

@@ -139,7 +139,12 @@ async def test_create_org_with_owner_success(
),
patch(
'storage.org_service.OrgStore.get_kwargs_from_settings',
return_value={},
return_value={
'agent_settings': {
'schema_version': 1,
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
}
},
),
patch(
'storage.org_service.OrgMemberStore.get_kwargs_from_settings',
@@ -160,7 +165,9 @@ async def test_create_org_with_owner_success(
assert result.contact_name == contact_name
assert result.contact_email == contact_email
assert result.org_version > 0 # Should be set to ORG_SETTINGS_VERSION
assert result.default_llm_model is not None # Should be set
assert (
result.agent_settings['llm.model'] == 'anthropic/claude-sonnet-4-5-20250929'
)
# Verify organization was persisted
with session_maker() as session:
@@ -1192,8 +1199,10 @@ async def test_update_org_with_permissions_success_llm_fields_admin(
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(
default_llm_model='claude-opus-4-5-20251101',
default_llm_base_url='https://api.anthropic.com',
agent_settings={
'llm.model': 'claude-opus-4-5-20251101',
'llm.base_url': 'https://api.anthropic.com',
}
)
with (
@@ -1210,8 +1219,8 @@ async def test_update_org_with_permissions_success_llm_fields_admin(
# Assert
assert result is not None
assert result.default_llm_model == 'claude-opus-4-5-20251101'
assert result.default_llm_base_url == 'https://api.anthropic.com'
assert result.agent_settings['llm.model'] == 'claude-opus-4-5-20251101'
assert result.agent_settings['llm.base_url'] == 'https://api.anthropic.com'
@pytest.mark.asyncio
@@ -1254,8 +1263,10 @@ async def test_update_org_with_permissions_success_llm_fields_owner(
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(
default_llm_model='claude-opus-4-5-20251101',
security_analyzer='enabled',
agent_settings={
'llm.model': 'claude-opus-4-5-20251101',
'verification.security_analyzer': 'enabled',
}
)
with (
@@ -1272,8 +1283,8 @@ async def test_update_org_with_permissions_success_llm_fields_owner(
# Assert
assert result is not None
assert result.default_llm_model == 'claude-opus-4-5-20251101'
assert result.security_analyzer == 'enabled'
assert result.agent_settings['llm.model'] == 'claude-opus-4-5-20251101'
assert result.agent_settings['verification.security_analyzer'] == 'enabled'
@pytest.mark.asyncio
@@ -1317,7 +1328,7 @@ async def test_update_org_with_permissions_success_mixed_fields_admin(
update_data = OrgUpdate(
contact_name='Jane Doe',
default_llm_model='claude-opus-4-5-20251101',
agent_settings={'llm.model': 'claude-opus-4-5-20251101'},
conversation_expiration=30,
)
@@ -1336,7 +1347,7 @@ async def test_update_org_with_permissions_success_mixed_fields_admin(
# Assert
assert result is not None
assert result.contact_name == 'Jane Doe'
assert result.default_llm_model == 'claude-opus-4-5-20251101'
assert result.agent_settings['llm.model'] == 'claude-opus-4-5-20251101'
assert result.conversation_expiration == 30
@@ -1520,7 +1531,7 @@ async def test_update_org_with_permissions_llm_fields_insufficient_permission(
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(default_llm_model='claude-opus-4-5-20251101')
update_data = OrgUpdate(agent_settings={'llm.model': 'claude-opus-4-5-20251101'})
with (
patch('storage.org_store.a_session_maker', async_session_maker),
@@ -1763,9 +1774,11 @@ async def test_update_org_with_permissions_only_llm_fields(
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(
default_llm_model='claude-opus-4-5-20251101',
security_analyzer='enabled',
agent='agent-mode',
agent_settings={
'llm.model': 'claude-opus-4-5-20251101',
'verification.security_analyzer': 'enabled',
'agent': 'agent-mode',
}
)
with (
@@ -1782,9 +1795,9 @@ async def test_update_org_with_permissions_only_llm_fields(
# Assert
assert result is not None
assert result.default_llm_model == 'claude-opus-4-5-20251101'
assert result.security_analyzer == 'enabled'
assert result.agent == 'agent-mode'
assert result.agent_settings['llm.model'] == 'claude-opus-4-5-20251101'
assert result.agent_settings['verification.security_analyzer'] == 'enabled'
assert result.agent_settings['agent'] == 'agent-mode'
@pytest.mark.asyncio

View File

@@ -97,7 +97,10 @@ async def test_update_org(async_session_maker, mock_litellm_api):
# Test updating org details
async with async_session_maker() as session:
# Create a test org
org = Org(name='test-org', agent='CodeActAgent')
org = Org(
name='test-org',
agent_settings={'schema_version': 1, 'agent': 'CodeActAgent'},
)
session.add(org)
await session.commit()
await session.refresh(org)
@@ -108,12 +111,16 @@ async def test_update_org(async_session_maker, mock_litellm_api):
patch('storage.org_store.a_session_maker', async_session_maker),
):
updated_org = await OrgStore.update_org(
org_id=org_id, kwargs={'name': 'updated-org', 'agent': 'PlannerAgent'}
org_id=org_id,
kwargs={
'name': 'updated-org',
'agent_settings': {'agent': 'PlannerAgent'},
},
)
assert updated_org is not None
assert updated_org.name == 'updated-org'
assert updated_org.agent == 'PlannerAgent'
assert updated_org.agent_settings['agent'] == 'PlannerAgent'
@pytest.mark.asyncio
@@ -135,15 +142,98 @@ async def test_create_org(async_session_maker, mock_litellm_api):
patch('storage.org_store.a_session_maker', async_session_maker),
):
org = await OrgStore.create_org(
kwargs={'name': 'new-org', 'agent': 'CodeActAgent'}
kwargs={
'name': 'new-org',
'agent_settings': {'schema_version': 1, 'agent': 'CodeActAgent'},
}
)
assert org is not None
assert org.name == 'new-org'
assert org.agent == 'CodeActAgent'
assert org.agent_settings['agent'] == 'CodeActAgent'
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
@@ -197,7 +287,7 @@ def test_get_kwargs_from_settings():
settings = Settings(
language='es',
agent='CodeActAgent',
llm_model='gpt-4',
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('test-key'),
enable_sound_notifications=True,
)
@@ -205,10 +295,13 @@ def test_get_kwargs_from_settings():
kwargs = OrgStore.get_kwargs_from_settings(settings)
# Should only include fields that exist in Org model
assert 'agent' in kwargs
assert 'default_llm_model' in kwargs
assert kwargs['agent'] == 'CodeActAgent'
assert kwargs['default_llm_model'] == 'gpt-4'
assert 'agent_settings' in kwargs
assert 'agent' not in kwargs
assert 'default_llm_model' not in kwargs
assert kwargs['agent_settings']['agent'] == 'CodeActAgent'
assert (
kwargs['agent_settings']['llm.model'] == 'anthropic/claude-sonnet-4-5-20250929'
)
# Should not include fields that don't exist in Org model
assert 'language' not in kwargs # language is not in Org model
assert 'llm_api_key' not in kwargs
@@ -299,7 +392,7 @@ async def test_persist_org_with_owner_returns_refreshed_org(
name='Test Org',
contact_name='Jane Doe',
contact_email='jane@example.com',
agent='CodeActAgent',
agent_settings={'schema_version': 1, 'agent': 'CodeActAgent'},
)
org_member = OrgMember(
@@ -317,7 +410,7 @@ async def test_persist_org_with_owner_returns_refreshed_org(
# Assert - verify the returned object has database-generated fields
assert result.id == org_id
assert result.name == 'Test Org'
assert result.agent == 'CodeActAgent'
assert result.agent_settings['agent'] == 'CodeActAgent'
# Verify org_version was set by create_org logic (if applicable)
assert hasattr(result, 'org_version')
@@ -400,9 +493,12 @@ async def test_persist_org_with_owner_with_multiple_fields(
name='Complex Org',
contact_name='Alice Smith',
contact_email='alice@example.com',
agent='CodeActAgent',
default_max_iterations=50,
confirmation_mode=True,
agent_settings={
'schema_version': 1,
'agent': 'CodeActAgent',
'max_iterations': 50,
'verification.confirmation_mode': True,
},
billing_margin=0.15,
)
@@ -412,8 +508,11 @@ async def test_persist_org_with_owner_with_multiple_fields(
role_id=1,
status='active',
llm_api_key='test-key',
max_iterations=100,
llm_model='gpt-4',
agent_settings={
'schema_version': 1,
'max_iterations': 100,
'llm.model': 'gpt-4',
},
)
# Act
@@ -422,25 +521,25 @@ async def test_persist_org_with_owner_with_multiple_fields(
# Assert
assert result.name == 'Complex Org'
assert result.agent == 'CodeActAgent'
assert result.default_max_iterations == 50
assert result.confirmation_mode is True
assert result.agent_settings['agent'] == 'CodeActAgent'
assert result.agent_settings['max_iterations'] == 50
assert result.agent_settings['verification.confirmation_mode'] is True
assert result.billing_margin == 0.15
# Verify persistence
async with async_session_maker() as session:
persisted_org = await session.get(Org, org_id)
assert persisted_org.agent == 'CodeActAgent'
assert persisted_org.default_max_iterations == 50
assert persisted_org.confirmation_mode is True
assert persisted_org.agent_settings['agent'] == 'CodeActAgent'
assert persisted_org.agent_settings['max_iterations'] == 50
assert persisted_org.agent_settings['verification.confirmation_mode'] is True
assert persisted_org.billing_margin == 0.15
result_query = await session.execute(
select(OrgMember).filter_by(org_id=org_id, user_id=user_id)
)
persisted_member = result_query.scalars().first()
assert persisted_member.max_iterations == 100
assert persisted_member.llm_model == 'gpt-4'
assert persisted_member.agent_settings['max_iterations'] == 100
assert persisted_member.agent_settings['llm.model'] == 'gpt-4'
@pytest.mark.asyncio
@@ -950,11 +1049,11 @@ async def test_update_org_llm_settings_async_with_llm_api_key():
mock_org = Org(
id=org_id,
name='Test Organization',
default_llm_model='old-model',
agent_settings={'schema_version': 1, 'llm.model': 'old-model'},
)
llm_settings = OrgLLMSettingsUpdate(
default_llm_model='new-model',
agent_settings={'llm.model': 'new-model'},
llm_api_key='new-member-api-key',
)
@@ -982,14 +1081,14 @@ async def test_update_org_llm_settings_async_with_llm_api_key():
# Assert - Org is returned
assert result is not None
assert result.default_llm_model == 'new-model'
assert result.agent_settings['llm.model'] == 'new-model'
# Assert - Member update was called with correct settings
mock_member_update.assert_called_once()
call_args = mock_member_update.call_args
member_settings = call_args[0][2] # Third positional arg is member_settings
assert member_settings.llm_api_key == 'new-member-api-key'
assert member_settings.llm_model == 'new-model'
assert member_settings.agent_settings is None
@pytest.mark.asyncio
@@ -1003,7 +1102,7 @@ async def test_update_org_llm_settings_async_org_not_found():
# Arrange
non_existent_org_id = uuid.uuid4()
llm_settings = OrgLLMSettingsUpdate(default_llm_model='new-model')
llm_settings = OrgLLMSettingsUpdate(agent_settings={'llm.model': 'new-model'})
# Mock the async session to return None for org
mock_session = AsyncMock()

View File

@@ -246,3 +246,82 @@ class TestSaasSecretsStore:
assert isinstance(store, SaasSecretsStore)
assert store.user_id == 'test-user-id'
assert store.config == mock_config
@pytest.mark.asyncio
@patch(
'storage.saas_secrets_store.UserStore.get_user_by_id',
new_callable=AsyncMock,
)
async def test_secrets_isolation_between_organizations(
self, mock_get_user, secrets_store, mock_user
):
"""Test that secrets from one organization are not deleted when storing
secrets in another organization. This reproduces a bug where switching
organizations and creating a secret would delete all secrets from the
user's personal workspace."""
org1_id = UUID('a1111111-1111-1111-1111-111111111111')
org2_id = UUID('b2222222-2222-2222-2222-222222222222')
# Store secrets in org1 (personal workspace)
mock_user.current_org_id = org1_id
mock_get_user.return_value = mock_user
org1_secrets = Secrets(
custom_secrets=MappingProxyType(
{
'personal_secret': CustomSecret.from_value(
{
'secret': 'personal_secret_value',
'description': 'My personal secret',
}
),
}
)
)
await secrets_store.store(org1_secrets)
# Verify org1 secrets are stored
loaded_org1 = await secrets_store.load()
assert loaded_org1 is not None
assert 'personal_secret' in loaded_org1.custom_secrets
assert (
loaded_org1.custom_secrets['personal_secret'].secret.get_secret_value()
== 'personal_secret_value'
)
# Switch to org2 and store secrets there
mock_user.current_org_id = org2_id
mock_get_user.return_value = mock_user
org2_secrets = Secrets(
custom_secrets=MappingProxyType(
{
'org2_secret': CustomSecret.from_value(
{'secret': 'org2_secret_value', 'description': 'Org2 secret'}
),
}
)
)
await secrets_store.store(org2_secrets)
# Verify org2 secrets are stored
loaded_org2 = await secrets_store.load()
assert loaded_org2 is not None
assert 'org2_secret' in loaded_org2.custom_secrets
assert (
loaded_org2.custom_secrets['org2_secret'].secret.get_secret_value()
== 'org2_secret_value'
)
# Switch back to org1 and verify secrets are still there
mock_user.current_org_id = org1_id
mock_get_user.return_value = mock_user
loaded_org1_again = await secrets_store.load()
assert loaded_org1_again is not None
assert 'personal_secret' in loaded_org1_again.custom_secrets
assert (
loaded_org1_again.custom_secrets[
'personal_secret'
].secret.get_secret_value()
== 'personal_secret_value'
)
# Verify org2 secrets are NOT visible in org1
assert 'org2_secret' not in loaded_org1_again.custom_secrets

View File

@@ -8,11 +8,22 @@ from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings as DataSettings
def _agent_value(settings: Settings, key: str):
return settings.get_agent_setting(key)
def _secret_value(settings: Settings, key: str):
secret = settings.get_secret_agent_setting(key)
return secret.get_secret_value() if secret else None
# Mock the database module before importing
with patch('storage.database.a_session_maker'):
from server.constants import (
LITE_LLM_API_URL,
)
from storage.encrypt_utils import decrypt_legacy_value, encrypt_legacy_value
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
@@ -26,6 +37,34 @@ def mock_config():
return config
def test_member_settings_persist_full_effective_agent_settings(mock_config):
settings = Settings(
agent='CodeActAgent',
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_base_url='https://api.example.com',
max_iterations=42,
confirmation_mode=True,
security_analyzer='llm',
enable_default_condenser=False,
condenser_max_size=128,
)
expected = {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
'llm.base_url': 'https://api.example.com',
'max_iterations': 42,
'verification.confirmation_mode': True,
'verification.security_analyzer': 'llm',
'condenser.enabled': False,
'condenser.max_size': 128,
}
actual = settings.normalized_agent_settings(strip_secret_values=True)
assert actual | expected == actual
@pytest.fixture
def settings_store(async_session_maker, mock_config):
store = SaasSettingsStore('5594c7b6-f959-4b81-92e9-b09c206f5081', mock_config)
@@ -45,6 +84,7 @@ def settings_store(async_session_maker, mock_config):
if not user_settings:
# Return default settings
return Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('test_api_key'),
llm_base_url='http://test.url',
agent='CodeActAgent',
@@ -54,12 +94,20 @@ def settings_store(async_session_maker, mock_config):
# Decrypt and convert to Settings
kwargs = {}
for column in UserSettings.__table__.columns:
if column.name != 'keycloak_user_id':
value = getattr(user_settings, column.name, None)
if value is not None:
kwargs[column.name] = value
if column.name == 'keycloak_user_id':
continue
value = getattr(user_settings, column.name, None)
if value is None:
continue
if column.name in {
'llm_api_key',
'llm_api_key_for_byor',
'search_api_key',
'sandbox_api_key',
}:
value = decrypt_legacy_value(value)
kwargs[column.name] = value
store._decrypt_kwargs(kwargs)
settings = Settings(**kwargs)
settings.email = 'test@example.com'
settings.email_verified = True
@@ -70,6 +118,7 @@ def settings_store(async_session_maker, mock_config):
if item:
# Make a copy of the item without email and email_verified
item_dict = item.model_dump(context={'expose_secrets': True})
item_dict['llm_api_key'] = _secret_value(item, 'llm.api_key')
if 'email' in item_dict:
del item_dict['email']
if 'email_verified' in item_dict:
@@ -78,7 +127,13 @@ def settings_store(async_session_maker, mock_config):
del item_dict['secrets_store']
# Encrypt the data before storing
store._encrypt_kwargs(item_dict)
for key in ('llm_api_key', 'search_api_key', 'sandbox_api_key'):
value = item_dict.get(key)
if value is not None:
item_dict[key] = encrypt_legacy_value(value)
item_dict['agent_settings'] = item.normalized_agent_settings(
strip_secret_values=True
)
# Continue with the original implementation
from sqlalchemy import select
@@ -114,11 +169,16 @@ async def test_store_and_load_keycloak_user(settings_store):
# Set a UUID-like Keycloak user ID
settings_store.user_id = '550e8400-e29b-41d4-a716-446655440000'
settings = Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('secret_key'),
llm_base_url=LITE_LLM_API_URL,
agent='smith',
email='test@example.com',
email_verified=True,
agent_settings={
'verification.critic_mode': 'all_actions',
'verification.critic_enabled': True,
},
)
await settings_store.store(settings)
@@ -126,8 +186,10 @@ async def test_store_and_load_keycloak_user(settings_store):
# Load and verify settings
loaded_settings = await settings_store.load()
assert loaded_settings is not None
assert loaded_settings.llm_api_key.get_secret_value() == 'secret_key'
assert loaded_settings.agent == 'smith'
assert _agent_value(loaded_settings, 'verification.critic_mode') == 'all_actions'
assert _agent_value(loaded_settings, 'verification.critic_enabled') is True
assert _secret_value(loaded_settings, 'llm.api_key') == 'secret_key'
assert _agent_value(loaded_settings, 'agent') == 'smith'
# Verify it was stored in user_settings table with keycloak_user_id
from sqlalchemy import select
@@ -140,7 +202,7 @@ async def test_store_and_load_keycloak_user(settings_store):
)
stored = result.scalars().first()
assert stored is not None
assert stored.agent == 'smith'
assert stored.agent_settings['agent'] == 'smith'
@pytest.mark.asyncio
@@ -154,15 +216,16 @@ async def test_load_returns_default_when_not_found(settings_store, async_session
loaded_settings = await settings_store.load()
assert loaded_settings is not None
assert loaded_settings.language == 'en'
assert loaded_settings.agent == 'CodeActAgent'
assert loaded_settings.llm_api_key.get_secret_value() == 'test_api_key'
assert loaded_settings.llm_base_url == 'http://test.url'
assert _agent_value(loaded_settings, 'agent') == 'CodeActAgent'
assert _secret_value(loaded_settings, 'llm.api_key') == 'test_api_key'
assert _agent_value(loaded_settings, 'llm.base_url') == 'http://test.url'
@pytest.mark.asyncio
async def test_encryption(settings_store):
settings_store.user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' # GitHub user ID
settings = Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('secret_key'),
agent='smith',
llm_base_url=LITE_LLM_API_URL,
@@ -183,7 +246,7 @@ async def test_encryption(settings_store):
assert stored.llm_api_key != 'secret_key'
# But we should be able to decrypt it when loading
loaded_settings = await settings_store.load()
assert loaded_settings.llm_api_key.get_secret_value() == 'secret_key'
assert _secret_value(loaded_settings, 'llm.api_key') == 'secret_key'
@pytest.mark.asyncio
@@ -203,8 +266,8 @@ async def test_ensure_api_key_keeps_valid_key(mock_config):
await store._ensure_api_key(item, 'org-123', openhands_type=True)
# Key should remain unchanged when it's valid
assert item.llm_api_key is not None
assert item.llm_api_key.get_secret_value() == existing_key
assert _secret_value(item, 'llm.api_key') is not None
assert _secret_value(item, 'llm.api_key') == existing_key
@pytest.mark.asyncio
@@ -232,8 +295,8 @@ async def test_ensure_api_key_generates_new_key_when_verification_fails(
):
await store._ensure_api_key(item, 'org-123', openhands_type=True)
assert item.llm_api_key is not None
assert item.llm_api_key.get_secret_value() == new_key
assert _secret_value(item, 'llm.api_key') is not None
assert _secret_value(item, 'llm.api_key') == new_key
@pytest.fixture
@@ -264,7 +327,6 @@ def org_with_multiple_members_fixture(session_maker):
id=org_id,
name='test-org',
org_version=1,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
session.add(org)
@@ -291,9 +353,12 @@ def org_with_multiple_members_fixture(session_maker):
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,
agent_settings={
'schema_version': 1,
'llm.model': 'old-model-v1',
'llm.base_url': 'http://old-url-1.com',
'max_iterations': 10,
},
status='active',
)
session.add(admin_member)
@@ -303,9 +368,12 @@ def org_with_multiple_members_fixture(session_maker):
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,
agent_settings={
'schema_version': 1,
'llm.model': 'old-model-v2',
'llm.base_url': 'http://old-url-2.com',
'max_iterations': 20,
},
status='active',
)
session.add(member1)
@@ -315,9 +383,12 @@ def org_with_multiple_members_fixture(session_maker):
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,
agent_settings={
'schema_version': 1,
'llm.model': 'old-model-v3',
'llm.base_url': 'http://old-url-3.com',
'max_iterations': 30,
},
status='active',
)
session.add(member2)
@@ -334,65 +405,265 @@ def org_with_multiple_members_fixture(session_maker):
@pytest.mark.asyncio
async def test_store_propagates_llm_settings_to_all_org_members(
async def test_store_updates_org_defaults_and_all_members_for_shared_keys(
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
"""
"""External provider keys should still sync as an org-wide shared snapshot."""
from sqlalchemy import select
from storage.org import Org
from storage.org_member import OrgMember
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
decrypt_value = fixture['decrypt_value']
store = SaasSettingsStore(str(fixture['admin_user_id']), mock_config)
new_settings = DataSettings(
llm_model='anthropic/claude-sonnet-4',
llm_base_url='https://api.anthropic.com/v1',
max_iterations=100,
llm_api_key=SecretStr('shared-external-api-key'),
)
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
with session_maker() as session:
org = session.execute(select(Org).where(Org.id == org_id)).scalars().first()
assert org is not None
assert org.agent_settings['llm.model'] == 'anthropic/claude-sonnet-4'
assert org.agent_settings['llm.base_url'] == 'https://api.anthropic.com/v1'
assert org.agent_settings['max_iterations'] == 100
members = {
str(member.user_id): member
for member in session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
).scalars().all()
}
assert len(members) == 3
for member in members.values():
assert member.agent_settings['llm.model'] == 'anthropic/claude-sonnet-4'
assert member.agent_settings['llm.base_url'] == 'https://api.anthropic.com/v1'
assert member.agent_settings['max_iterations'] == 100
assert decrypt_value(member._llm_api_key) == 'shared-external-api-key'
@pytest.mark.asyncio
async def test_store_keeps_openhands_managed_keys_member_specific(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""Managed OpenHands keys should not be copied from one member to everyone else."""
from sqlalchemy import select
from storage.org import Org
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'),
llm_model='openhands/claude-opus-4-5-20251101',
llm_base_url=LITE_LLM_API_URL,
max_iterations=75,
llm_api_key=SecretStr('admin-managed-api-key'),
)
with (
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
patch(
'storage.saas_settings_store.LiteLlmManager.verify_existing_key',
new_callable=AsyncMock,
return_value=True,
),
):
await store.store(new_settings)
with session_maker() as session:
org = session.execute(select(Org).where(Org.id == org_id)).scalars().first()
assert org is not None
assert org.agent_settings['llm.model'] == 'openhands/claude-opus-4-5-20251101'
assert org.agent_settings['llm.base_url'] == LITE_LLM_API_URL
assert org.agent_settings['max_iterations'] == 75
members = {
str(member.user_id): member
for member in session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
).scalars().all()
}
assert len(members) == 3
admin_member = members[admin_user_id]
assert decrypt_value(admin_member._llm_api_key) == 'admin-managed-api-key'
member1 = members[str(fixture['member1_user_id'])]
member2 = members[str(fixture['member2_user_id'])]
assert decrypt_value(member1._llm_api_key) == 'member1-initial-key'
assert decrypt_value(member2._llm_api_key) == 'member2-initial-key'
for member in members.values():
assert member.agent_settings['llm.model'] == 'openhands/claude-opus-4-5-20251101'
assert member.agent_settings['llm.base_url'] == LITE_LLM_API_URL
assert member.agent_settings['max_iterations'] == 75
@pytest.mark.asyncio
async def test_store_saves_mcp_config_to_current_member_only(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
from sqlalchemy import select
from storage.org import Org
from storage.org_member import OrgMember
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
member1_user_id = str(fixture['member1_user_id'])
member2_user_id = str(fixture['member2_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
user_mcp_config = {
'sse_servers': [{'url': 'https://user1-mcp-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
new_settings = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=user_mcp_config,
)
# 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()
org = session.execute(select(Org).where(Org.id == org_id)).scalars().first()
assert org is not None
assert org.agent_settings.get('mcp_config') is None
# Verify we have all 3 members
assert len(members) == 3, f'Expected 3 org members, got {len(members)}'
members = {
str(m.user_id): m
for m in session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
).scalars().all()
}
assert members[admin_user_id].mcp_config == user_mcp_config
assert members[member1_user_id].mcp_config is None
assert members[member2_user_id].mcp_config is None
assert members[admin_user_id].agent_settings.get('mcp_config') is None
assert members[member1_user_id].agent_settings.get('mcp_config') is None
assert members[member2_user_id].agent_settings.get('mcp_config') is None
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}'
@pytest.mark.asyncio
async def test_store_does_not_overwrite_other_members_mcp_config(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
from sqlalchemy import select
from storage.org_member import OrgMember
# Verify max_iterations is updated
assert (
member.max_iterations == 100
), f'Expected max_iterations to be 100 for member {member.user_id}'
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
member1_user_id = str(fixture['member1_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}'
admin_store = SaasSettingsStore(admin_user_id, mock_config)
member_store = SaasSettingsStore(member1_user_id, mock_config)
admin_mcp_config = {
'sse_servers': [{'url': 'https://admin-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
member_mcp_config = {
'sse_servers': [{'url': 'https://member-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await admin_store.store(
DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=admin_mcp_config,
)
)
await member_store.store(
DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=member_mcp_config,
)
)
with session_maker() as session:
members = {
str(m.user_id): m
for m in session.execute(select(OrgMember)).scalars().all()
}
assert members[admin_user_id].mcp_config == admin_mcp_config
assert members[member1_user_id].mcp_config == member_mcp_config
@pytest.mark.asyncio
async def test_load_returns_current_member_specific_mcp_config(
async_session_maker, mock_config, org_with_multiple_members_fixture
):
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
member1_user_id = str(fixture['member1_user_id'])
admin_mcp_config = {
'sse_servers': [{'url': 'https://admin-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
member_mcp_config = {
'sse_servers': [{'url': 'https://member-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
admin_store = SaasSettingsStore(admin_user_id, mock_config)
member_store = SaasSettingsStore(member1_user_id, mock_config)
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await admin_store.store(
DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=admin_mcp_config,
)
)
await member_store.store(
DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=member_mcp_config,
)
)
with patch(
'storage.saas_settings_store.a_session_maker', async_session_maker
), patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
):
admin_loaded_settings = await admin_store.load()
member_loaded_settings = await member_store.load()
assert admin_loaded_settings is not None
assert admin_loaded_settings.mcp_config is not None
assert admin_loaded_settings.mcp_config.sse_servers[0].url == 'https://admin-private-server.com'
assert member_loaded_settings is not None
assert member_loaded_settings.mcp_config is not None
assert member_loaded_settings.mcp_config.sse_servers[0].url == 'https://member-private-server.com'

View File

@@ -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
@@ -457,7 +459,8 @@ async def test_get_instance_no_auth(mock_request):
@pytest.mark.asyncio
async def test_saas_user_auth_from_bearer_success():
"""Test successful authentication from bearer token."""
"""Test successful authentication from bearer token sets user_id and api_key_org_id."""
# Arrange
mock_request = MagicMock()
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
@@ -468,12 +471,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 +498,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)

View File

@@ -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',
)

View File

@@ -0,0 +1,205 @@
"""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.filesystem_shared_event_service import (
FilesystemSharedEventServiceInjector,
)
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_filesystem_when_no_env_set(self):
"""Test that FilesystemSharedEventServiceInjector 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()
# Default behavior is filesystem storage when nothing is configured
assert isinstance(injector, FilesystemSharedEventServiceInjector)
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_filesystem(self):
"""Test that unknown provider defaults to FilesystemSharedEventServiceInjector."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': 'unknown_provider',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should default to filesystem for unknown providers
assert isinstance(injector, FilesystemSharedEventServiceInjector)
def test_empty_provider_falls_back_to_file_store_gcp(self):
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=google_cloud."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': '',
'FILE_STORE': 'google_cloud',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should use GCP when FILE_STORE=google_cloud
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
def test_empty_provider_falls_back_to_file_store_s3(self):
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=s3."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': '',
'FILE_STORE': 's3',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should use AWS when FILE_STORE=s3
assert isinstance(injector, AwsSharedEventServiceInjector)
def test_empty_provider_falls_back_to_file_store_filesystem(self):
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE=filesystem."""
with patch.dict(
os.environ,
{
'SHARED_EVENT_STORAGE_PROVIDER': '',
'FILE_STORE': 'filesystem',
},
clear=True,
):
injector = get_shared_event_service_injector()
# Should use filesystem when FILE_STORE=filesystem
assert isinstance(injector, FilesystemSharedEventServiceInjector)

View File

@@ -52,6 +52,7 @@ def test_get_kwargs_from_settings():
settings = Settings(
language='es',
enable_sound_notifications=True,
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('test-key'),
)
@@ -83,6 +84,7 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
# Mock LiteLlmManager.create_entries to return a Settings object
mock_settings = Settings(
language='en',
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('test_api_key'),
llm_base_url='http://test.url',
agent='CodeActAgent',
@@ -97,8 +99,77 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
# With mock, should return settings with API key from LiteLLM
assert settings is not None
assert settings.llm_api_key.get_secret_value() == 'test_api_key'
assert settings.llm_base_url == 'http://test.url'
assert (
settings.get_secret_agent_setting('llm.api_key').get_secret_value()
== 'test_api_key'
)
assert settings.get_agent_setting('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 ---
@@ -622,8 +693,10 @@ def test_has_custom_settings_custom_base_url():
user_settings = UserSettings(
keycloak_user_id='test',
llm_base_url='https://custom.api.example.com',
llm_model='some-model',
agent_settings={
'llm.base_url': 'https://custom.api.example.com',
'llm.model': 'some-model',
},
)
result = UserStore._has_custom_settings(user_settings, old_user_version=1)
@@ -635,11 +708,7 @@ def test_has_custom_settings_no_model():
"""Test that no model set means using defaults."""
from storage.user_settings import UserSettings
user_settings = UserSettings(
keycloak_user_id='test',
llm_base_url=None,
llm_model=None,
)
user_settings = UserSettings(keycloak_user_id='test', agent_settings={})
result = UserStore._has_custom_settings(user_settings, old_user_version=1)
@@ -652,8 +721,7 @@ def test_has_custom_settings_empty_model():
user_settings = UserSettings(
keycloak_user_id='test',
llm_base_url=None,
llm_model=' ', # whitespace only
agent_settings={'llm.model': ' '},
)
result = UserStore._has_custom_settings(user_settings, old_user_version=1)
@@ -661,6 +729,35 @@ def test_has_custom_settings_empty_model():
assert result is False
def test_user_settings_byor_secret_property_encrypts_round_trip():
from storage.user_settings import UserSettings
user_settings = UserSettings(keycloak_user_id='test')
user_settings.llm_api_key_for_byor_secret = SecretStr('sk-byor-secret')
assert user_settings.llm_api_key_for_byor != 'sk-byor-secret'
assert user_settings.llm_api_key_for_byor_secret is not None
assert (
user_settings.llm_api_key_for_byor_secret.get_secret_value() == 'sk-byor-secret'
)
def test_user_settings_byor_secret_property_accepts_plaintext_legacy_rows():
from storage.user_settings import UserSettings
user_settings = UserSettings(
keycloak_user_id='test',
llm_api_key_for_byor='sk-legacy-plaintext',
)
assert user_settings.llm_api_key_for_byor_secret is not None
assert (
user_settings.llm_api_key_for_byor_secret.get_secret_value()
== 'sk-legacy-plaintext'
)
# --- Tests for _create_user_settings_from_entities ---
@@ -671,10 +768,12 @@ def test_create_user_settings_from_entities():
# Create mock entities
org_member = MagicMock()
org_member.llm_api_key = SecretStr('test-api-key')
org_member.llm_api_key_for_byor = None
org_member.llm_model = 'claude-3-5-sonnet'
org_member.llm_base_url = 'https://api.example.com'
org_member.max_iterations = 50
org_member.agent_settings = {
'schema_version': 1,
'llm.model': 'claude-3-5-sonnet',
'llm.base_url': 'https://api.example.com',
'max_iterations': 50,
}
user = MagicMock()
user.accepted_tos = None
@@ -687,26 +786,22 @@ def test_create_user_settings_from_entities():
user.git_user_email = 'test@git.com'
org = MagicMock()
org.agent = 'CodeActAgent'
org.security_analyzer = 'mock-analyzer'
org.confirmation_mode = False
org.remote_runtime_resource_factor = 1.0
org.enable_default_condenser = True
org.billing_margin = 0.0
org.enable_proactive_conversation_starters = True
org.sandbox_base_container_image = None
org.sandbox_runtime_container_image = None
org.org_version = 1
org.mcp_config = None
org.agent_settings = {
'schema_version': 1,
'agent': 'CodeActAgent',
'verification.security_analyzer': 'mock-analyzer',
}
org.search_api_key = None
org.sandbox_api_key = None
org.max_budget_per_task = None
org.enable_solvability_analysis = False
org.v1_enabled = True
org.condenser_max_size = None
org.default_llm_model = 'default-model'
org.default_llm_base_url = 'https://default.api.com'
org.default_max_iterations = 100
result = UserStore._create_user_settings_from_entities(
user_id, org_member, user, org
@@ -714,7 +809,11 @@ def test_create_user_settings_from_entities():
assert result.keycloak_user_id == user_id
assert result.llm_api_key == 'test-api-key'
assert result.llm_model == 'claude-3-5-sonnet'
assert result.agent_settings['llm.model'] == 'claude-3-5-sonnet'
assert result.agent_settings['llm.base_url'] == 'https://api.example.com'
assert result.agent_settings['max_iterations'] == 50
assert result.agent_settings['agent'] == 'CodeActAgent'
assert result.agent_settings['verification.security_analyzer'] == 'mock-analyzer'
assert result.language == 'en'
assert result.email == 'test@example.com'
@@ -726,10 +825,7 @@ def test_create_user_settings_from_entities_with_org_fallback():
# Create mock entities with None in OrgMember
org_member = MagicMock()
org_member.llm_api_key = None
org_member.llm_api_key_for_byor = None
org_member.llm_model = None # Should fall back to org.default_llm_model
org_member.llm_base_url = None # Should fall back to org.default_llm_base_url
org_member.max_iterations = None # Should fall back to org.default_max_iterations
org_member.agent_settings = {}
user = MagicMock()
user.accepted_tos = None
@@ -742,36 +838,40 @@ def test_create_user_settings_from_entities_with_org_fallback():
user.git_user_email = None
org = MagicMock()
org.agent = 'CodeActAgent'
org.security_analyzer = None
org.confirmation_mode = True
org.remote_runtime_resource_factor = 2.0
org.enable_default_condenser = False
org.billing_margin = 0.1
org.enable_proactive_conversation_starters = False
org.sandbox_base_container_image = 'custom-image'
org.sandbox_runtime_container_image = None
org.org_version = 2
org.mcp_config = {'key': 'value'}
org.agent_settings = {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm.model': 'default-model',
'llm.base_url': 'https://default.api.com',
'verification.confirmation_mode': True,
'condenser.enabled': False,
'condenser.max_size': 1000,
'max_iterations': 100,
'mcp_config': {'key': 'value'},
}
org.search_api_key = SecretStr('search-key')
org.sandbox_api_key = None
org.max_budget_per_task = 10.0
org.enable_solvability_analysis = True
org.v1_enabled = False
org.condenser_max_size = 1000
# Org defaults
org.default_llm_model = 'default-model'
org.default_llm_base_url = 'https://default.api.com'
org.default_max_iterations = 100
result = UserStore._create_user_settings_from_entities(
user_id, org_member, user, org
)
# Should have fallen back to org defaults
assert result.llm_model == 'default-model'
assert result.llm_base_url == 'https://default.api.com'
assert result.max_iterations == 100
assert result.agent_settings['llm.model'] == 'default-model'
assert result.agent_settings['llm.base_url'] == 'https://default.api.com'
assert result.agent_settings['max_iterations'] == 100
assert result.agent_settings['agent'] == 'CodeActAgent'
assert result.agent_settings['verification.confirmation_mode'] is True
assert result.agent_settings['condenser.max_size'] == 1000
assert result.language == 'es'
assert result.search_api_key == 'search-key'
@@ -1243,3 +1343,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.

View File

@@ -0,0 +1 @@
# Tests for enterprise server utils

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