Skip to content

feat: permission/scope alignment (core — no live-read) - #5133

Merged
qinxuye merged 9 commits into
xorbitsai:mainfrom
m199369309:feat/permission-system-overhaul
Jul 3, 2026
Merged

feat: permission/scope alignment (core — no live-read)#5133
qinxuye merged 9 commits into
xorbitsai:mainfrom
m199369309:feat/permission-system-overhaul

Conversation

@m199369309

@m199369309 m199369309 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Aligns the frontend permission list, backend route scopes, and menu/route gating so the three match 1:1. Adds an admin-target-takeover guard to prevent non-admin users from modifying admin accounts. Adds owner_username to the API key list response so non-admin callers can render the owner column. Fixes the reveal_api_key scope to match the frontend button guard.

What changed vs the original mega-PR

Based on maintainer @qinxuye's feedback, this PR is a scoped-down version of the original 7-commit series. The live-read changes (C5 /v1/users/me + PermissionsContext, C7 _get_caller_live_perms) are dropped because the live-read design only worked at the UI menu layer, not at the API route layer (Security(scopes=[...]) still checks JWT snapshot). Live-read will be revisited in a separate follow-up — see the open issue linked in the comments.

Commits

# Commit Summary
1 `feat(ui): add JWT utils for permission checks` New `utils/jwt.js` with `parseTokenFromSession` / `hasPermission` / `isAdmin` helpers (base64url-safe)
2 `feat(ui): expand ALL_PERMISSIONS list and add i18n keys` Add `models:register`, `logs:list`, `monitor:view` to user management UI + 4 locales
3 `feat(ui): gate B-class menu items and routes by permission scope` Menu visibility by scope + `PermissionGate` route guard + monitoring config buttons admin-only
4 `feat(api): rename route scopes to align with UI permissions` `models:start/stop` → `models:write`, `models:add/unregister` → `models:register`, `admin` → `logs:list` for log routes. `SCOPE_ALIASES` bridge keeps legacy tokens working. BREAKING for new-scope tokens on old deployments.
5 `fix: update auth test fixtures for merged models:write scope + JWT base64url` conftest user3 `models:start` → `models:write`; remove terminate-rejected assertions (merge makes them pass); jwt.js base64url normalization
6 `feat(api): extend admin-target-takeover guard to all sensitive write handlers` New `_reject_admin_target_takeover` prevents non-admin `users:manage` users from deleting / changing password / disabling / changing permissions of admin users
7 `feat(api,ui): owner_username in list_api_keys + reveal scope fix` (G1) `owner_username` field in list_api_keys response + frontend renderCell preference; (J1) `reveal_api_key` scope `admin` → `keys:manage`
8 `fix(api): align API key read route scopes with frontend contract` New `require_keys_read` dependency accepts `keys:create` OR `keys:manage`; applied to GET `/v1/admin/keys` and GET `/v1/admin/keys/{id}`

Background

Xinference's permission system had three structural issues:

  1. UI permissions vs backend scopes drifted: frontend `models:write` was a dead permission (no route used it); backend `models:start` / `models:stop` / `models:add` / `models:unregister` were not assignable via the UI.
  2. Menu visibility was unconditional: zero-permission users saw dead menus that 403'd on click; monitoring / logs pages were exposed to all logged-in users.
  3. Admin-target-takeover gap: a delegated `users:manage` operator could delete an admin account, change an admin's password, or disable the only admin — locking the deployment out.

This PR addresses all three. The alias bridge (commit 4) ensures existing tokens carrying legacy scope names keep working during the transition period; the bridge is scheduled for removal in a future release.

⚠️ Breaking change (commit 4)

Tokens carrying only the new scope names (`models:write`, `models:register`, `logs:list`) issued by newer deployments will NOT work on older deployments that still expect the legacy names. The alias bridge is one-directional (legacy → new).

Admin default permissions now include `models:register`, `logs:list`, `monitor:view`.

Test plan

  • `pytest -vv xinference/api/tests/test_oauth2_scope_alias_migration.py` (11/11 passed)
  • `pre-commit run --files `
  • `pytest -vv xinference/api/tests/test_oauth2_admin_target_takeover.py` (requires `jose` dep; CI will run)
  • `pytest -vv xinference/client/tests/test_client_with_auth.py` (regression — verified locally that user3 with `models:write` can now terminate, matching the merged-scope semantics)
  • `cd xinference/ui/web/ui && npx eslint . && npx prettier --check .`
  • Manual: full permission matrix (admin / zero-perm / `monitor:view` / `logs:list` / `users:manage` / `keys:create` / `keys:manage`)

Out of scope (follow-up)

  • Live-read permission changes: the original mega-PR included `/v1/users/me` + `PermissionsContext` + `_get_caller_live_perms` to make admin permission changes take effect without re-login. Maintainer feedback revealed this only works at the UI layer, not the API route layer (`Security` still checks JWT snapshot). Dropped from this PR; tracked in a separate follow-up issue for proper design discussion.
  • Force-logout / JWT active invalidation: separate follow-up.
  • SCOPE_ALIASES removal: scheduled for a future release after deprecation warnings confirm zero alias hits in production.

🤖 Generated with Claude Code

@XprobeBot XprobeBot added this to the v2.x milestone Jul 3, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request aligns permissions and scopes, introduces a legacy scope alias bridge for backward compatibility, and adds an admin-target-takeover guard to prevent non-admins from modifying admin accounts. It also implements a live-read /v1/users/me endpoint and corresponding frontend PermissionsContext and PermissionGate to enforce real-time permission updates. Feedback highlights an N+1 query issue when resolving API key owners, a potential decoding failure in JWT parsing when using atob directly on base64url strings, and a recommendation to use optional chaining on the permissions array to prevent runtime errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread xinference/api/oauth2/advanced/routes.py Outdated
Comment thread xinference/ui/web/ui/src/utils/jwt.js Outdated
Comment thread xinference/ui/web/ui/src/scenes/apikey_management/index.js Outdated
m199369309 added a commit to m199369309/inference that referenced this pull request Jul 3, 2026
Three issues from gemini-code-assist review + one real CI regression:

1. **C4 test regression** (4 failing Ubuntu CI jobs): conftest.py user3
   had `permissions=["models:list", "models:read", "models:start"]`.
   After C4 merged `models:start` + `models:stop` into a single
   `models:write` scope (with alias bridge), user3's `models:start`
   expands to `models:write` which covers BOTH launch AND terminate.
   The tests `test_client_auth` / `test_async_client_auth` expected
   user3's `terminate_model` to raise RuntimeError; after C4 it
   succeeds → "DID NOT RAISE" failure.

   Fix: update conftest user3 to use the new `models:write` scope name
   directly, and remove the `with pytest.raises(RuntimeError):
   terminate` assertions for user3 (both JWT and API key paths). The
   "can launch but can't terminate" distinction is intentionally gone
   per C4's merge design (see PR description breaking change). Admin
   (user1) still terminates in the downstream flow, preserving the
   `list_models() == 1` → `== 0` assertions.

2. **N+1 query in list_api_keys** (routes.py): admin path fetched
   users one-by-one via `get_user_by_id`. Switched to a single
   `list_users()` call when `is_admin` (admin sees all keys, so the
   user set is large); non-admin path keeps N+1 (user set is
   typically 1 — the caller themselves).

3. **JWT base64url decoding** (utils/jwt.js): `atob()` throws
   DOMException on base64url strings (which contain `-` and `_` and
   lack padding). Standard JWT payloads are base64url-encoded, so
   direct `atob()` would silently fail token parsing in some
   browsers. Fixed by replacing `-` → `+` and `_` → `/` before
   `atob()`.

4. **Null-safety in apikey_management** (apikey_management/index.js):
   `permissions.includes('admin')` throws TypeError if
   `permissions` is null/undefined (e.g. during PermissionsContext
   initial load before `/v1/users/me` resolves). Changed to
   `permissions?.includes('admin')`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@m199369309

Copy link
Copy Markdown
Collaborator Author

Thanks @gemini-code-assist for the review — all 3 suggestions applied in db66e14, plus a real CI regression fix:

1. C4 test regression (4 failing Ubuntu CI jobs — real breakage, not flaky):

conftest.py user3 had `permissions=["models:list", "models:read", "models:start"]`. After C4 merged `models:start` + `models:stop` into a single `models:write` scope (with alias bridge), user3's `models:start` expands to `models:write` which covers BOTH launch AND terminate. The tests `test_client_auth` / `test_async_client_auth` expected user3's `terminate_model` to raise `RuntimeError`; after C4 it succeeds → "DID NOT RAISE" failure.

Fix: conftest user3 now uses `models:write` directly, and the `with pytest.raises(RuntimeError): terminate` assertions for user3 are removed (both JWT and API key paths). The "can launch but can't terminate" distinction is intentionally gone per C4's merge design (see PR description breaking change). Admin (user1) still terminates downstream, preserving the `list_models() == 1` → `== 0` assertions.

2. N+1 query in list_api_keys (routes.py): admin path now uses a single `list_users()` call instead of per-user `get_user_by_id`. Non-admin path keeps N+1 (user set is typically 1).

3. JWT base64url decoding (utils/jwt.js): `atob()` throws DOMException on base64url. Now normalizes `-`→`+` and `_`→`/` before decoding.

4. Null-safety (apikey_management/index.js): `permissions.includes('admin')` → `permissions?.includes('admin')` to handle the initial-load case where PermissionsContext hasn't resolved yet.

Re: macOS / Windows CI — those jobs were still pending when the fix pushed; the Ubuntu jobs should now pass. The pre-existing flaky tests (test_log_rotation on macOS, test_whisper_mlx on metal, test_worker socket bind on Windows) are unrelated to this PR.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Thanks for the detailed update and for resolving the CI regression. The approach of using models:write directly in conftest.py and updating the assertions is the correct way to handle the scope merge. The batch resolution of usernames in list_api_keys and the null-safety improvements in apikey_management/index.js are also solid improvements. Your fixes for the JWT base64url decoding and the CI test regressions look correct and well-justified.

@m199369309

Copy link
Copy Markdown
Collaborator Author

Follow-up fix in 181d493: the N+1 fix from db66e14 introduced a mypy error that broke the lint CI job.

Root cause: The local variable u in the admin branch (for u in auth.db.list_users()) was inferred as Dict[str, Any]. The else branch originally reused the variable name u for auth.db.get_user_by_id(uid) (which returns Optional[Dict]) — type collision. The first rename to owner then collided with the owner: Optional[int] function parameter, causing mypy to infer int and report Value of type int is not indexable on owner["username"].

Fix: Renamed the else-branch variable to owner_user, avoiding both the loop-variable type collision and the parameter shadowing.

Lint CI should now pass.

Comment thread xinference/api/oauth2/advanced/auth_service.py
Comment thread xinference/ui/web/ui/src/router/index.js
m199369309 and others added 8 commits July 4, 2026 00:03
Adds `utils/jwt.js` with three helpers that the upcoming permission/scope
alignment series will build on:
- `parseTokenFromSession()` — reads JWT from `sessionStorage`, returns
  `{username, scopes}`
- `hasPermission(scopes, scope)` — `admin` wildcard bypass + scope
  membership check, mirroring the backend `if "admin" in token_scopes`
  shortcut
- `isAdmin(scopes)` — convenience wrapper

No behavior change yet — pure additions. Subsequent PRs will wire these
into `MenuSide`, `router`, and `monitoring`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three new permissions to the user management UI so the upcoming
scope alignment series can wire backend routes to them:
- `models:register` (in `models` group)
- `logs:list` (new `logs` group)
- `monitor:view` (new `monitor` group)

Adds matching `permissions.*` i18n keys in en/zh/ja/ko locales.

No behavior change yet — admins can now assign the new scopes, but no
frontend or backend code consumes them until subsequent PRs land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits menu items into two classes:
- A-class (launch / running / register model): always visible, no route
  guard, backend 403 as fallback
- B-class (cluster / monitoring / logs / audit / user mgmt / API key /
  security): visible only when the user has the required scope, with a
  `PermissionGate` redirect for direct URL access

Also gates the two monitoring-page config buttons (settings gear +
notConfigured placeholder button) behind `admin`, matching the existing
`update_monitor_config` route scope.

Menu visibility matrix:
| Menu              | Scope condition                                  |
|-------------------|--------------------------------------------------|
| Launch model      | (always)                                         |
| Running model     | (always)                                         |
| Register model    | (always)                                         |
| Cluster info      | admin                                            |
| Monitoring        | monitor:view                                     |
| Logs              | esEnabled && logs:list                           |
| Audit             | admin                                            |
| User management   | authAdvanced && users:manage                     |
| API Key management| authAdvanced && (keys:create || keys:manage)     |
| Security          | authAdvanced && admin                            |

Routes for B-class pages are wrapped in `<PermissionGate requiredScope=...>`
which redirects to `/` when the session lacks the scope. `admin` is a
wildcard that passes any gate. A-class routes are intentionally NOT
wrapped.

Note: this PR uses JWT-snapshot scopes via `parseTokenFromSession()`. A
follow-up PR will replace this with a live-read `PermissionsContext`
backed by `/v1/users/me`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns backend route scopes with the frontend permission list so the
two match 1:1. Adds a `SCOPE_ALIASES` bridge so existing tokens with
legacy scope names continue to work during the transition period.

Scope renames:
| Old scope           | New scope         | Affected routes                          |
|---------------------|-------------------|------------------------------------------|
| models:start        | models:write      | POST /v1/models/{uid}/stop, /cancel      |
| models:stop         | models:write      | POST /v1/models/{uid}/terminate, etc.    |
| models:add          | models:register   | POST /v1/model_registrations             |
| models:unregister   | models:register   | DELETE /v1/model_registrations           |
| (admin-only)        | logs:list         | GET /v1/cluster/logs, /context, /nodes   |

`monitor:view` is a frontend-only scope (the monitoring page's data
dependencies `GET /v1/cluster/ui_config` and `GET /v1/cluster/logs` are
already covered by `monitor:view` menu gate and `logs:list` route scope
respectively).

The alias bridge lives in a new `xinference/api/oauth2/scope_aliases.py`
module imported by both `auth_service.py` modules. `_normalize_scopes`
expands legacy scopes to their new equivalents before the scope-by-scope
check, so a token carrying `models:start` passes a `models:write`
check.

The alias bridge is scheduled for removal in a future release; a
deprecation warning will be added in a follow-up PR once the warning
collection mechanism is in place.

BREAKING CHANGE: tokens carrying only the new scope names
(`models:write`, `models:register`, `logs:list`) issued by newer
deployments will NOT work on older deployments that still expect the
legacy names. The alias bridge is one-directional (legacy → new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…handlers

The existing `_reject_permission_escalation` function only validates
Layer B (a caller cannot grant scopes they don't hold). It does NOT
validate Layer A: a non-admin caller with `users:manage` could delete
an admin account, change an admin's password and log in as them, or
disable the only admin and lock the deployment out.

This commit adds `_reject_admin_target_takeover(user_id)` which raises
403 when a non-admin caller tries to perform a sensitive write on an
admin user. Admin callers bypass the check entirely.

Wired into 4 handlers:
- `delete_user` (new guard)
- `change_password` (new guard)
- `update_user` enable/disable path (new guard) + permissions path
  (new guard, in addition to existing Layer B)
- `update_user_permissions` (new guard, in addition to existing Layer B)

`create_user` does not need Layer A (the target user doesn't exist yet).
`create_api_key` does not need Layer A (API keys have no admin concept;
the model_permissions path is already covered by Layer B via
`_reject_permission_escalation` semantics — to be wired in a follow-up
for the `model_permissions` field specifically).

Test coverage: `test_oauth2_admin_target_takeover.py` parameterizes
across all 4 action types × {non-admin caller targets admin user → 403,
non-admin targets non-admin → ok, admin targets admin → ok, missing
user → ok (no existence leak), empty/None permissions → ok}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…se64url

Three fixes bundled (all from gemini review + C4 test regression on the
original mega-PR xorbitsai#5133):

1. **C4 test regression**: conftest.py user3 had
   `permissions=["models:list", "models:read", "models:start"]`. After
   C4 merged `models:start` + `models:stop` into a single `models:write`
   scope (with alias bridge), user3's `models:start` expands to
   `models:write` which covers BOTH launch AND terminate. The tests
   `test_client_auth` / `test_async_client_auth` expected user3's
   `terminate_model` to raise `RuntimeError`; after C4 it succeeds.
   Fix: conftest user3 now uses `models:write` directly; the
   `with pytest.raises(RuntimeError): terminate` assertions for user3
   are removed (both JWT and API key paths). Admin (user1) still
   terminates downstream.

2. **JWT base64url decoding** (utils/jwt.js): `atob()` throws
   DOMException on base64url strings. Now normalizes `-`→`+` and `_`→`/`
   before decoding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two independent fixes for the API key management flow (extracted from
the original mega-PR's C7 commit, without the live-read changes that
were dropped after maintainer feedback):

1. **owner_username field (G1)**: `list_api_keys` now batch-resolves
   `user_id → username` and adds `owner_username` to each key in the
   response. Non-admin callers (who can't GET /v1/admin/users) can now
   render the owner column without falling back to `#<id>`.
   `/v1/admin/users` stays admin-only because its response contains
   `permissions` / `enabled` / `must_change_password` fields.
   N+1 avoidance: admin path uses a single `list_users()` query;
   non-admin path keeps per-uid `get_user_by_id` (user set is
   typically 1 — the caller themselves).
   Frontend `apikey_management` owner column renderCell prefers
   `row.owner_username` over local `users[]` lookup.

2. **reveal_api_key scope: admin → keys:manage (J1)**: the frontend
   `canManageKeys` button guard already shows the reveal button to
   `keys:manage` users, but the backend route scope was still `admin`,
   causing 403 when `keys:manage` (non-admin) users clicked it. Reveal
   is a read operation; `keys:manage` users can already delete keys
   (more destructive), so blocking reveal was over-protection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses maintainer @qinxuye's feedback on PR xorbitsai#5133: the frontend
PermissionGate shows the API Key Management page to users holding
either keys:create OR keys:manage, and the handler is_admin check
treats both as admin-capable. But the GET routes were protected by
Security(scopes=["keys:create"]) — AND-semantics — so a user with only
keys:manage could reach the page but the initial GET /v1/admin/keys
was rejected before the handler ran.

Fix: add a require_keys_read dependency that accepts keys:create OR
keys:manage (or admin wildcard). FastAPI's Security(scopes=[...]) is
AND-only, so a custom dependency is needed for OR semantics. Apply to
GET /v1/admin/keys (list) and GET /v1/admin/keys/{id} (get one).

Write routes (POST create, PUT update, DELETE, GET reveal) are
unchanged — they keep their existing single-scope Security because
each write operation maps to exactly one scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@m199369309
m199369309 force-pushed the feat/permission-system-overhaul branch from 181d493 to a40fbe9 Compare July 3, 2026 16:08
@m199369309 m199369309 changed the title feat: permission/scope alignment series (C1-C7) feat: permission/scope alignment (core — no live-read) Jul 3, 2026
@m199369309

Copy link
Copy Markdown
Collaborator Author

@qinxuye Thanks for both reviews — valid points. This PR has been re-scoped to address them:

Feedback 1 (live-read doesn't work at route layer): You're right — the `_get_caller_live_perms` helper and `/v1/users/me` + `PermissionsContext` only worked at the UI menu layer, not for actual API calls (`Security(scopes=[...])` still checks JWT snapshot). This created a worse UX than pre-PR (user sees a menu item that 403s on click).

Action taken: Dropped C5 (`/v1/users/me` + `PermissionsContext`) and C7's live-read portion (`_get_caller_live_perms` + 3 handler switches) from this PR. The remaining C7 pieces (G1 `owner_username` + J1 `reveal` scope) are kept as independent improvements — they don't depend on live-read.

Live-read design discussion moved to #5134 with four options (A: Security queries DB per request / B: drop live-read / C: shorten JWT + re-read on refresh / D: WebSocket push). Recommendation is C. Your input on direction would be appreciated.

Feedback 2 (frontend/backend scope contract mismatch on read routes): Fixed in commit `a40fbe9e`. New `require_keys_read` dependency accepts `keys:create` OR `keys:manage` (FastAPI's `Security(scopes=[...])` is AND-only, so a custom dependency is needed for OR). Applied to GET `/v1/admin/keys` and GET `/v1/admin/keys/{id}`. Write routes unchanged (each maps to exactly one scope).

PR now contains 8 commits (force-pushed to replace the original 7-commit mega-PR + 3 fix commits):

  1. feat(ui): add JWT utils for permission checks
  2. feat(ui): expand ALL_PERMISSIONS list and add i18n keys
  3. feat(ui): gate B-class menu items and routes by permission scope
  4. feat(api): rename route scopes to align with UI permissions
  5. fix: update auth test fixtures for merged models:write scope + JWT base64url
  6. feat(api): extend admin-target-takeover guard to all sensitive write handlers
  7. feat(api,ui): owner_username in list_api_keys + reveal scope fix
  8. fix(api): align API key read route scopes with frontend contract

Note: force-push invalidated the previous inline comments, but the design feedback they raised is fully addressed above. CI is re-running on the new head.

Comment thread xinference/api/oauth2/advanced/routes.py Outdated
…WT decode

Addresses maintainer @qinxuye's feedback on PR xorbitsai#5133: the custom
require_keys_read dependency only called _get_current_user_from_token
(which decodes the JWT payload) — it did not verify that the user
still exists or is still enabled in the DB. A user with a keys:manage
token could be disabled after login and still pass this dependency
for GET /v1/admin/keys and GET /v1/admin/keys/{id} until the JWT
expires.

Fix: reuse Security(auth_service) as a sub-dependency so the full
AdvancedAuthService.__call__ validation runs (JWT verify + user DB
lookup + enabled check + audit log). Security(auth_service) with no
scopes enforces authentication + user validity but not any specific
scope. The OR check (keys:create || keys:manage || admin) is then
performed manually on the JWT scopes — consistent with how
Security(scopes=[...]) checks JWT scopes (not DB permissions).

require_keys_read is now a closure inside register_advanced_auth_routes
so it can reference the auth_service instance (which is only available
after app initialization, not at module definition time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@m199369309

Copy link
Copy Markdown
Collaborator Author

@qinxuye Good catch — valid security regression. Pushed fix in d9f44df.

Root cause: require_keys_read only called _get_current_user_from_token (JWT decode) — it skipped the user DB lookup + enabled check + audit log that AdvancedAuthService.__call__ performs for standard Security(scopes=[...]) routes. A disabled/deleted user with a still-valid JWT could pass the read dependency.

Fix: require_keys_read now uses Security(auth_service) as a sub-dependency (no required scopes = enforces authentication + user validity + enabled + audit, but not any specific scope). The OR check (keys:create || keys:manage || admin) is then performed manually on the JWT scopes — consistent with how Security(scopes=[...]) checks JWT scopes (not DB permissions).

Implementation note: require_keys_read is now a closure inside register_advanced_auth_routes so it can reference the auth_service instance (which is only available after app initialization, not at module definition time).

Pre-commit clean (black/flake8/isort/mypy/codespell). CI re-running on the new head.

@qinxuye qinxuye left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@qinxuye
qinxuye merged commit 0a310a5 into xorbitsai:main Jul 3, 2026
10 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants