Compare commits

..

13 Commits

Author SHA1 Message Date
TyperBody 6c05f3dfcb feat(plugins): show installed state in marketplace and stream install tasks
- report download progress (bytes, speed) and human-readable install stages
  from the plugin runtime connector via the install task context
- add a marketplace installed-state helper so market cards reflect whether a
  plugin is already installed
- surface install progress / queue state in the plugin install-task UI
- extend BackendClient and API entities with install-task status
- i18n for the new marketplace / install strings
2026-09-13 00:02:08 +08:00
huanghuoguoguo d26d0635c5 fix(vector): correct SeekDB adapter semantics (#2536) 2026-09-12 19:40:30 +08:00
彼方 58cde8c022 Merge pull request #2534 from langbot-app/fix/i18n-passkey-keys
fix(i18n): complete passkey keys across all locale files
2026-09-12 16:47:27 +08:00
彼方 9eb8683997 Merge pull request #2530 from langbot-app/feat/passkey-login
feat(auth): support passkey (webauthn) login and credential management
2026-09-12 16:00:10 +08:00
BiFangKNT 19526e1400 test(api): define explicit fixtures for passkey integration tests 2026-09-12 15:51:56 +08:00
BiFangKNT dfde9578c1 fix(ci): fix ruff lint errors and postgres legacy migration table exclusion 2026-09-12 15:35:47 +08:00
Hyu ec5b8cc8a8 docs(space): sync Runner usage recommendation contract (#2533)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 13:37:14 +08:00
Hyu 137bb4fdb3 fix(ci): recover immutable release PyPI builds (#2532)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 12:50:37 +08:00
Hyu 273b8839b9 chore(release): prepare LangBot 4.10.11 (#2531)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 12:33:26 +08:00
BiFangKNT 9db6650274 style(tests): Remove unused time import from test file 2026-09-12 12:26:51 +08:00
BiFangKNT b594cf23e4 feat(auth): add webauthn authentication support 2026-09-12 12:17:26 +08:00
Hyu 45d77c3926 fix(plugin): preserve explicit nested installation scope (#2528)
* fix(plugin): preserve explicit nested installation scope

* fix(deps): pin released RAG runtime SDK 0.5.8

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 17:41:33 +08:00
Hyu 1ea9cd3f6f fix(pipelines): show the actual sandbox scope restriction (#2527)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 14:42:38 +08:00
52 changed files with 3617 additions and 316 deletions
+35 -4
View File
@@ -2,6 +2,11 @@ name: Build and Publish to PyPI
on:
workflow_dispatch:
inputs:
source_ref:
description: 'Existing release tag to publish (for example v4.10.11)'
required: true
type: string
release:
types: [published]
@@ -11,13 +16,39 @@ jobs:
permissions:
contents: read
id-token: write # Required for trusted publishing to PyPI
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ inputs.source_ref || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Validate release source and version
env:
RELEASE_TAG: ${{ inputs.source_ref || github.event.release.tag_name }}
run: |
python3 - <<'PY'
import os
import re
import subprocess
import tomllib
from pathlib import Path
tag = os.environ['RELEASE_TAG']
if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag):
raise SystemExit('source_ref must be an existing release tag: vX.Y.Z')
def revision(ref):
return subprocess.check_output(['git', 'rev-parse', '--verify', ref], text=True).strip()
if revision('HEAD') != revision(f'refs/tags/{tag}^{{}}'):
raise SystemExit('Checked-out commit does not match the release tag')
version = tomllib.loads(Path('pyproject.toml').read_text())['project']['version']
if version != tag[1:]:
raise SystemExit(f'Package version {version} does not match tag {tag}')
print(f'Validated {tag} at {revision("HEAD")} (package {version})')
PY
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -26,9 +57,9 @@ jobs:
- name: Build frontend
run: |
cd web
npm install -g pnpm
pnpm install
pnpm build
# Match the archive/Docker npm path; npm ci rejects older tags' stale npm lockfiles.
npm install --include=optional
npm run build
mkdir -p ../src/langbot/web/dist
cp -r dist ../src/langbot/web/
+3
View File
@@ -57,3 +57,6 @@ testsdk/
# Next.js build cache (legacy)
web/.next/
web/.pnpm-home
.tmp
Caddyfile
+5 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.10"
version = "4.10.11"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.7",
"langbot-plugin==0.5.8",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -81,6 +81,7 @@ dependencies = [
"botocore>=1.42.39",
"litellm>=1.0.0",
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
"webauthn>=3.0.0",
]
keywords = [
"bot",
@@ -109,7 +110,8 @@ classifiers = [
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
"pyseekdb==1.4.0.post1",
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
]
[project.urls]
+34 -1
View File
@@ -25,7 +25,10 @@ CLI uses. Create one in your Space account (Profile → Personal Access Tokens),
then send it as a Bearer token:
```
Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
Authorization: Bearer <your-pat>
```
Requests without a valid PAT get `401 Unauthorized`.
## Client configuration
@@ -66,6 +69,36 @@ All tools are read-only.
state (available, unprobed, unavailable), then Space recommendation. Each
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
## Runner usage recommendations
Use `search_plugins` with `runner_usage: "agent"` for Agent, pipeline, and
setup-wizard recommendations, or `runner_usage: "event"` for event processors.
The component kind remains `Runner`. Only these two exact values are accepted;
omit the optional field to preserve unfiltered browsing.
```json
{"query":"", "runner_usage":"agent", "page":1, "page_size":100}
```
Plugin results include `latest_version` and `runner_usages: string[]`, the
explicit union of usages in that latest installable version. Only recommend a
plugin when this array explicitly contains the target usage. Missing, empty,
malformed, or unknown usages must never mean agent-compatible. Event-only
plugins must never enter Agent recommendations. Empty filtered results are
valid while legacy packages await corrected releases; never remove the filter
to fill a recommendation list.
REST callers use `runner_usage` on both
`POST /api/v1/marketplace/extensions/search` and the compatibility
`POST /api/v1/marketplace/plugins/search`; preserve it during fallback. Add
`"type_filter":"plugin", "component_filter":"Runner"` on the unified endpoint.
Usage is ANDed with other filters before pagination and `total`; MCP/Skill items
do not match. Invalid REST values return HTTP 400.
Open the same filter in the webpage:
`https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent`
(or `runner_usage=event`). Switch All / Agent / Event in the Runner usage row.
## Implementation & maintenance (for Space developers)
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
@@ -1,9 +1,12 @@
from __future__ import annotations
import quart
import argon2
import asyncio
import datetime
import hmac
import time
import typing
import uuid
from urllib.parse import parse_qs, urlsplit
@@ -64,6 +67,22 @@ class UserRouterGroup(group.RouterGroup):
return redirect_uri
def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]:
origin = ''
if json_data and isinstance(json_data, dict):
origin = json_data.get('origin', '')
if not origin:
origin = quart.request.headers.get('Origin', '')
if not origin:
origin = quart.request.headers.get('Referer', '')
if not origin:
origin = quart.request.url_root.rstrip('/')
parsed = urlsplit(origin)
rp_id = parsed.hostname or 'localhost'
clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/')
return clean_origin, rp_id
async def initialize(self) -> None:
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
@@ -387,6 +406,8 @@ class UserRouterGroup(group.RouterGroup):
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
capabilities['passkey_login_enabled'] = True
capabilities['passkey_supported'] = True
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -477,6 +498,182 @@ class UserRouterGroup(group.RouterGroup):
except Exception:
raise
@self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Generate WebAuthn registration options for current account."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = (await quart.request.json) or {}
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
account_uuid=user_obj.uuid,
rp_id=rp_id,
origin=origin,
rp_name='LangBot',
)
return self.success(data={'options': options, 'challenge_token': challenge_token})
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Verify WebAuthn registration response and save credential."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
name = json_data.get('name')
if not challenge_token or not credential:
return self.fail(1, 'Missing challenge_token or credential')
try:
cred = await self.ap.user_service.verify_and_save_passkey_registration(
challenge_token=challenge_token,
credential_data=credential,
name=name,
)
return self.success(
data={
'uuid': cred.uuid,
'name': cred.name,
'created_at': cred.created_at.isoformat() if cred.created_at else None,
}
)
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Generate WebAuthn authentication options for passkey login."""
json_data = (await quart.request.json) or {}
email = json_data.get('email')
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options(
rp_id=rp_id,
origin=origin,
email=email,
)
return self.success(data={'options': options, 'challenge_token': challenge_token})
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Verify WebAuthn authentication response and log in."""
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
if not challenge_token or not credential:
return self.fail(1, 'Missing challenge_token or credential')
try:
token, user_obj = await self.ap.user_service.verify_passkey_authentication(
challenge_token=challenge_token,
credential_data=credential,
)
return self.success(
data={
'token': token,
'user': user_obj.user,
}
)
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""List registered passkeys for the current user."""
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid)
return self.success(
data=[
{
'uuid': pk.uuid,
'name': pk.name,
'aaguid': pk.aaguid,
'transports': pk.transports,
'backed_up': pk.backed_up,
'created_at': pk.created_at.isoformat() if pk.created_at else None,
'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,
}
for pk in passkeys
]
)
@self.route('/passkey/<passkey_uuid>', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str, passkey_uuid: str) -> str:
"""Rename a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
name = (json_data.get('name') or '').strip()
if not name:
return self.fail(1, 'Passkey name cannot be empty')
updated = await self.ap.user_service.rename_user_passkey(
account_uuid=user_obj.uuid,
passkey_uuid=passkey_uuid,
new_name=name,
)
if not updated:
return self.http_status(404, -1, 'Passkey not found')
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
@self.route('/passkey/<passkey_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str, passkey_uuid: str) -> str:
"""Delete/revoke a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
deleted = await self.ap.user_service.delete_user_passkey(
account_uuid=user_obj.uuid,
passkey_uuid=passkey_uuid,
)
if not deleted:
return self.http_status(404, -1, 'Passkey not found')
return self.success()
async def _handle_space_direct_launch(
self,
launch_assertion: str,
+333
View File
@@ -4,6 +4,7 @@ import sqlalchemy
import argon2
import jwt
import datetime
import json
import typing
import asyncio
import dataclasses
@@ -12,10 +13,19 @@ import hashlib
import secrets
import time
import uuid
import webauthn
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import user
from ....entity.persistence import passkey
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
from ....utils import constants
from ....entity.errors import account as account_errors
@@ -29,6 +39,9 @@ if typing.TYPE_CHECKING:
_SPACE_OAUTH_STATE_MAX_ENTRIES = 4096
_SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64
_SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4
_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64
_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4
class AccountExistsLoginRequiredError(ValueError):
@@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption:
launch_workspace_uuid: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class PasskeyChallengeData:
challenge: bytes
purpose: typing.Literal['register', 'auth']
rp_id: str
origin: str
expires_at: float
account_uuid: str | None = None
user_email: str | None = None
class UserService:
ap: Application
_create_user_lock: asyncio.Lock
@@ -65,6 +89,9 @@ class UserService:
self._space_oauth_state_lock = asyncio.Lock()
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
self._space_oauth_state_expiry_heap: list[tuple[float, str]] = []
self._passkey_challenge_lock = asyncio.Lock()
self._passkey_challenges: dict[str, PasskeyChallengeData] = {}
self._passkey_challenge_expiry_heap: list[tuple[float, str]] = []
@staticmethod
def _space_oauth_state_digest(state: str) -> str:
@@ -850,3 +877,309 @@ class UserService:
await self._update_space_provider_for_account(local_account, api_key)
return await self.get_user_by_email(space_email)
def _prune_passkey_challenges(self, now: float) -> None:
while self._passkey_challenge_expiry_heap:
expires_at, token = self._passkey_challenge_expiry_heap[0]
entry = self._passkey_challenges.get(token)
if entry is None or entry.expires_at != expires_at:
heapq.heappop(self._passkey_challenge_expiry_heap)
continue
if expires_at > now:
break
heapq.heappop(self._passkey_challenge_expiry_heap)
self._passkey_challenges.pop(token, None)
max_heap_entries = max(
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR,
len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER,
)
if len(self._passkey_challenge_expiry_heap) > max_heap_entries:
self._passkey_challenge_expiry_heap[:] = [
(entry.expires_at, token) for token, entry in self._passkey_challenges.items()
]
heapq.heapify(self._passkey_challenge_expiry_heap)
async def issue_passkey_challenge(
self,
purpose: typing.Literal['register', 'auth'],
rp_id: str,
origin: str,
*,
account_uuid: str | None = None,
user_email: str | None = None,
ttl_seconds: int = 300,
) -> tuple[str, bytes]:
now = time.monotonic()
challenge_bytes = secrets.token_bytes(32)
challenge_token = secrets.token_urlsafe(32)
expires_at = now + ttl_seconds
async with self._passkey_challenge_lock:
self._prune_passkey_challenges(now)
while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES:
if not self._passkey_challenge_expiry_heap:
break
_, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap)
self._passkey_challenges.pop(oldest_token, None)
self._passkey_challenges[challenge_token] = PasskeyChallengeData(
challenge=challenge_bytes,
purpose=purpose,
rp_id=rp_id,
origin=origin,
expires_at=expires_at,
account_uuid=account_uuid,
user_email=user_email,
)
heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token))
return challenge_token, challenge_bytes
async def consume_passkey_challenge(
self,
challenge_token: str,
purpose: typing.Literal['register', 'auth'],
) -> PasskeyChallengeData:
now = time.monotonic()
async with self._passkey_challenge_lock:
self._prune_passkey_challenges(now)
data = self._passkey_challenges.pop(challenge_token, None)
if data is None or data.expires_at < now:
raise ValueError('Invalid or expired passkey challenge')
if data.purpose != purpose:
raise ValueError('Passkey challenge purpose mismatch')
return data
async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]:
statement = (
sqlalchemy.select(passkey.PasskeyCredential)
.where(passkey.PasskeyCredential.account_uuid == account_uuid)
.order_by(passkey.PasskeyCredential.created_at.desc())
)
async with self._session_factory()() as session:
result = await session.scalars(statement)
return list(result.all())
async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None:
statement = sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.credential_id == credential_id
)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None:
statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def generate_passkey_registration_options(
self,
account_uuid: str,
rp_id: str,
origin: str,
rp_name: str = 'LangBot',
) -> tuple[dict[str, typing.Any], str]:
account = await self.get_user_by_uuid(account_uuid)
if account is None:
raise ValueError('User not found')
self._require_active_account(account)
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
purpose='register',
rp_id=rp_id,
origin=origin,
account_uuid=account_uuid,
user_email=account.user,
)
existing_passkeys = await self.get_user_passkeys(account_uuid)
exclude_credentials = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys
]
options = webauthn.generate_registration_options(
rp_id=rp_id,
rp_name=rp_name,
user_name=account.user,
user_id=account.uuid.encode('utf-8'),
user_display_name=account.user,
challenge=challenge_bytes,
exclude_credentials=exclude_credentials or None,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
),
)
options_dict = json.loads(webauthn.options_to_json(options))
return options_dict, challenge_token
async def verify_and_save_passkey_registration(
self,
challenge_token: str,
credential_data: dict[str, typing.Any] | str,
name: str | None = None,
) -> passkey.PasskeyCredential:
challenge_data = await self.consume_passkey_challenge(challenge_token, 'register')
if not challenge_data.account_uuid:
raise ValueError('Registration challenge must be bound to an account')
verification = webauthn.verify_registration_response(
credential=credential_data,
expected_challenge=challenge_data.challenge,
expected_rp_id=challenge_data.rp_id,
expected_origin=challenge_data.origin,
require_user_verification=False,
)
cred_id_str = bytes_to_base64url(verification.credential_id)
pub_key_str = bytes_to_base64url(verification.credential_public_key)
transports = None
if isinstance(credential_data, dict):
resp = credential_data.get('response', {})
if isinstance(resp, dict) and 'transports' in resp:
t_list = resp.get('transports')
if isinstance(t_list, list):
transports = ','.join(str(x) for x in t_list)
credential_name = (name or '').strip()
if not credential_name:
credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})'
record = passkey.PasskeyCredential(
uuid=str(uuid.uuid4()),
account_uuid=challenge_data.account_uuid,
name=credential_name,
credential_id=cred_id_str,
public_key=pub_key_str,
sign_count=verification.sign_count,
aaguid=verification.aaguid,
transports=transports,
backed_up=verification.credential_backed_up,
)
async with self._session_factory()() as session:
async with session.begin():
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def generate_passkey_authentication_options(
self,
rp_id: str,
origin: str,
email: str | None = None,
) -> tuple[dict[str, typing.Any], str]:
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
purpose='auth',
rp_id=rp_id,
origin=origin,
user_email=email,
)
allow_credentials: list[PublicKeyCredentialDescriptor] | None = None
if email:
user_obj = await self.get_user_by_email(email)
if user_obj:
user_passkeys = await self.get_user_passkeys(user_obj.uuid)
if user_passkeys:
allow_credentials = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys
]
options = webauthn.generate_authentication_options(
rp_id=rp_id,
challenge=challenge_bytes,
allow_credentials=allow_credentials or None,
user_verification=UserVerificationRequirement.PREFERRED,
)
options_dict = json.loads(webauthn.options_to_json(options))
return options_dict, challenge_token
async def verify_passkey_authentication(
self,
challenge_token: str,
credential_data: dict[str, typing.Any] | str,
) -> tuple[str, user.User]:
challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth')
raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None
if not raw_id:
raise ValueError('Missing credential id')
stored_credential = await self.get_passkey_by_credential_id(raw_id)
if stored_credential is None:
raise ValueError('Passkey credential not recognized')
user_obj = await self.get_user_by_uuid(stored_credential.account_uuid)
if user_obj is None:
raise ValueError('Associated user not found')
self._require_active_account(user_obj)
verification = webauthn.verify_authentication_response(
credential=credential_data,
expected_challenge=challenge_data.challenge,
expected_rp_id=challenge_data.rp_id,
expected_origin=challenge_data.origin,
credential_public_key=base64url_to_bytes(stored_credential.public_key),
credential_current_sign_count=stored_credential.sign_count,
require_user_verification=False,
)
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.id == stored_credential.id
)
)
if record:
record.sign_count = verification.new_sign_count
record.last_used_at = datetime.datetime.now()
record.backed_up = verification.credential_backed_up
token = await self.generate_jwt_token(user_obj)
return token, user_obj
async def rename_user_passkey(
self,
account_uuid: str,
passkey_uuid: str,
new_name: str,
) -> passkey.PasskeyCredential | None:
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.uuid == passkey_uuid,
passkey.PasskeyCredential.account_uuid == account_uuid,
)
)
if record is None:
return None
record.name = new_name
await session.flush()
await session.refresh(record)
return record
async def delete_user_passkey(
self,
account_uuid: str,
passkey_uuid: str,
) -> bool:
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.uuid == passkey_uuid,
passkey.PasskeyCredential.account_uuid == account_uuid,
)
)
if record is None:
return False
await session.delete(record)
return True
@@ -0,0 +1,38 @@
from __future__ import annotations
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class PasskeyCredential(Base):
__tablename__ = 'passkey_credentials'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
__table_args__ = (
sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True),
sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'),
)
@@ -0,0 +1,54 @@
"""add passkey credentials table
Revision ID: 0024_passkey_credentials
Revises: 0023_bot_scoped_sessions
Create Date: 2026-09-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0024_passkey_credentials'
down_revision = '0023_bot_scoped_sessions'
branch_labels = None
depends_on = None
_TABLE_NAME = 'passkey_credentials'
def upgrade() -> None:
conn = op.get_bind()
existing_tables = set(sa.inspect(conn).get_table_names())
if _TABLE_NAME not in existing_tables:
op.create_table(
_TABLE_NAME,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('credential_id', sa.String(255), nullable=False),
sa.Column('public_key', sa.Text(), nullable=False),
sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('aaguid', sa.String(64), nullable=True),
sa.Column('transports', sa.String(255), nullable=True),
sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
)
op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True)
op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False)
def downgrade() -> None:
op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME)
op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME)
op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME)
op.drop_table(_TABLE_NAME)
+1
View File
@@ -63,6 +63,7 @@ _ALEMBIC_TENANT_TABLES = {
'mcp_servers',
'model_providers',
'codex_credentials',
'passkey_credentials',
'llm_models',
'embedding_models',
'rerank_models',
+41
View File
@@ -87,8 +87,10 @@ async def _read_httpx_response_limited(
response: httpx.Response,
*,
max_bytes: int,
task_context: taskmgr.TaskContext | None = None,
) -> bytes:
content_length = response.headers.get('content-length')
declared_size: int | None = None
if content_length is not None:
try:
declared_size = int(content_length)
@@ -97,11 +99,23 @@ async def _read_httpx_response_limited(
if declared_size is not None and declared_size > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
if task_context is not None and declared_size is not None:
task_context.metadata['download_total'] = declared_size
start_time = time.time()
body = bytearray()
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
body.extend(chunk)
if len(body) > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
if task_context is not None:
elapsed = time.time() - start_time
task_context.metadata.update(
{
'download_current': len(body),
'download_speed': len(body) / elapsed if elapsed > 0 else 0,
}
)
return bytes(body)
@@ -111,6 +125,7 @@ async def _marketplace_get(
*,
max_bytes: int,
allow_not_found: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> tuple[int, bytes]:
async with client.stream('GET', url) as response:
if allow_not_found and response.status_code == 404:
@@ -119,6 +134,7 @@ async def _marketplace_get(
return response.status_code, await _read_httpx_response_limited(
response,
max_bytes=max_bytes,
task_context=task_context,
)
@@ -1680,6 +1696,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
client,
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
task_context=task_context,
)
return plugin_package, latest_version
@@ -1695,7 +1712,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_name = str(install_info.get('plugin_name') or '')
file_bytes: bytes | None
if task_context is not None:
# Reset per-install progress counters so a re-install of the same
# plugin does not inherit stale metadata from a previous task.
task_context.set_current_action('preparing plugin install')
task_context.metadata.update(
{
'download_total': 0,
'download_current': 0,
'download_speed': 0,
}
)
if install_source == PluginInstallSource.MARKETPLACE:
if task_context is not None:
task_context.set_current_action('downloading plugin package')
file_bytes, version = await self._download_marketplace_package(
execution_context,
plugin_author,
@@ -1719,6 +1750,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
else:
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
if task_context is not None:
task_context.set_current_action('inspecting plugin package')
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
if not manifest_author or not manifest_name:
raise ValueError('Plugin package manifest identity is missing')
@@ -1730,8 +1763,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if task_context is not None:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
if task_context is not None:
task_context.set_current_action('storing plugin package')
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
if task_context is not None:
task_context.set_current_action('installing plugin dependencies')
try:
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
execution_context,
@@ -1749,6 +1786,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author=plugin_author,
plugin_name=plugin_name,
)
if task_context is not None:
task_context.set_current_action('launching plugin')
await self._apply_desired_state(
PluginInstallationDesiredState(binding=binding, enabled=True),
artifact_package=file_bytes,
@@ -1766,6 +1805,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
pass
except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
if task_context is not None:
task_context.set_current_action('waiting for plugin to become ready')
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
async def upgrade_plugin(
+8 -6
View File
@@ -48,6 +48,7 @@ from ..utils import constants
_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
_UNSET_INSTALLATION_SCOPE = object()
def _binary_storage_value_limit(ap: Any) -> int:
@@ -479,7 +480,6 @@ class RuntimeConnectionHandler(handler.Handler):
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
default=None,
)
)
self._installation_bindings: dict[
@@ -1631,13 +1631,15 @@ class RuntimeConnectionHandler(handler.Handler):
) -> InstallationBinding | ActionContext | None:
if action_context is not None:
return super().resolve_outbound_action_context(action_context)
inbound_context = self.current_action_context
if inbound_context is not None:
return inbound_context
return self._outbound_installation_context.get()
# An explicit scope targets the nested call, not its inbound caller.
# None deliberately clears the context for runtime-scoped actions.
scoped_context = self._outbound_installation_context.get(_UNSET_INSTALLATION_SCOPE)
if scoped_context is not _UNSET_INSTALLATION_SCOPE:
return typing.cast(InstallationBinding | None, scoped_context)
return self.current_action_context
def require_outbound_installation_context(self) -> InstallationBinding:
binding = self._outbound_installation_context.get()
binding = self._outbound_installation_context.get(None)
if not isinstance(binding, InstallationBinding):
raise ValueError('Host plugin action requires an InstallationBinding scope')
return binding
+25 -26
View File
@@ -101,18 +101,6 @@ class SeekDBVectorDatabase(VectorDatabase):
self._collection_configs: Dict[str, HNSWConfiguration] = {}
self._runtime_cache_limit = runtime_cache_limit(ap)
self._escape_table = str.maketrans(
{
'\x00': '',
'\\': '\\\\',
"'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
}
)
async def close(self) -> None:
self._collections.clear()
self._collection_configs.clear()
@@ -192,16 +180,22 @@ class SeekDBVectorDatabase(VectorDatabase):
return coll
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
return {
k: v.translate(self._escape_table)
if isinstance(v, str)
else v
if v is None or isinstance(v, (int, float, bool))
else str(v)
for k, v in meta.items()
if v is not None
}
"""Keep supported scalar metadata values without altering strings."""
return {k: v if isinstance(v, (str, int, float, bool)) else str(v) for k, v in meta.items() if v is not None}
@staticmethod
def _relevance_scores_to_distances(results: Dict[str, Any]) -> None:
"""Convert SeekDB hybrid relevance scores to lower-is-better distances."""
distances = results.get('distances')
if not isinstance(distances, list):
return
results['distances'] = [
[1.0 - float(score) if isinstance(score, (int, float)) else score for score in batch]
if isinstance(batch, list)
else batch
for batch in distances
]
async def get_or_create_collection(self, collection: str):
"""Get or create collection (without vector size - will use default)."""
@@ -236,10 +230,10 @@ class SeekDBVectorDatabase(VectorDatabase):
kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
if documents is not None:
kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]
await asyncio.to_thread(coll.add, **kwargs)
kwargs['documents'] = documents
await asyncio.to_thread(coll.upsert, **kwargs)
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
self.ap.logger.info(f"Upserted {len(ids)} embeddings into SeekDB collection '{collection}'")
async def search(
self,
@@ -287,7 +281,8 @@ class SeekDBVectorDatabase(VectorDatabase):
# Route by search type.
# pyseekdb's query() always requires embeddings, so full-text and
# hybrid modes use hybrid_search() which supports text-only queries
# and returns the same nested-list format with distances.
# and returns relevance scores in the nested ``distances`` field.
returns_relevance_scores = False
if search_type == SearchType.FULL_TEXT:
if not query_text:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
@@ -309,6 +304,7 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
returns_relevance_scores = True
elif search_type == SearchType.HYBRID:
if not query_text:
@@ -352,6 +348,7 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
returns_relevance_scores = True
self.ap.logger.info(
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
@@ -363,6 +360,8 @@ class SeekDBVectorDatabase(VectorDatabase):
results = await asyncio.to_thread(coll.query, **query_kwargs)
results = self._json_safe(results)
if returns_relevance_scores:
self._relevance_scores_to_distances(results)
self.ap.logger.info(
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
)
+35 -12
View File
@@ -143,18 +143,41 @@ stages:
operator: eq
value: false
disabled_tooltip:
en_US: >-
Sandbox scope can't be changed: either the Box sandbox is disabled
or unavailable (enable it in config.yaml with box.enabled = true and
ensure the runtime is reachable), or this deployment pins all
pipelines to a fixed scope.
zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。"
zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。"
ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。"
vi_VN: "Không thể thay đổi phạm vi sandboxBox sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi."
th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว"
es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único."
ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров."
en_US: "Sandbox is unavailable. Enable Box and check its connection before changing the scope."
zh_Hans: "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。"
zh_Hant: "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。"
ja_JP: "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。"
vi_VN: "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi."
th_TH: "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต"
es_ES: "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance."
ru_RU: "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область."
disabled_tooltip_overrides:
- when:
field: __system.box_scope_forced_global
operator: eq
value: true
tooltip:
en_US: "A global sandbox is enforced; the scope cannot be changed."
zh_Hans: "已强制使用全局沙箱,无法修改作用域。"
zh_Hant: "已強制使用全域沙箱,無法修改作用域。"
ja_JP: "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。"
vi_VN: "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un sandbox global; no se puede cambiar el alcance."
ru_RU: "Принудительно используется глобальная песочница; изменить область нельзя."
- when:
field: __system.box_scope_forced
operator: eq
value: true
tooltip:
en_US: "A fixed sandbox scope is enforced; the scope cannot be changed."
zh_Hans: "已强制使用固定沙箱作用域,无法修改作用域。"
zh_Hant: "已強制使用固定沙箱作用域,無法修改作用域。"
ja_JP: "固定のサンドボックススコープが強制されているため、スコープを変更できません。"
vi_VN: "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance."
ru_RU: "Принудительно задана фиксированная область песочницы; изменить её нельзя."
type: select
required: false
default: "{launcher_type}_{launcher_id}"
+6
View File
@@ -310,6 +310,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': True,
'password_login_enabled': True,
'space_login_enabled': False,
'passkey_login_enabled': True,
'passkey_supported': True,
}
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
fake_api_app.user_service.get_first_user.assert_not_awaited()
@@ -334,6 +336,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': False,
'password_login_enabled': False,
'space_login_enabled': True,
'passkey_login_enabled': True,
'passkey_supported': True,
}
@pytest.mark.asyncio
@@ -355,6 +359,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': True,
'password_login_enabled': False,
'space_login_enabled': True,
'passkey_login_enabled': True,
'passkey_supported': True,
}
@pytest.mark.asyncio
@@ -0,0 +1,190 @@
"""
Integration smoke tests for Passkey API endpoints.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock
import pytest
from tests.factories import FakeApp
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')]
@pytest.fixture(scope='module')
def mock_circular_import_chain():
class FakeMinimalApplication:
pass
mock_app = Mock()
mock_app.Application = FakeMinimalApplication
mock_entities = Mock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.system',
'langbot.pkg.api.http.controller.groups.user',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.user as _user_group # noqa: E402, F401
yield
@pytest.fixture
def fake_api_app():
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True},
}
)
app.user_service = Mock()
app.user_service.verify_jwt_token = AsyncMock(side_effect=ValueError('Invalid token'))
app.user_service.get_user_by_email = AsyncMock(return_value=Mock())
return app
@pytest.fixture
async def quart_test_client(fake_api_app, http_controller_cls):
controller = http_controller_cls(fake_api_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
class TestPasskeyPublicEndpoints:
@pytest.mark.asyncio
async def test_auth_options_endpoint(self, quart_test_client, fake_api_app):
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
return_value=({'challenge': 'test_chal', 'rpId': 'localhost'}, 'token_123')
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/options',
json={'origin': 'http://localhost:3000'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['challenge_token'] == 'token_123'
assert data['data']['options']['rpId'] == 'localhost'
@pytest.mark.asyncio
async def test_auth_verify_missing_payload(self, quart_test_client, fake_api_app):
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/verify',
json={},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] != 0
assert 'Missing challenge_token or credential' in data['msg']
@pytest.mark.asyncio
async def test_auth_verify_success(self, quart_test_client, fake_api_app):
fake_api_app.user_service.verify_passkey_authentication = AsyncMock(
return_value=('jwt_token_abc', Mock(user='user@example.com'))
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/verify',
json={'challenge_token': 'token_123', 'credential': {'id': 'cred_id'}},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['token'] == 'jwt_token_abc'
assert data['data']['user'] == 'user@example.com'
class TestPasskeyProtectedEndpoints:
@pytest.mark.asyncio
async def test_register_options_requires_auth(self, quart_test_client):
response = await quart_test_client.post('/api/v1/user/passkey/register/options', json={})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_passkeys_list_requires_auth(self, quart_test_client):
response = await quart_test_client.get('/api/v1/user/passkeys')
assert response.status_code == 401
class TestPasskeyReverseProxyScenarios:
@pytest.mark.asyncio
async def test_auth_options_respects_custom_origin_body_behind_proxy(self, quart_test_client, fake_api_app):
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
return_value=({'challenge': 'test_chal', 'rpId': 'proxy.company.com'}, 'token_proxy')
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/options',
json={'origin': 'https://proxy.company.com:8443'},
headers={'Host': '127.0.0.1:5300'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
rp_id='proxy.company.com',
origin='https://proxy.company.com:8443',
email=None,
)
@pytest.mark.asyncio
async def test_auth_options_falls_back_to_origin_header(self, quart_test_client, fake_api_app):
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_header')
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/options',
json={},
headers={'Origin': 'https://bot.example.com'},
)
assert response.status_code == 200
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
rp_id='bot.example.com',
origin='https://bot.example.com',
email=None,
)
@pytest.mark.asyncio
async def test_auth_options_falls_back_to_referer_header(self, quart_test_client, fake_api_app):
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_referer')
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/options',
json={},
headers={'Referer': 'https://bot.example.com:9000/login'},
)
assert response.status_code == 200
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
rp_id='bot.example.com',
origin='https://bot.example.com:9000',
email=None,
)
@@ -550,11 +550,13 @@ class TestPostgreSQLWorkspaceMigration:
)
assert 'workspaces' not in tables_before_migration
assert 'codex_credentials' not in tables_before_migration
assert 'passkey_credentials' not in tables_before_migration
await manager._initialize_managed_schema()
async with postgres_engine.connect() as conn:
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
assert 'passkey_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
workspace = (
(await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
@@ -0,0 +1,307 @@
"""Real Core/SDK protocol regression tests; no subprocesses or external services.
Run against the intended local SDK (``uv run --no-sync`` after local install).
The in-memory transport carries JSON strings through Handler.run on both sides;
send_file, envelope validation, base64 decoding and transfer storage are real.
Only Core's database/object-storage services, parser dispatch/provider and host
sandbox prerequisite probing are doubles. Worker launch/registration is
represented by its already-registered state.
"""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
from langbot_plugin.runtime.context import RuntimeContext
from langbot_plugin.runtime.io.connection import Connection
from langbot_plugin.entities.io.errors import ActionCallError, ConnectionClosedError
from langbot_plugin.runtime.io.handler import FILE_CHUNK_LENGTH, Handler
from langbot_plugin.runtime.io.handlers.control import ControlConnectionHandler
from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler
from langbot_plugin.runtime.plugin.mgr import PluginManager
from langbot_plugin.runtime.security import PLUGIN_FILE_STORAGE_DIR_ENV
pytestmark = pytest.mark.asyncio
PAYLOAD = bytes(range(256)) * 161 + b'\x00original RAG file\xff'
BINDING = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=3,
artifact_digest='a' * 64,
)
LEGACY = ActionContext(**BINDING.model_dump(exclude={'runtime_revision', 'artifact_digest'}))
class QueueConnection(Connection):
"""Only the byte transport is replaced, not the request/response machinery."""
def __init__(self):
self.incoming = asyncio.Queue()
self.sent = []
self.peer = None
async def send(self, message: str) -> None:
assert isinstance(message, str)
self.sent.append(json.loads(message))
await self.peer.incoming.put(message)
async def receive(self) -> str:
message = await self.incoming.get()
if message is None:
raise ConnectionClosedError('test transport closed')
return message
async def close(self) -> None:
await self.incoming.put(None)
await self.peer.incoming.put(None)
def connection_pair():
left, right = QueueConnection(), QueueConnection()
left.peer, right.peer = right, left
return left, right
@asynccontextmanager
async def protocol_stack(tmp_path, monkeypatch, profile='oss_dev', binding=LEGACY):
monkeypatch.chdir(tmp_path)
stored = tmp_path / 'original.bin'
stored.write_bytes(PAYLOAD)
storage_calls = []
async def get_file_stream(execution_context, storage_path):
storage_calls.append((execution_context, storage_path))
assert execution_context.workspace_uuid == BINDING.workspace_uuid
assert storage_path == 'knowledge/original.bin'
return stored.read_bytes()
async def get_execution_binding(workspace_uuid, expected_generation):
assert workspace_uuid == BINDING.workspace_uuid
assert expected_generation == BINDING.placement_generation
return BINDING
setting = SimpleNamespace(
plugin_author='tester',
plugin_name='engine',
installation_uuid=BINDING.installation_uuid,
runtime_revision=BINDING.runtime_revision,
artifact_digest=BINDING.artifact_digest,
)
app = SimpleNamespace(
deployment=SimpleNamespace(mode='oss' if profile == 'oss_dev' else 'cloud'),
logger=logging.getLogger(__name__),
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(first=lambda: setting))),
workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding),
rag_runtime_service=SimpleNamespace(get_file_stream=get_file_stream),
)
core_conn, control_conn = connection_pair()
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'core-transfer'))
core = RuntimeConnectionHandler(core_conn, AsyncMock(return_value=False), app)
core.register_installation_binding(BINDING, plugin_author='tester', plugin_name='engine')
runtime = RuntimeContext()
runtime.plugin_mgr = PluginManager(runtime)
# No worker is launched: omit only host nsjail/cgroup prerequisite probing.
monkeypatch.setattr(runtime.plugin_mgr.worker_launcher, 'configure', lambda policy, profile: None)
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'runtime-transfer'))
control = ControlConnectionHandler(control_conn, runtime)
runtime.activate_control_handler(control)
bridge_conn, plugin_conn = connection_pair()
bridge = PluginConnectionHandler(bridge_conn, runtime, file_storage_dir=str(tmp_path / 'bridge-transfer'))
plugin = Handler(plugin_conn, file_storage_dir=str(tmp_path / 'plugin-transfer'))
# Trusted state left by registration, not plugin-supplied action data.
bridge.bind_action_context(binding)
runtime.plugin_mgr.plugin_handlers.append(bridge)
runtime.plugin_mgr.plugins.append(SimpleNamespace(_runtime_plugin_handler=bridge))
handlers = [core, control, bridge, plugin]
tasks = [asyncio.create_task(handler.run()) for handler in handlers]
try:
await asyncio.wait_for(
core.set_runtime_config(
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='test-runtime'),
worker_policy=PluginWorkerPolicy(
max_cpus=1,
max_memory_mb=128,
max_pids=32,
max_open_files=64,
max_file_size_mb=8,
require_hard_limits=False,
),
runtime_profile=profile,
cloud_service_url=None,
),
5,
)
if isinstance(binding, InstallationBinding):
runtime.activate_installation_binding(binding)
else:
runtime.bind_workspace(binding)
yield SimpleNamespace(
core=core,
control=control,
runtime=runtime,
bridge=bridge,
plugin=plugin,
core_conn=core_conn,
control_conn=control_conn,
bridge_conn=bridge_conn,
plugin_conn=plugin_conn,
app=app,
storage_calls=storage_calls,
)
finally:
for handler in handlers:
await handler.close()
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 5)
def assert_chunks(connection, binding, payload=PAYLOAD):
chunks = [message for message in connection.sent if message.get('action') == CommonAction.FILE_CHUNK.value]
expected = (len(payload) + FILE_CHUNK_LENGTH - 1) // FILE_CHUNK_LENGTH
assert expected > 1
assert len(chunks) == expected
assert [chunk['data']['chunk_index'] for chunk in chunks] == list(range(expected))
assert {chunk['data']['chunk_amount'] for chunk in chunks} == {expected}
assert all(chunk['context'] == binding.model_dump() for chunk in chunks)
assert len({chunk['data']['file_key'] for chunk in chunks}) == 1
return chunks[0]['data']['file_key']
@pytest.mark.parametrize(
'profile,binding',
[('oss_dev', LEGACY), ('oss_dev', BINDING), ('shared', BINDING)],
ids=['legacy-oss', 'managed-oss', 'managed-shared'],
)
async def test_knowledge_file_roundtrip_reaches_plugin_original_bytes(tmp_path, monkeypatch, profile, binding):
async with protocol_stack(tmp_path, monkeypatch, profile, binding) as stack:
# Legacy plugin API sends no authority; Runtime supplies its trusted binding.
result = await asyncio.wait_for(
stack.plugin.call_action(
PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM,
{'storage_path': 'knowledge/original.bin'},
),
5,
)
assert await stack.plugin.read_local_file(result['file_key']) == PAYLOAD
assert len(stack.storage_calls) == 1
core_key = assert_chunks(stack.core_conn, binding)
plugin_key = assert_chunks(stack.bridge_conn, binding)
assert result['file_key'] == plugin_key != core_key
assert not (Path(stack.control.file_storage_dir) / core_key).exists()
assert not stack.control._owned_transfer_files
callbacks = [
message
for message in stack.control_conn.sent
if message.get('action') == PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM.value
]
assert len(callbacks) == 1
assert callbacks[0]['context'] == binding.model_dump()
assert callbacks[0]['data'] == {'storage_path': 'knowledge/original.bin'}
async def test_shared_control_rejects_legacy_chunks_before_storage(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
with stack.core.installation_scope(LEGACY):
with pytest.raises(ActionCallError, match='InstallationBinding|Legacy FILE_CHUNK'):
await asyncio.wait_for(stack.core.send_file(PAYLOAD, ''), 5)
assert not list(Path(stack.control.file_storage_dir).iterdir())
assert not stack.control._owned_transfer_files
async def test_candidate_artifact_pretransfer_does_not_require_active_installation(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
candidate = BINDING.model_copy(
update={'installation_uuid': 'candidate-installation', 'runtime_revision': 1, 'artifact_digest': 'c' * 64}
)
assert not stack.runtime.is_current_installation_binding(candidate)
with stack.core.installation_scope(candidate):
key = await asyncio.wait_for(stack.core.send_file(PAYLOAD, 'lbp'), 5)
assert_chunks(stack.core_conn, candidate)
assert await stack.control.read_local_file(key) == PAYLOAD
assert not stack.runtime.is_current_installation_binding(candidate)
async def test_nested_parser_target_owns_file_and_action_envelopes(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
target = BINDING.model_copy(
update={
'installation_uuid': 'parser-installation',
'runtime_revision': 2,
'artifact_digest': 'b' * 64,
}
)
stack.runtime.activate_installation_binding(target)
parser_calls = []
restored = []
async def parse_document(author, name, context_data, file_bytes):
parser_calls.append((stack.control.current_action_context, author, name, context_data, file_bytes))
return {'documents': [{'text': 'parsed'}]}
stack.runtime.plugin_mgr.parse_document = parse_document
class ParserConnector:
async def require_workspace_context(self, context):
assert context.workspace_uuid == BINDING.workspace_uuid
async def call_parser(self, plugin_name, context_data, file_bytes):
assert plugin_name == 'tester/parser'
assert stack.core.current_action_context == BINDING
with stack.core.installation_scope(target):
result = await stack.core.parse_document('tester', 'parser', context_data, file_bytes)
restored.append(stack.core.resolve_outbound_action_context(None))
return result
stack.app.plugin_connector = ParserConnector()
result = await asyncio.wait_for(
stack.plugin.call_action(
PluginToRuntimeAction.INVOKE_PARSER,
{
'plugin_author': 'tester',
'plugin_name': 'parser',
'storage_path': 'knowledge/original.bin',
'filename': 'original.bin',
},
),
5,
)
assert result == {'documents': [{'text': 'parsed'}]}
key = assert_chunks(stack.core_conn, target)
parse_requests = [
message
for message in stack.core_conn.sent
if message.get('action') == LangBotToRuntimeAction.PARSE_DOCUMENT.value
]
assert len(parse_requests) == 1
assert parse_requests[0]['context'] == target.model_dump()
assert parse_requests[0]['data']['context']['file_key'] == key
assert parser_calls == [
(
target,
'tester',
'parser',
{
'mime_type': 'application/octet-stream',
'filename': 'original.bin',
'metadata': {},
},
PAYLOAD,
)
]
assert restored == [BINDING]
assert stack.core.current_action_context is None
assert stack.core.resolve_outbound_action_context(None) is None
assert not (Path(stack.control.file_storage_dir) / key).exists()
+123
View File
@@ -0,0 +1,123 @@
"""Real embedded SeekDB regression tests.
Install the optional dependency before running these slow tests::
uv sync --dev --extra seekdb
uv run pytest tests/integration/vector/test_seekdb.py -m slow -q
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import uuid
import pytest
pytest.importorskip('pyseekdb')
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
pytestmark = [pytest.mark.integration, pytest.mark.slow]
@pytest.fixture
async def backend(tmp_path):
app = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'vdb': {
'runtime_cache_limit': 16,
'seekdb': {
'mode': 'embedded',
'path': str(tmp_path),
'database': 'langbot_test',
},
}
}
),
logger=SimpleNamespace(
info=lambda *args, **kwargs: None,
warning=lambda *args, **kwargs: None,
),
)
database = SeekDBVectorDatabase(app)
collection = f'test_{uuid.uuid4().hex}'
yield database, collection
await database.delete_collection(collection)
await database.close()
@pytest.mark.asyncio
async def test_upsert_and_text_round_trip(backend) -> None:
database, collection = backend
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
updated = f'Updated: {original}'
await database.add_embeddings(
collection,
['document-a'],
[[1.0, 0.0, 0.0]],
[{'file_id': 'file-a', 'text': original}],
[original],
)
await database.add_embeddings(
collection,
['document-a'],
[[0.0, 1.0, 0.0]],
[{'file_id': 'file-a', 'text': updated}],
[updated],
)
items, _ = await database.list_by_filter(collection, {'file_id': 'file-a'})
assert len(items) == 1
assert items[0]['id'] == 'document-a'
assert items[0]['document'] == updated
assert items[0]['metadata']['text'] == updated
@pytest.mark.asyncio
async def test_full_text_and_hybrid_results_keep_relevance_order(backend) -> None:
database, collection = backend
documents = [
'orchid orchid orchid flower',
'orchid grows in a garden with many other beautiful plants',
'a completely unrelated topic',
]
await database.add_embeddings(
collection,
['best', 'weak', 'noise'],
[[1.0, 0.0, 0.0], [0.9, 0.1, 0.0], [0.0, 0.0, 1.0]],
[
{'file_id': item_id, 'document_id': item_id, 'text': document}
for item_id, document in zip(['best', 'weak', 'noise'], documents, strict=True)
],
documents,
)
seekdb_collection = await database.get_or_create_collection(collection)
await asyncio.to_thread(seekdb_collection.refresh_index)
full_text = await database.search(
collection,
[1.0, 0.0, 0.0],
k=3,
search_type='full_text',
query_text='orchid',
)
hybrid = await database.search(
collection,
[1.0, 0.0, 0.0],
k=3,
search_type='hybrid',
query_text='orchid',
vector_weight=0.65,
)
assert full_text['ids'][0][:2] == ['best', 'weak']
assert full_text['distances'][0] == sorted(full_text['distances'][0])
assert hybrid['ids'][0] == ['best', 'weak', 'noise']
assert hybrid['distances'][0] == sorted(hybrid['distances'][0])
@@ -0,0 +1,103 @@
"""
Unit tests for Passkey WebAuthn service operations in UserService.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.service.user import UserService
from langbot.pkg.entity.persistence.user import AccountStatus, User
pytestmark = pytest.mark.asyncio
class TestPasskeyChallengeLifecycle:
async def test_challenge_issuance_and_consumption(self):
service = UserService(SimpleNamespace())
token, challenge_bytes = await service.issue_passkey_challenge(
purpose='register',
rp_id='localhost',
origin='http://localhost:3000',
account_uuid='acc-123',
user_email='user@example.com',
)
assert len(token) > 20
assert len(challenge_bytes) == 32
data = await service.consume_passkey_challenge(token, 'register')
assert data.challenge == challenge_bytes
assert data.rp_id == 'localhost'
assert data.origin == 'http://localhost:3000'
assert data.account_uuid == 'acc-123'
assert data.user_email == 'user@example.com'
# Replay should fail
with pytest.raises(ValueError, match='Invalid or expired passkey challenge'):
await service.consume_passkey_challenge(token, 'register')
async def test_challenge_purpose_mismatch_fails(self):
service = UserService(SimpleNamespace())
token, _ = await service.issue_passkey_challenge(
purpose='register',
rp_id='localhost',
origin='http://localhost:3000',
)
with pytest.raises(ValueError, match='Passkey challenge purpose mismatch'):
await service.consume_passkey_challenge(token, 'auth')
async def test_challenge_expiration(self):
service = UserService(SimpleNamespace())
token, _ = await service.issue_passkey_challenge(
purpose='auth',
rp_id='localhost',
origin='http://localhost:3000',
ttl_seconds=0,
)
with pytest.raises(ValueError, match='Invalid or expired passkey challenge'):
await service.consume_passkey_challenge(token, 'auth')
class TestPasskeyOptionsGeneration:
async def test_generate_registration_options(self):
service = UserService(SimpleNamespace())
mock_user = Mock(spec=User)
mock_user.uuid = 'acc-test-uuid'
mock_user.user = 'test@example.com'
mock_user.status = AccountStatus.ACTIVE.value
service.get_user_by_uuid = AsyncMock(return_value=mock_user)
service.get_user_passkeys = AsyncMock(return_value=[])
options, token = await service.generate_passkey_registration_options(
account_uuid='acc-test-uuid',
rp_id='localhost',
origin='http://localhost:3000',
rp_name='LangBot Test',
)
assert isinstance(options, dict)
assert options['rp']['name'] == 'LangBot Test'
assert options['rp']['id'] == 'localhost'
assert options['user']['name'] == 'test@example.com'
assert 'challenge' in options
assert len(token) > 0
async def test_generate_authentication_options_discoverable(self):
service = UserService(SimpleNamespace())
options, token = await service.generate_passkey_authentication_options(
rp_id='localhost',
origin='http://localhost:3000',
)
assert isinstance(options, dict)
assert options['rpId'] == 'localhost'
assert 'challenge' in options
assert len(token) > 0
@@ -0,0 +1,193 @@
"""Exercise nested installation routing through real Core/SDK wire envelopes."""
from __future__ import annotations
import asyncio
import base64
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.req import ActionRequest
from langbot_plugin.entities.io.resp import ActionResponse
from langbot_plugin.runtime.io import handler as sdk_handler
from langbot.pkg.plugin.connector import PluginRuntimeConnector
from tests.unit_tests.plugin.test_handler_tenancy import RecordingConnection, make_handler, workspace_context
class ReplyingConnection(RecordingConnection):
"""Replace only the transport, retaining serialization and response routing."""
async def send(self, message: str) -> None:
await super().send(message)
request = json.loads(message)
if 'action' in request:
response = ActionResponse.success({'elements': []})
response.seq_id = request['seq_id']
await self.handler._route_response(response.seq_id, response.model_dump())
@property
def requests(self):
return [request for message in self.sent if 'action' in (request := json.loads(message))]
@pytest.fixture
def bridge(monkeypatch):
runtime_handler, app, binding_a = make_handler()
connection = ReplyingConnection()
connection.handler = runtime_handler
runtime_handler.conn = connection
monkeypatch.setattr(sdk_handler, 'FILE_CHUNK_LENGTH', 4)
binding_b = binding_a.model_copy(
update={
'installation_uuid': '00000000-0000-4000-8000-000000000002',
'runtime_revision': 2,
'artifact_digest': 'b' * 64,
}
)
return runtime_handler, app, connection, binding_a, binding_b
@pytest.mark.asyncio
@pytest.mark.parametrize('mode', ['managed', 'legacy'])
async def test_nested_invoke_parser_uses_target_for_every_chunk_and_parse(bridge, mode):
runtime_handler, app, connection, binding_a, binding_b = bridge
app.instance_config = SimpleNamespace(data={'plugin': {'enable': True}})
app.deployment.mode = 'cloud' if mode == 'managed' else 'oss'
connector = PluginRuntimeConnector(app, AsyncMock())
connector.handler = runtime_handler
app.plugin_connector = connector
execution_context = runtime_handler._execution_context(binding_a)
setting_b = SimpleNamespace(
installation_uuid=binding_b.installation_uuid,
runtime_revision=binding_b.runtime_revision,
artifact_digest=binding_b.artifact_digest,
install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if mode == 'managed' else {},
)
connector._setting_for_plugin = AsyncMock(return_value=(execution_context, setting_b))
connector.require_workspace_context = AsyncMock(return_value=execution_context)
file_bytes = b'parser document'
app.rag_runtime_service = SimpleNamespace(get_file_stream=AsyncMock(return_value=file_bytes))
inbound_context = binding_a
if mode == 'legacy':
inbound_context = workspace_context().for_installation(binding_a.installation_uuid)
setting_a = SimpleNamespace(
plugin_author='author-a',
plugin_name='plugin-a',
installation_uuid=binding_a.installation_uuid,
runtime_revision=binding_a.runtime_revision,
artifact_digest=binding_a.artifact_digest,
)
app.persistence_mgr.execute_async.return_value = SimpleNamespace(first=lambda: setting_a)
expected = binding_b if mode == 'managed' else connector._legacy_oss_bridge_binding(execution_context)
request = ActionRequest.make_request(
101,
PluginToRuntimeAction.INVOKE_PARSER.value,
{'plugin_author': 'author-b', 'plugin_name': 'parser-b', 'storage_path': 'file-a'},
inbound_context,
)
await runtime_handler._handle_action(request.model_dump())
response = json.loads(connection.sent[-1])
assert response['code'] == 0, response
chunks = connection.requests[:-1]
parse = connection.requests[-1]
assert len(chunks) == 4
assert all(chunk['action'] == CommonAction.FILE_CHUNK.value for chunk in chunks)
assert parse['action'] == LangBotToRuntimeAction.PARSE_DOCUMENT.value
assert all(request['context'] == expected.model_dump() for request in connection.requests)
assert b''.join(base64.b64decode(chunk['data']['chunk_base64']) for chunk in chunks) == file_bytes
assert {chunk['data']['file_key'] for chunk in chunks} == {parse['data']['context']['file_key']}
connector._setting_for_plugin.assert_awaited_once_with('author-b', 'parser-b', require_enabled=True)
assert runtime_handler.current_action_context is None
assert runtime_handler.resolve_outbound_action_context(None) is None
@pytest.mark.asyncio
async def test_explicit_argument_overrides_scope_and_inbound_falls_back(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
token = runtime_handler._current_action_context.set(binding_a)
try:
with runtime_handler.installation_scope(binding_b):
await runtime_handler.call_action(
LangBotToRuntimeAction.LIST_PARSERS, {}, action_context=binding_a.model_dump()
)
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
assert [request['context'] for request in connection.requests] == [binding_a.model_dump()] * 2
assert runtime_handler.resolve_outbound_action_context(None) is None
@pytest.mark.asyncio
async def test_explicit_none_scope_clears_inbound_and_restores_outer_scope(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
token = runtime_handler._current_action_context.set(binding_a)
try:
with runtime_handler.installation_scope(binding_b):
await runtime_handler.ping()
await runtime_handler.list_parsers()
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
assert [request.get('context') for request in connection.requests] == [
None,
binding_b.model_dump(),
binding_a.model_dump(),
]
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', [RuntimeError, asyncio.CancelledError])
async def test_scope_restores_after_exception_or_cancellation(bridge, failure):
runtime_handler, _, connection, binding_a, binding_b = bridge
with runtime_handler.installation_scope(binding_a):
with pytest.raises(failure):
with runtime_handler.installation_scope(binding_b):
await runtime_handler.list_parsers()
raise failure()
await runtime_handler.list_parsers()
await runtime_handler.list_parsers()
assert [request.get('context') for request in connection.requests] == [
binding_b.model_dump(),
binding_a.model_dump(),
None,
]
@pytest.mark.asyncio
async def test_concurrent_nested_scopes_do_not_leak_on_task_cancellation(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
entered = asyncio.Event()
release = asyncio.Event()
async def cancelled_invocation():
with runtime_handler.installation_scope(binding_b):
await runtime_handler.list_parsers()
entered.set()
await release.wait()
token = runtime_handler._current_action_context.set(binding_a)
task = asyncio.create_task(cancelled_invocation())
try:
await asyncio.wait_for(entered.wait(), timeout=2)
with runtime_handler.installation_scope(None):
await runtime_handler.list_parsers()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert [request.get('context') for request in connection.requests] == [
binding_b.model_dump(),
None,
binding_a.model_dump(),
]
assert runtime_handler.resolve_outbound_action_context(None) is None
@@ -12,4 +12,7 @@ def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
project = pyproject['project']
base_dependencies = project['dependencies']
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
assert project['optional-dependencies']['seekdb'] == [
'pyseekdb==1.4.0.post1',
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
]
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
def _adapter_with_collection(collection: MagicMock) -> SeekDBVectorDatabase:
adapter = SeekDBVectorDatabase.__new__(SeekDBVectorDatabase)
adapter.ap = SimpleNamespace(logger=MagicMock())
adapter.client = MagicMock()
adapter.client.has_collection.return_value = True
adapter._collections = {'knowledge_base': collection}
adapter._runtime_cache_limit = 16
return adapter
@pytest.mark.asyncio
async def test_add_embeddings_upserts_and_preserves_text() -> None:
collection = MagicMock()
adapter = _adapter_with_collection(collection)
adapter._get_or_create_collection_internal = AsyncMock(return_value=collection)
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
await adapter.add_embeddings(
collection='knowledge_base',
ids=['document-a'],
embeddings_list=[[1.0, 0.0, 0.0]],
metadatas=[{'text': original}],
documents=[original],
)
collection.upsert.assert_called_once_with(
ids=['document-a'],
embeddings=[[1.0, 0.0, 0.0]],
metadatas=[{'text': original}],
documents=[original],
)
collection.add.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('search_type', 'scores', 'expected_distances'),
[
('full_text', [0.4508196721, 0.25], [0.5491803279, 0.75]),
('hybrid', [0.0328, 0.0323, 0.0159], [0.9672, 0.9677, 0.9841]),
],
)
async def test_search_converts_relevance_scores_to_distances(
search_type: str,
scores: list[float],
expected_distances: list[float],
) -> None:
collection = MagicMock()
collection.hybrid_search.return_value = {
'ids': [['best', 'weak', 'noise'][: len(scores)]],
'metadatas': [[{} for _ in scores]],
'distances': [scores],
}
adapter = _adapter_with_collection(collection)
results = await adapter.search(
collection='knowledge_base',
query_embedding=[1.0, 0.0, 0.0],
k=len(scores),
search_type=search_type,
query_text='orchid',
vector_weight=0.65,
)
assert results['distances'][0] == pytest.approx(expected_distances)
assert results['distances'][0] == sorted(results['distances'][0])
@pytest.mark.asyncio
async def test_vector_search_keeps_seekdb_cosine_distances() -> None:
collection = MagicMock()
collection.query.return_value = {
'ids': [['best', 'weak']],
'metadatas': [[{}, {}]],
'distances': [[0.1, 0.25]],
}
adapter = _adapter_with_collection(collection)
results = await adapter.search(
collection='knowledge_base',
query_embedding=[1.0, 0.0, 0.0],
k=2,
search_type='vector',
)
assert results['distances'] == [[0.1, 0.25]]
Generated
+178 -79
View File
@@ -608,6 +608,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
]
[[package]]
name = "cbor2"
version = "6.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/14/b02446bacfe44351b1689c04937ade007588f44570431880a6937e525e6c/cbor2-6.1.4.tar.gz", hash = "sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9", size = 90840, upload-time = "2026-08-01T20:41:39.797Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/84/1e363301c06f509963d134f5479e82b3ade87fb1495ddacf9bf7ff24ac42/cbor2-6.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8156fdeb73c3ff6c8cf67ad414fb5c887cd708ff0af6d61f62629f41cb4c17b2", size = 414947, upload-time = "2026-08-01T20:40:37.405Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/d8e1ed3e79ea20a3423a96b5c89ce794fa02cb428e4429e601f8ebcbac7c/cbor2-6.1.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e1fe2d62c50df290576280b18247ec63486f78be73e285bae269c2456c6ddff0", size = 457343, upload-time = "2026-08-01T20:40:38.868Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0c/5796c2ed2dcd0696fc4abedf0ea0dfd5361b3f022a311481f977fa51b2b8/cbor2-6.1.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c204a75f91f8cd9ed0881f6b88ec395c59aeac9fcf4d08155e7f899db2a1c46e", size = 464314, upload-time = "2026-08-01T20:40:40.63Z" },
{ url = "https://files.pythonhosted.org/packages/b1/88/de524c6c2c91b740e5df6e6955a113fb616e979b26fd2e6a0693082d36e0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28fa5db05a7eae8fd80709959988d8a7f12838c6d4e5c58ec951414058641195", size = 523053, upload-time = "2026-08-01T20:40:42.602Z" },
{ url = "https://files.pythonhosted.org/packages/84/07/cb5fd92834633508d680a5b5695aeaf99d33ca0bdc5b844550d538f335b0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316e217a496640418d3137483279d0e70053b000cdd4b52a4dbf20ea478bc40a", size = 532177, upload-time = "2026-08-01T20:40:44.058Z" },
{ url = "https://files.pythonhosted.org/packages/c9/19/be98721365edfe6fc23e6bcd1385afa0e960b247c5f0b50bb67f5d05e2d9/cbor2-6.1.4-cp311-cp311-win32.whl", hash = "sha256:4903f24e0f9087275a0b6606c8b0aa586277001d51e4844fcdbc5b7211330aa8", size = 281660, upload-time = "2026-08-01T20:40:45.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/23/d54f679d4b155918f5a0879dab78203ce4fd514d311b7cfeba27dafe480b/cbor2-6.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:5b99305d4013867e059f147752b95f728680682ab03d75a3f4dcfbb270d8dfe9", size = 303207, upload-time = "2026-08-01T20:40:47.293Z" },
{ url = "https://files.pythonhosted.org/packages/53/3c/b3839d6213c88b249ba860525df05ff18b27bdc28ebc09cb1547790f001a/cbor2-6.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:bd20ecc5c8ece24db952e48a91c8c47319eaa6358af707c85ac2bb388a79abc8", size = 296123, upload-time = "2026-08-01T20:40:48.808Z" },
{ url = "https://files.pythonhosted.org/packages/2e/76/fb64293c19cafb860060310c57b768fd9cfb7cf592449660b756538cc116/cbor2-6.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b", size = 404608, upload-time = "2026-08-01T20:40:50.219Z" },
{ url = "https://files.pythonhosted.org/packages/96/ac/f58b3bafce7c86ada2ad8eaf189453136d2cf5bae526ea0540e1b9bc9d06/cbor2-6.1.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2", size = 449851, upload-time = "2026-08-01T20:40:51.725Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a5/10c6c126d59b07f2bd005094dd12a20afa46146f7e2673ed6f61a57641a7/cbor2-6.1.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220", size = 461193, upload-time = "2026-08-01T20:40:53.446Z" },
{ url = "https://files.pythonhosted.org/packages/15/e4/4445e6237088d1cca3b8536daeb90d6b4e23776de5609c9fa46773874757/cbor2-6.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f", size = 516937, upload-time = "2026-08-01T20:40:54.952Z" },
{ url = "https://files.pythonhosted.org/packages/8c/87/9c0959510f7a402e5995c81ccfd82cb9f314140dc0cce88c12836e5b93f1/cbor2-6.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242", size = 529229, upload-time = "2026-08-01T20:40:56.365Z" },
{ url = "https://files.pythonhosted.org/packages/91/8e/6811e4ee84203ac657f6f461a37c7c9ba0287bde80eb83c7971e9b3fe156/cbor2-6.1.4-cp312-cp312-win32.whl", hash = "sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e", size = 278810, upload-time = "2026-08-01T20:40:57.76Z" },
{ url = "https://files.pythonhosted.org/packages/da/27/87440788fc0d9513534c3c699238e2a9ca6010f8cb72e9c203b7af20a9f6/cbor2-6.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6", size = 299971, upload-time = "2026-08-01T20:40:59.256Z" },
{ url = "https://files.pythonhosted.org/packages/23/f9/77981e6e63092de19d7306a09a12b0eb3fd2907dc22c10dd5d389eb27faf/cbor2-6.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b", size = 290662, upload-time = "2026-08-01T20:41:00.89Z" },
{ url = "https://files.pythonhosted.org/packages/0d/17/0b20c88e76942ede86c98cdce138681690f95908c540c264fff847729cd4/cbor2-6.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4", size = 403677, upload-time = "2026-08-01T20:41:02.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/3d/93eed770864540c5c9ea0841008208e9db686b7335f42520705b7d6dc6b2/cbor2-6.1.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245", size = 449762, upload-time = "2026-08-01T20:41:04.094Z" },
{ url = "https://files.pythonhosted.org/packages/e3/21/69e4d37f00319b3d37322355aedc83154b4d8b75dc9e9789c06e1fbd8a92/cbor2-6.1.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905", size = 460420, upload-time = "2026-08-01T20:41:05.891Z" },
{ url = "https://files.pythonhosted.org/packages/be/26/2cfdd5ee826205a88a826bb38b7a572c676ec3efa29574be5cdbd04b4859/cbor2-6.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76", size = 516490, upload-time = "2026-08-01T20:41:07.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/86/d687cd1c2c9f9a986e8552ad1fdbd22411cc86389b5705dba6ec6f7e3226/cbor2-6.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0", size = 528810, upload-time = "2026-08-01T20:41:09.144Z" },
{ url = "https://files.pythonhosted.org/packages/40/08/88cecf20b8825bdd991c47b317415c08ef9e7d5f05a1def9acd346edabde/cbor2-6.1.4-cp313-cp313-win32.whl", hash = "sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd", size = 278058, upload-time = "2026-08-01T20:41:10.48Z" },
{ url = "https://files.pythonhosted.org/packages/0e/67/ba140234a6415c16dcfbe0585ce12f905157b70e9cb1bb63a2b6d5721e70/cbor2-6.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f", size = 299315, upload-time = "2026-08-01T20:41:12.067Z" },
{ url = "https://files.pythonhosted.org/packages/5f/7f/35d53ff4252a5a85656480d3a81d5a5af823979ccd0c5cac95196a7548a6/cbor2-6.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f", size = 289976, upload-time = "2026-08-01T20:41:13.63Z" },
{ url = "https://files.pythonhosted.org/packages/05/5d/c5374c76471ab41dff4420a276569a56352e83166374fba6f40fd0bde7ad/cbor2-6.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69", size = 407497, upload-time = "2026-08-01T20:41:15.189Z" },
{ url = "https://files.pythonhosted.org/packages/46/f9/b9f12a5e24d5ae355e4c0f6d37330a2bbedad3331247a223a51c4cd39d5e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22", size = 452191, upload-time = "2026-08-01T20:41:16.705Z" },
{ url = "https://files.pythonhosted.org/packages/67/22/8224b01f95a6fe07b1a64082aea34d9f49068392b3de93f5f3a10c73c62e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c", size = 462383, upload-time = "2026-08-01T20:41:18.17Z" },
{ url = "https://files.pythonhosted.org/packages/92/52/437e4aa4f5df1fb41020d64b3d99a8239f0f99a3a75eb6ffa5cb66004b7f/cbor2-6.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae", size = 518700, upload-time = "2026-08-01T20:41:19.592Z" },
{ url = "https://files.pythonhosted.org/packages/7d/45/2f5ea5bfe0fd800b3739c7df8679bdffa9f7def6b2f2fee064ada1c63e85/cbor2-6.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2", size = 531243, upload-time = "2026-08-01T20:41:21.21Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c6/0beac64cb74cd3217f295f9bb0d64675e1809c683a31ea2a49ac9d4d1504/cbor2-6.1.4-cp314-cp314-win32.whl", hash = "sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18", size = 285248, upload-time = "2026-08-01T20:41:22.658Z" },
{ url = "https://files.pythonhosted.org/packages/bb/7d/4afa096ddc94049f5a514690891b02a18319e146ceb14465ce30c8340a8b/cbor2-6.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6", size = 313044, upload-time = "2026-08-01T20:41:24.066Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b5/e614cee861772f6b5c4d926b066d2e7dbc11e220b50ba716ba91e430fb0f/cbor2-6.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f", size = 304088, upload-time = "2026-08-01T20:41:25.431Z" },
{ url = "https://files.pythonhosted.org/packages/9e/41/3b28184154f6cbf7e47c1b7fb4a7a291c54f27a6f3a0a2f64b078c6a13e1/cbor2-6.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f", size = 401042, upload-time = "2026-08-01T20:41:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/d5/1a/a8624023b84b41c43a150a89517c104aed0e467bd258866f13be4c3ac0c6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da", size = 445301, upload-time = "2026-08-01T20:41:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/60/39/07dd0ea957c1f48673d3947f97ee36826efd4a824053dd0ec4df2f0c89d6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54", size = 459816, upload-time = "2026-08-01T20:41:29.839Z" },
{ url = "https://files.pythonhosted.org/packages/23/8e/2015175132a27c1daed434f671ac6d9c1311461995df47f201307700e0da/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025", size = 511565, upload-time = "2026-08-01T20:41:31.939Z" },
{ url = "https://files.pythonhosted.org/packages/82/66/420991095d9473614b205d4c4e40b5d3b9f1ee4410eb3c48c1e902947837/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9", size = 527709, upload-time = "2026-08-01T20:41:33.413Z" },
{ url = "https://files.pythonhosted.org/packages/cc/7c/73057e7a38488a816a0d40ff9e7cd9f418800894582e2e48fb2f47ce66a2/cbor2-6.1.4-cp314-cp314t-win32.whl", hash = "sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257", size = 281462, upload-time = "2026-08-01T20:41:35.127Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/d5db22837cb566de733b9d1c418cdf1912ccb1efc7b179e295430b1d81a2/cbor2-6.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c", size = 309165, upload-time = "2026-08-01T20:41:36.716Z" },
{ url = "https://files.pythonhosted.org/packages/29/5f/ff2c6da83553a692219a0a62a21b57a27ded4405200e50db758a17fbaf15/cbor2-6.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883", size = 298386, upload-time = "2026-08-01T20:41:38.392Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -1018,7 +1066,7 @@ name = "cuda-bindings"
version = "13.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder" },
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
@@ -1051,34 +1099,34 @@ wheels = [
[package.optional-dependencies]
cudart = [
{ name = "nvidia-cuda-runtime" },
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cufft = [
{ name = "nvidia-cufft" },
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cufile = [
{ name = "nvidia-cufile" },
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cupti = [
{ name = "nvidia-cuda-cupti" },
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
curand = [
{ name = "nvidia-curand" },
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cusolver = [
{ name = "nvidia-cusolver" },
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cusparse = [
{ name = "nvidia-cusparse" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc" },
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvtx = [
{ name = "nvidia-nvtx" },
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
[[package]]
@@ -2008,7 +2056,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.10"
version = "4.10.11"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2085,11 +2133,13 @@ dependencies = [
{ name = "urllib3" },
{ name = "uv" },
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
{ name = "webauthn" },
{ name = "websockets" },
]
[package.optional-dependencies]
seekdb = [
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "pyseekdb" },
]
@@ -2129,7 +2179,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.7" },
{ name = "langbot-plugin", specifier = "==0.5.8" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2154,10 +2204,11 @@ requires-dist = [
{ name = "pycryptodome", specifier = ">=3.22.0" },
{ name = "pydantic", specifier = ">2.0" },
{ name = "pyjwt", specifier = ">=2.12.0" },
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'seekdb') or (sys_platform == 'linux' and extra == 'seekdb')", specifier = "==1.4.0" },
{ name = "pymilvus", specifier = ">=2.6.4" },
{ name = "pynacl", specifier = ">=1.5.0" },
{ name = "pypdf2", specifier = ">=3.0.1" },
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.4.0.post1" },
{ name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.27" },
{ name = "python-socks", specifier = ">=2.7.1" },
@@ -2180,6 +2231,7 @@ requires-dist = [
{ name = "urllib3", specifier = ">=2.7.0" },
{ name = "uv", specifier = ">=0.11.15" },
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "webauthn", specifier = ">=3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
provides-extras = ["seekdb"]
@@ -2196,7 +2248,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.7"
version = "0.5.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2217,9 +2269,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/ab/8d8bd6b8355c5b30b4aab2b5322fd28d8f36158f36d6b4ee33f4df4bc861/langbot_plugin-0.5.8.tar.gz", hash = "sha256:46fbdf948f4a2d110607738ab35633c9ab22a30784edce3a4e684cd19bab84ff", size = 487972, upload-time = "2026-09-11T09:27:58.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
{ url = "https://files.pythonhosted.org/packages/c2/13/4939205e2f7922ec09113e390e35f9355ce6d93e1b380a4b3c49441130f5/langbot_plugin-0.5.8-py3-none-any.whl", hash = "sha256:4fbbcfa55f1dcb9af8392b48de8b7877ea79c880dfd268d651404702614d182e", size = 311552, upload-time = "2026-09-11T09:27:57.082Z" },
]
[[package]]
@@ -3247,7 +3299,7 @@ name = "nvidia-cublas"
version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cuda-nvrtc" },
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -3286,7 +3338,7 @@ name = "nvidia-cudnn-cu13"
version = "9.20.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
@@ -3298,7 +3350,7 @@ name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -3328,9 +3380,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas" },
{ name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -3342,7 +3394,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -4073,6 +4125,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pybase64"
version = "1.4.3"
@@ -4411,21 +4484,18 @@ crypto = [
[[package]]
name = "pylibseekdb"
version = "1.3.0"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176, upload-time = "2026-05-25T08:59:18.118Z" },
{ url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003, upload-time = "2026-05-25T06:11:51.929Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660, upload-time = "2026-05-25T06:12:02.817Z" },
{ url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028, upload-time = "2026-05-25T08:59:41.571Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851, upload-time = "2026-05-25T06:12:11.973Z" },
{ url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328, upload-time = "2026-05-25T06:12:22.051Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219, upload-time = "2026-05-25T09:00:08.798Z" },
{ url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366, upload-time = "2026-05-25T06:12:31.689Z" },
{ url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143, upload-time = "2026-05-25T06:12:43.005Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982, upload-time = "2026-05-25T09:00:26.672Z" },
{ url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377, upload-time = "2026-05-25T06:12:53.468Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373, upload-time = "2026-05-25T06:13:03.535Z" },
{ url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" },
{ url = "https://files.pythonhosted.org/packages/64/93/e9a13b996b5561f89c9a4f1b62796f8a6230a5dce215869e3cfef8adc4f1/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e37b931417b7fc7fc88d15fd8b9b0dad499cd05e693cc483aa3743840e85f0c0", size = 49442143, upload-time = "2026-08-27T13:03:41.823Z" },
{ url = "https://files.pythonhosted.org/packages/71/cd/e54bb304512042cac0514fd607175f5425bd0924330e3eb74937c2afe827/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6aaa3c9e4865d32f533af04eb8eab06d2c10fc581ac38097d618efb44c05dc5b", size = 53975703, upload-time = "2026-08-27T13:04:41.008Z" },
{ url = "https://files.pythonhosted.org/packages/ca/03/4380094699cbd4539971c0b943701f776408c94c28cc3ecdaed7c217bb29/pylibseekdb-1.4.0-cp312-abi3-macosx_15_0_arm64.whl", hash = "sha256:2fee55af299f2992dd5d61c9e239ef8855629f4117ee8b4c21dc877160707004", size = 52171602, upload-time = "2026-08-27T13:05:15.263Z" },
{ url = "https://files.pythonhosted.org/packages/df/b5/ea71acbee58925a51cb1a7137afd2d1c4e3fbb5bf2a0144cd53d299a20df/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f606579904a19bcd7ec96bc117251db9d4202485fc7355f2a289804d1b3b2c1b", size = 49438937, upload-time = "2026-08-27T13:03:48.418Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f3/452485e45676a7d738720a7a3f7abbf4d24a3d272cbacabef41ae8b8e52c/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d32d6f0d3b92b0c719b6c4230a24748ce10666f67052c696359e69138d8fbe7", size = 53972507, upload-time = "2026-08-27T13:04:46.946Z" },
]
[[package]]
@@ -4490,6 +4560,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
]
[[package]]
name = "pyopenssl"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
]
[[package]]
name = "pypdf2"
version = "3.0.1"
@@ -4537,7 +4620,7 @@ wheels = [
[[package]]
name = "pyseekdb"
version = "1.1.0.post3"
version = "1.4.0.post1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx", marker = "python_full_version < '3.14'" },
@@ -4551,7 +4634,7 @@ dependencies = [
{ name = "tqdm", marker = "python_full_version < '3.14'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985, upload-time = "2026-02-12T14:19:05.402Z" },
{ url = "https://files.pythonhosted.org/packages/22/87/d5dd862faa3d4adf3847c1ce19c3ea5ecd0dcfda9c2584a95bfd2b0fac0f/pyseekdb-1.4.0.post1-py3-none-any.whl", hash = "sha256:a3379f6962a0c01aa029d3e5a8f0c0f5a59b27a689b8aae1931d9ce5563f252c", size = 158375, upload-time = "2026-08-03T08:56:59.501Z" },
]
[[package]]
@@ -5172,10 +5255,10 @@ name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "threadpoolctl" },
{ name = "joblib", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
@@ -5222,7 +5305,7 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -5293,14 +5376,14 @@ name = "sentence-transformers"
version = "5.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "scikit-learn" },
{ name = "scipy" },
{ name = "torch" },
{ name = "tqdm" },
{ name = "transformers" },
{ name = "typing-extensions" },
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "torch", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "transformers", marker = "python_full_version >= '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
wheels = [
@@ -5673,21 +5756,21 @@ name = "torch"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions" },
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "filelock", marker = "python_full_version >= '3.14'" },
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
{ name = "networkx", marker = "python_full_version >= '3.14'" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
{ name = "sympy", marker = "python_full_version >= '3.14'" },
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
@@ -5729,15 +5812,15 @@ name = "transformers"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "safetensors" },
{ name = "tokenizers" },
{ name = "tqdm" },
{ name = "typer" },
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "packaging", marker = "python_full_version >= '3.14'" },
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
{ name = "regex", marker = "python_full_version >= '3.14'" },
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "typer", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
wheels = [
@@ -5998,9 +6081,9 @@ name = "valkey-glide"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "protobuf" },
{ name = "sniffio" },
{ name = "anyio", marker = "sys_platform != 'win32'" },
{ name = "protobuf", marker = "sys_platform != 'win32'" },
{ name = "sniffio", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
wheels = [
@@ -6166,6 +6249,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
]
[[package]]
name = "webauthn"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cbor2" },
{ name = "cryptography" },
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
{ name = "pyopenssl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/22/b19c91e850c4578b7d6cdb53453c5fe2f2e99d0c56e322c65c3caf1b3051/webauthn-3.0.0.tar.gz", hash = "sha256:324e54e1f6eeef486623b5d90df6fcd74ae04ff0c137d2b818a8f709b6ca3ab8", size = 160472, upload-time = "2026-06-29T22:40:33.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/d3/38d4efaedba74d854f88b60fd7b80ab37869032f9a9ad54d1892dab20241/webauthn-3.0.0-py3-none-any.whl", hash = "sha256:b5d0c02b6efa16be683f8a75abd2073f5e59a15f42623cc22c31f27600259e64", size = 73887, upload-time = "2026-06-29T22:40:32.171Z" },
]
[[package]]
name = "websocket-client"
version = "1.9.0"
+1
View File
@@ -55,6 +55,7 @@
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.7",
"@simplewebauthn/browser": "^14.0.0",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^6.0.1",
+17
View File
@@ -93,6 +93,9 @@ dependencies:
'@radix-ui/react-tooltip':
specifier: ^1.2.7
version: 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
'@simplewebauthn/browser':
specifier: ^14.0.0
version: 14.0.0
'@tailwindcss/postcss':
specifier: ^4.1.5
version: 4.1.18
@@ -1846,6 +1849,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -1855,6 +1859,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@@ -1864,6 +1869,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -1873,6 +1879,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -1882,6 +1889,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -1891,6 +1899,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@@ -1942,6 +1951,10 @@ packages:
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
dev: false
/@simplewebauthn/browser@14.0.0:
resolution: {integrity: sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==}
dev: false
/@standard-schema/utils@0.3.0:
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
dev: false
@@ -4240,6 +4253,7 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -4259,6 +4273,7 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@@ -4278,6 +4293,7 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -4297,6 +4313,7 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@@ -12,7 +12,17 @@ import {
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import {
Loader2,
ExternalLink,
KeyRound,
Layers,
Fingerprint,
Plus,
Trash2,
Pencil,
} from 'lucide-react';
import { startRegistration } from '@simplewebauthn/browser';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
@@ -22,6 +32,16 @@ interface AccountSettingsPanelProps {
onEmailResolved?: (email: string) => void;
}
interface PasskeyItem {
uuid: string;
name: string;
aaguid?: string;
transports?: string;
backed_up?: boolean;
created_at?: string;
last_used_at?: string;
}
export default function AccountSettingsPanel({
active,
onEmailResolved,
@@ -33,10 +53,14 @@ export default function AccountSettingsPanel({
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
useEffect(() => {
if (active) {
loadUserInfo();
loadPasskeys();
}
}, [active]);
@@ -55,6 +79,67 @@ export default function AccountSettingsPanel({
}
}
async function loadPasskeys() {
setPasskeyLoading(true);
try {
const list = await httpClient.getPasskeys();
setPasskeys(list);
} catch {
// ignore
} finally {
setPasskeyLoading(false);
}
}
const handleAddPasskey = async () => {
setRegisteringPasskey(true);
try {
const { options, challenge_token } =
await httpClient.getPasskeyRegisterOptions(window.location.origin);
const regResp = await startRegistration({ optionsJSON: options });
const defaultName =
prompt(t('account.passkeyNamePlaceholder')) || undefined;
await httpClient.verifyPasskeyRegister(
challenge_token,
regResp,
defaultName,
);
toast.success(t('account.passkeyAddedSuccess'));
await loadPasskeys();
} catch (error: any) {
if (error?.name === 'NotAllowedError') {
// User cancelled
} else {
toast.error(error?.message || t('common.error'));
}
} finally {
setRegisteringPasskey(false);
}
};
const handleDeletePasskey = async (uuid: string) => {
if (!confirm(t('account.deletePasskeyConfirm'))) return;
try {
await httpClient.deletePasskey(uuid);
toast.success(t('account.passkeyDeleteSuccess'));
await loadPasskeys();
} catch (error: any) {
toast.error(error?.message || t('common.error'));
}
};
const handleRenamePasskey = async (uuid: string, currentName: string) => {
const newName = prompt(t('account.passkeyName'), currentName);
if (!newName || !newName.trim() || newName === currentName) return;
try {
await httpClient.renamePasskey(uuid, newName.trim());
toast.success(t('account.passkeyRenameSuccess'));
await loadPasskeys();
} catch (error: any) {
toast.error(error?.message || t('common.error'));
}
};
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
@@ -148,6 +233,105 @@ export default function AccountSettingsPanel({
</ItemActions>
)}
</Item>
{/* Passkey Section */}
<div className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t('account.passkeySectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{t('account.passkeySectionDesc')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddPasskey}
disabled={
registeringPasskey || !systemInfo.allow_modify_login_info
}
className="cursor-pointer"
>
{registeringPasskey ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t('account.addPasskey')}
</Button>
</div>
{passkeyLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
{t('account.noPasskeys')}
</div>
) : (
<div className="space-y-2">
{passkeys.map((pk) => (
<Item
key={pk.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Fingerprint className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{pk.name}</ItemTitle>
<ItemDescription>
{pk.created_at && (
<span>
{t('account.passkeyCreated', {
date: new Date(
pk.created_at,
).toLocaleDateString(),
})}
</span>
)}
{pk.last_used_at && (
<span className="ml-2">
·{' '}
{t('account.passkeyLastUsed', {
date: new Date(
pk.last_used_at,
).toLocaleDateString(),
})}
</span>
)}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => handleRenamePasskey(pk.uuid, pk.name)}
disabled={!systemInfo.allow_modify_login_info}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive cursor-pointer hover:text-destructive"
onClick={() => handleDeletePasskey(pk.uuid)}
disabled={!systemInfo.allow_modify_login_info}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</ItemActions>
</Item>
))}
</div>
)}
</div>
</div>
)}
@@ -46,30 +46,10 @@ import {
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
/**
* Resolve the value referenced by a `show_if.field` string.
*
* Fields prefixed with `__system.` are looked up in the caller-supplied
* `systemContext` dictionary (e.g. `__system.is_wizard` `systemContext.is_wizard`).
* All other field names are resolved from the live form values first, then
* fall back to `externalDependentValues`.
*/
function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
return systemContext?.[key];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
import {
resolveDisabledState,
resolveShowIfValue,
} from './DynamicFormConditions';
type DynamicFormValueSpec = Pick<
IDynamicFormItemSchema,
@@ -675,40 +655,19 @@ export default function DynamicFormComponent({
}
}
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
// hiding the field, leaves it visible and inert. Use it when the
// operator needs to see that the field exists yet cannot edit it
// under the current runtime state (e.g. sandbox-bound fields when
// Box is disabled).
let isDisabledByCondition = false;
if (config.disable_if) {
const dependValue = resolveShowIfValue(
config.disable_if.field,
// Keep locked fields visible and resolve only the applicable reason.
const { isDisabledByCondition, disabledTooltip: tooltip } =
resolveDisabledState(
config,
watchedValues as Record<string, unknown>,
externalDependentValues,
systemContext,
);
const cond = config.disable_if;
if (cond.operator === 'eq' && dependValue === cond.value) {
isDisabledByCondition = true;
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
isDisabledByCondition = true;
} else if (
cond.operator === 'in' &&
Array.isArray(cond.value) &&
cond.value.includes(dependValue)
) {
isDisabledByCondition = true;
}
}
// All fields are disabled when editing (creation_settings are
// immutable) or when ``disable_if`` matches.
const isFieldDisabled = !!isEditing || isDisabledByCondition;
const disabledTooltip =
isDisabledByCondition && config.disabled_tooltip
? extractI18nObject(config.disabled_tooltip)
: '';
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
const renderDisabledTooltipIcon = () =>
disabledTooltip ? (
<DisabledTooltipIcon text={disabledTooltip} />
@@ -0,0 +1,71 @@
import {
SYSTEM_FIELD_PREFIX,
type IDynamicFormItemSchema,
type IShowIfCondition,
} from '@/app/infra/entities/form/dynamic';
/** System references use caller context; other fields prefer live form values. */
export function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
return systemContext?.[field.slice(SYSTEM_FIELD_PREFIX.length)];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
export function matchesFormCondition(
condition: IShowIfCondition,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): boolean {
const value = resolveShowIfValue(
condition.field,
watchedValues,
externalDependentValues,
systemContext,
);
switch (condition.operator) {
case 'eq':
return value === condition.value;
case 'neq':
return value !== condition.value;
case 'in':
return Array.isArray(condition.value) && condition.value.includes(value);
default:
return false;
}
}
export function resolveDisabledState(
config: Pick<
IDynamicFormItemSchema,
'disable_if' | 'disabled_tooltip' | 'disabled_tooltip_overrides'
>,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
) {
const matches = (condition: IShowIfCondition) =>
matchesFormCondition(
condition,
watchedValues,
externalDependentValues,
systemContext,
);
const isDisabledByCondition =
!!config.disable_if && matches(config.disable_if);
const disabledTooltip = isDisabledByCondition
? (config.disabled_tooltip_overrides?.find((override) =>
matches(override.when),
)?.tooltip ?? config.disabled_tooltip)
: undefined;
return { isDisabledByCondition, disabledTooltip };
}
@@ -0,0 +1,14 @@
/** Unavailability takes priority over the deployment's scope restriction. */
export function getBoxScopeContext(
boxAvailable: boolean,
forcedTemplate?: string,
) {
forcedTemplate = forcedTemplate?.trim();
return {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !forcedTemplate,
// Only expose forced-scope reasons when the sandbox is available.
box_scope_forced: boxAvailable && !!forcedTemplate,
box_scope_forced_global: boxAvailable && forcedTemplate === '{global}',
};
}
@@ -8,6 +8,7 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { getBoxScopeContext } from './BoxScopeContext';
import { systemInfo } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
@@ -425,13 +426,12 @@ export default function PipelineFormComponent({
// 2. the deployment pins all pipelines to a fixed scope via
// ``system.limitation.force_box_session_id_template`` (SaaS).
const forcedBoxTemplate =
systemInfo.limitation?.force_box_session_id_template || '';
systemInfo.limitation?.force_box_session_id_template?.trim() || '';
const boxScopeForced = !!forcedBoxTemplate;
const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent';
const stageSystemContext = isLocalAgentStage
? {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
...getBoxScopeContext(boxAvailable, forcedBoxTemplate),
pipeline_id: pipelineId,
}
: undefined;
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import {
Download,
Package,
Rocket,
Server,
Sparkles,
CheckCircle2,
@@ -39,11 +40,27 @@ const STAGES: {
icon: Package,
i18nKey: 'plugins.installProgress.installingDeps',
},
{
key: InstallStage.LAUNCHING,
icon: Rocket,
i18nKey: 'plugins.installProgress.launching',
},
];
/**
* Find the row that should be highlighted for a given stage.
* LAUNCHING/INITIALIZING/DONE collapse onto the launching row.
*/
function getStageIndex(stage: InstallStage): number {
if (
stage === InstallStage.LAUNCHING ||
stage === InstallStage.INITIALIZING ||
stage === InstallStage.DONE
) {
return STAGES.length - 1;
}
const idx = STAGES.findIndex((s) => s.key === stage);
return idx >= 0 ? idx : -1;
return idx >= 0 ? idx : 0;
}
function formatFileSize(bytes: number): string {
@@ -169,9 +186,12 @@ function formatSpeed(bytesPerSec: number): string {
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
const { t } = useTranslation();
const currentStageIndex = getStageIndex(task.stage);
const isDone = task.stage === InstallStage.DONE;
const isError = task.stage === InstallStage.ERROR;
// When a task fails, `stage` becomes ERROR — fall back to the furthest
// stage it actually reached so the failed phase is still displayed.
const displayStage = isError && task.lastStage ? task.lastStage : task.stage;
const currentStageIndex = getStageIndex(displayStage);
// MCP / Skill don't have the plugin's download + dependency-install stages;
// show a single "installing → done/failed" row instead of plugin steps.
@@ -27,6 +27,9 @@ export interface PluginInstallTask {
pluginName: string; // display name
source: 'github' | 'marketplace' | 'local';
stage: InstallStage;
/** Furthest non-terminal stage reached kept when the task fails so the
* UI can still show which phase failed. */
lastStage?: InstallStage;
overallProgress: number; // 0-100
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
fileSize?: number; // bytes, if known
@@ -43,6 +46,8 @@ export interface PluginInstallTask {
depsSpeed?: number; // deps download speed bytes/s
error?: string;
startedAt: number; // timestamp
/** Timestamp when the current stage began; used for smooth creeping. */
stageStartedAt?: number;
currentAction: string; // raw backend action string
}
@@ -84,42 +89,158 @@ export function usePluginInstallTasks() {
}
/**
* Map backend `current_action` to our InstallStage.
* Ordered lifecycle stages. Used to enforce forward-only transitions so the
* progress bar never moves backwards while a task is running.
*/
function mapActionToStage(action: string): InstallStage {
if (!action) return InstallStage.DOWNLOADING;
const lower = action.toLowerCase();
if (lower.includes('download')) return InstallStage.DOWNLOADING;
if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS;
if (lower.includes('initializ') || lower.includes('setting'))
return InstallStage.INSTALLING_DEPS;
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
if (lower.includes('installed') || lower.includes('complete'))
return InstallStage.DONE;
return InstallStage.DOWNLOADING;
const STAGE_ORDER: InstallStage[] = [
InstallStage.DOWNLOADING,
InstallStage.INSTALLING_DEPS,
InstallStage.INITIALIZING,
InstallStage.LAUNCHING,
InstallStage.DONE,
];
/**
* Lower bound (%) for each stage. A task's progress is never allowed to drop
* below the floor of the furthest stage it has already reached.
*/
const STAGE_FLOOR: Record<InstallStage, number> = {
[InstallStage.DOWNLOADING]: 2,
[InstallStage.INSTALLING_DEPS]: 55,
[InstallStage.INITIALIZING]: 85,
[InstallStage.LAUNCHING]: 94,
[InstallStage.DONE]: 100,
[InstallStage.ERROR]: 0,
};
/** Get the lower-bound percentage for a stage. */
function stageFloor(stage: InstallStage): number {
return STAGE_FLOOR[stage] ?? 0;
}
/** Get the lower bound of the stage that follows the given one. */
function nextStageFloor(stage: InstallStage): number {
const idx = STAGE_ORDER.indexOf(stage);
const next = idx >= 0 ? STAGE_ORDER[idx + 1] : undefined;
return next ? stageFloor(next) : 100;
}
/** Return whichever stage is further along in the lifecycle. */
function maxStage(current: InstallStage, incoming: InstallStage): InstallStage {
const currentIdx = STAGE_ORDER.indexOf(current);
const incomingIdx = STAGE_ORDER.indexOf(incoming);
if (currentIdx === -1) return incoming;
if (incomingIdx === -1) return current;
return incomingIdx >= currentIdx ? incoming : current;
}
/**
* Get overall progress percentage from a stage.
* Map backend `current_action` to our InstallStage.
*
* Unknown / transitional actions must NOT map back to an earlier stage,
* otherwise the bar would jump backwards mid-install.
*/
function stageToProgress(stage: InstallStage): number {
switch (stage) {
case InstallStage.DOWNLOADING:
return 10;
case InstallStage.INSTALLING_DEPS:
return 70;
case InstallStage.INITIALIZING:
return 70;
case InstallStage.LAUNCHING:
return 85;
case InstallStage.DONE:
return 100;
case InstallStage.ERROR:
return 0;
default:
return 0;
function mapActionToStage(action: string): InstallStage {
const lower = (action || '').toLowerCase();
if (!lower) return InstallStage.DOWNLOADING;
// "preparing"/"resolving" happen before any bytes land on disk.
if (lower.includes('prepar') || lower.includes('resolv'))
return InstallStage.DOWNLOADING;
if (lower.includes('download') && !lower.includes('dependenc'))
return InstallStage.DOWNLOADING;
// Activation / readiness tail phase — its own slice of the bar.
if (
lower.includes('launch') ||
lower.includes('start') ||
lower.includes('wait') ||
lower.includes('ready') ||
lower.includes('initializ')
) {
return InstallStage.LAUNCHING;
}
// Dependency installation and package finalization.
if (
lower.includes('dependenc') ||
lower.includes('requirements') ||
lower.includes('parsing') ||
lower.includes('extract') ||
lower.includes('inspect') ||
lower.includes('persist') ||
lower.includes('stor') ||
lower.includes('install') ||
lower.includes('setting')
) {
return InstallStage.INSTALLING_DEPS;
}
// Unknown transitional actions belong to the busy middle of the install.
return InstallStage.INSTALLING_DEPS;
}
/**
* Time-based creep so the bar keeps moving when no counters exist.
*
* Uses an asymptote so the increment decelerates as it approaches the stage
* ceiling the bar always feels alive but never overshoots into the next
* stage's range.
*/
function creep(stageStartedAt: number, span: number): number {
if (span <= 0) return 0;
const elapsed = (Date.now() - stageStartedAt) / 1000;
// Approaching `span` asymptotically: after ~60s we are ~86% of the span.
const ratio = 1 - Math.exp(-elapsed / 30);
return span * ratio;
}
/**
* Compute a progress value for the current stage.
*
* Real byte / dependency counters drive the value when available; otherwise
* the value creeps forward slowly based on elapsed time. Callers are expected
* to combine the result with the previous value via `Math.max` so it is
* monotonic.
*/
function computeStageProgress(
task: PluginInstallTask,
stage: InstallStage,
): number {
const floor = stageFloor(stage);
const ceiling = Math.max(floor, nextStageFloor(stage) - 1);
// Creep from when this stage began so a stage change restarts the ramp
// instead of inheriting the previous stage's elapsed time.
const stageStartedAt = task.stageStartedAt ?? task.startedAt;
const creepValue = Math.min(
ceiling,
floor + creep(stageStartedAt, ceiling - floor),
);
if (stage === InstallStage.DOWNLOADING) {
const total = task.downloadTotal ?? task.fileSize;
const current = task.downloadCurrent;
if (total && total > 0 && current != null && current > 0) {
const ratio = Math.min(1, current / total);
// Never let a stale counter pull the value below the creep baseline.
return Math.max(creepValue, floor + (ceiling - floor) * ratio);
}
return creepValue;
}
if (stage === InstallStage.INSTALLING_DEPS) {
const total = task.depsTotal;
const installed = task.depsInstalled;
if (total && total > 0 && installed != null && installed > 0) {
const ratio = Math.min(1, installed / total);
// Leave headroom for the finalize/launch phase that has no counters.
return Math.max(creepValue, floor + (ceiling - floor) * ratio * 0.9);
}
return creepValue;
}
return creepValue;
}
/**
@@ -146,8 +267,14 @@ function isPluginInstallTask(name: string): boolean {
/**
* Convert a backend AsyncTask to our PluginInstallTask.
*
* `previous` (when provided) carries monotonic state forward so re-syncing
* after a refresh or a poll cannot make the progress bar move backwards.
*/
function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
function asyncTaskToPluginInstallTask(
task: AsyncTask,
previous?: PluginInstallTask,
): PluginInstallTask {
const source = extractSourceFromName(task.name);
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
const action = task.task_context?.current_action || '';
@@ -157,24 +284,6 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
let stage: InstallStage;
let overallProgress: number;
let error: string | undefined;
if (done) {
if (exception) {
stage = InstallStage.ERROR;
overallProgress = 0;
error = exception;
} else {
stage = InstallStage.DONE;
overallProgress = 100;
}
} else {
stage = mapActionToStage(action);
overallProgress = Math.min(95, stageToProgress(stage));
}
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin';
@@ -184,6 +293,75 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
extensionType = 'skill';
}
// Prefer the task's real creation time so a refresh (or first sync) restores
// the correct elapsed baseline instead of restarting the ramp from zero.
const backendStartedAt =
typeof task.created_at === 'number' && task.created_at > 0
? task.created_at * 1000
: undefined;
const startedAt = previous?.startedAt ?? backendStartedAt ?? Date.now();
let stageStartedAt =
previous?.stageStartedAt ??
previous?.startedAt ??
backendStartedAt ??
startedAt;
let stage: InstallStage;
let overallProgress: number;
let error: string | undefined;
// Furthest non-terminal stage reached, kept across failures.
let lastStage = previous?.lastStage ?? previous?.stage;
if (done) {
if (exception) {
// Preserve how far the task got before failing, so the bar shows the
// failure point instead of jumping back to zero.
stage = InstallStage.ERROR;
overallProgress = previous?.overallProgress ?? 0;
error = exception;
} else {
stage = InstallStage.DONE;
overallProgress = 100;
}
} else {
const incoming = mapActionToStage(action);
// Forward-only: never move back to an earlier stage than we already reached.
stage = previous ? maxStage(previous.stage, incoming) : incoming;
if (!previous || previous.stage !== stage) {
stageStartedAt = Date.now();
}
lastStage = stage;
const counters: PluginInstallTask = {
id: `${source}-${task.id}`,
taskId: task.id,
pluginName,
source,
extensionType,
stage,
overallProgress: 0,
downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent,
downloadTotal: num(md.download_total) ?? previous?.downloadTotal,
downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed,
depsTotal: num(md.deps_total) ?? previous?.depsTotal,
depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled,
depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining,
currentDep: str(md.current_dep) ?? previous?.currentDep,
depsDownloadedSize:
num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize,
depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed,
startedAt,
stageStartedAt,
currentAction: action,
};
const computed = computeStageProgress(counters, stage);
overallProgress = Math.max(previous?.overallProgress ?? 0, computed);
// Keep the bar strictly below 100 until the backend confirms completion.
overallProgress = Math.round(Math.min(99, overallProgress));
}
return {
id: `${source}-${task.id}`,
taskId: task.id,
@@ -191,18 +369,21 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
source,
extensionType,
stage,
lastStage,
overallProgress,
downloadCurrent: num(md.download_current),
downloadTotal: num(md.download_total),
downloadSpeed: num(md.download_speed),
depsTotal: num(md.deps_total),
depsInstalled: num(md.deps_installed),
depsRemaining: num(md.deps_remaining),
currentDep: str(md.current_dep),
depsDownloadedSize: num(md.deps_downloaded_size),
depsSpeed: num(md.deps_speed),
downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent,
downloadTotal: num(md.download_total) ?? previous?.downloadTotal,
downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed,
depsTotal: num(md.deps_total) ?? previous?.depsTotal,
depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled,
depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining,
currentDep: str(md.current_dep) ?? previous?.currentDep,
depsDownloadedSize:
num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize,
depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed,
error,
startedAt: Date.now(),
startedAt,
stageStartedAt,
currentAction: action,
};
}
@@ -315,8 +496,11 @@ export function PluginInstallTaskProvider({
return {
...t,
stage: InstallStage.ERROR,
// Keep the phase that failed for the UI to display.
lastStage: t.lastStage ?? t.stage,
error: exception,
overallProgress: 0,
// Show where it failed instead of resetting to 0.
overallProgress: t.overallProgress,
currentAction: action,
...progressFields,
};
@@ -332,26 +516,28 @@ export function PluginInstallTaskProvider({
};
}
const stage = mapActionToStage(action);
const baseProgress = stageToProgress(stage);
// Add small time-based increment within stage
const elapsed = (Date.now() - t.startedAt) / 1000;
const withinStageIncrement = Math.min(
15,
Math.floor(elapsed / 2),
);
const progress = Math.min(
95,
baseProgress + withinStageIncrement,
);
// Forward-only stage transition.
const incoming = mapActionToStage(action);
const stage = maxStage(t.stage, incoming);
// Reset the per-stage ramp whenever we enter a new stage.
const stageAdvanced = stage !== t.stage;
return {
const next: PluginInstallTask = {
...t,
stage,
overallProgress: progress,
lastStage: stage,
stageStartedAt: stageAdvanced
? Date.now()
: (t.stageStartedAt ?? t.startedAt),
currentAction: action,
...progressFields,
};
const computed = computeStageProgress(next, stage);
// Progress must never move backwards while the task runs.
const overallProgress = Math.round(
Math.min(99, Math.max(t.overallProgress, computed)),
);
return { ...next, overallProgress };
}),
);
})
@@ -377,46 +563,61 @@ export function PluginInstallTaskProvider({
);
setTasks((prevTasks) => {
const existingTaskIds = new Set(prevTasks.map((t) => t.taskId));
const updatedTasks = [...prevTasks];
// Collect tasks that need polling started after state is committed.
const toPoll: Array<{ key: string; taskId: number }> = [];
for (const bt of backendTasks) {
// Skip tasks that the user has dismissed
if (dismissedTaskIds.current.has(bt.id)) continue;
if (!existingTaskIds.has(bt.id)) {
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
if (idx === -1) {
// New task from backend (e.g. after page refresh) — add it
const newTask = asyncTaskToPluginInstallTask(bt);
updatedTasks.push(newTask);
// If not done, start polling for progress
if (!bt.runtime.done) {
pollTask(newTask.id, bt.id);
toPoll.push({ key: newTask.id, taskId: bt.id });
} else {
// Mark as already notified so we don't re-trigger toasts for old completed tasks
notifiedTaskIds.current.add(bt.id);
}
} else {
// Already tracking — if it's done in backend but still active locally, update it
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
if (idx !== -1) {
const existing = updatedTasks[idx];
if (
bt.runtime.done &&
existing.stage !== InstallStage.DONE &&
existing.stage !== InstallStage.ERROR
) {
const converted = asyncTaskToPluginInstallTask(bt);
converted.startedAt = existing.startedAt;
converted.pluginName = existing.pluginName;
converted.fileSize = existing.fileSize;
converted.extensionType = existing.extensionType;
updatedTasks[idx] = converted;
}
}
continue;
}
// Already tracking — merge the backend snapshot into the existing
// task. Passing `existing` keeps `startedAt`, `pluginName` and
// progress monotonic so re-syncing never rewinds the bar.
const existing = updatedTasks[idx];
const converted = asyncTaskToPluginInstallTask(bt, existing);
converted.pluginName = existing.pluginName;
converted.fileSize = existing.fileSize;
converted.extensionType = existing.extensionType;
// Never downgrade a terminal task that is already done/failed locally,
// unless the backend reports it finished as well.
if (
(existing.stage === InstallStage.DONE ||
existing.stage === InstallStage.ERROR) &&
!bt.runtime.done
) {
continue;
}
updatedTasks[idx] = converted;
if (!bt.runtime.done) {
toPoll.push({ key: converted.id, taskId: bt.id });
}
}
// Schedule polling outside the state updater.
queueMicrotask(() => {
toPoll.forEach(({ key, taskId }) => pollTask(key, taskId));
});
return updatedTasks;
});
} catch {
@@ -464,6 +665,7 @@ export function PluginInstallTaskProvider({
// Remove from dismissed set if re-added
dismissedTaskIds.current.delete(params.taskId);
const startedAt = Date.now();
const newTask: PluginInstallTask = {
id: taskKey,
taskId: params.taskId,
@@ -471,9 +673,11 @@ export function PluginInstallTaskProvider({
source: params.source,
extensionType: params.extensionType,
stage: InstallStage.DOWNLOADING,
overallProgress: 5,
// Start at the downloading floor and creep up from real counters.
overallProgress: stageFloor(InstallStage.DOWNLOADING),
fileSize: params.fileSize,
startedAt: Date.now(),
downloadTotal: params.fileSize,
startedAt,
currentAction: '',
};
@@ -7,6 +7,7 @@ import {
CheckCircle2,
XCircle,
Loader2,
Rocket,
X,
ListTodo,
Puzzle,
@@ -30,6 +31,7 @@ import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.DOWNLOADING]: Download,
[InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.LAUNCHING]: Rocket,
[InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle,
};
@@ -95,6 +97,8 @@ function TaskQueueItem({
return t('plugins.installProgress.downloading');
case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps');
case InstallStage.LAUNCHING:
return t('plugins.installProgress.launching');
case InstallStage.DONE:
return isDone
? getInstallCompleteMessage()
@@ -1,4 +1,11 @@
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
Suspense,
} from 'react';
import { useSearchParams } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import {
@@ -51,6 +58,10 @@ import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
import {
resolveInstalledState,
useMarketplaceInstalledIndex,
} from './marketplace-installed';
interface SortOption {
value: string;
@@ -91,6 +102,20 @@ function MarketPageContent({
const { t } = useTranslation();
const [searchParams] = useSearchParams();
// Installed-extension lookup, recomputed whenever the sidebar lists change
// (e.g. right after an install completes).
const installedIndex = useMarketplaceInstalledIndex();
const decorateInstalled = useCallback(
(vo: PluginMarketCardVO): PluginMarketCardVO => {
const state = resolveInstalledState(installedIndex, vo);
vo.installed = state.installed;
vo.hasUpdate = state.hasUpdate;
return vo;
},
[installedIndex],
);
const validTypes = ['plugin', 'mcp', 'skill'];
const extensionTypeOptions = [
@@ -571,7 +596,12 @@ function MarketPageContent({
};
}, []);
const visiblePlugins = plugins;
// Decorate with installed state at render time so the badge updates the
// moment the sidebar lists refresh (e.g. after an install completes).
const visiblePlugins = useMemo(
() => plugins.map((plugin) => decorateInstalled(plugin)),
[plugins, decorateInstalled],
);
// 加载更多
const loadMore = useCallback(() => {
@@ -8,6 +8,10 @@ import { I18nObject } from '@/app/infra/entities/common';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
resolveInstalledState,
useMarketplaceInstalledIndex,
} from './marketplace-installed';
export interface RecommendationList {
uuid: string;
@@ -66,6 +70,7 @@ function RecommendationListRow({
isLast: boolean;
}) {
const { t } = useTranslation();
const installedIndex = useMarketplaceInstalledIndex();
const [page, setPage] = useState(0);
const [perPage, setPerPage] = useState(4);
// Countdown progress to the next auto-advance, 0 → 1 over AUTO_ADVANCE_MS.
@@ -261,16 +266,22 @@ function RecommendationListRow({
ref={gridRef}
className="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]"
>
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}
cardVO={pluginToVO(plugin, t)}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
))}
{visiblePlugins.map((plugin) => {
const cardVO = pluginToVO(plugin, t);
const state = resolveInstalledState(installedIndex, cardVO);
cardVO.installed = state.installed;
cardVO.hasUpdate = state.hasUpdate;
return (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}
cardVO={cardVO}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
);
})}
</div>
{totalPages > 1 && !isLast && (
<div className="border-b border-border mt-6" />
@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
export interface MarketplaceInstalledState {
installed: boolean;
hasUpdate: boolean;
}
export interface InstalledIndexEntry {
hasUpdate: boolean;
}
/** Composite key used to look up installed extensions: `type:author/name`. */
export function installedExtensionKey(
type: string | undefined,
author: string,
name: string,
): string {
return `${type || 'plugin'}:${author}/${name}`;
}
/**
* Build a lookup of already-installed extensions.
*
* The sidebar identifies each kind differently:
* - plugins: `author/name`
* - MCP servers: `author__name` (double underscore)
* - skills: the bare skill name
*/
export function buildInstalledIndex(
plugins: { id: string; hasUpdate?: boolean }[],
mcpServers: { id: string }[],
skills: { id: string }[],
): Map<string, InstalledIndexEntry> {
const index = new Map<string, InstalledIndexEntry>();
for (const plugin of plugins) {
index.set(`plugin:${plugin.id}`, { hasUpdate: plugin.hasUpdate ?? false });
}
for (const server of mcpServers) {
index.set(`mcp:${server.id.replace(/__/g, '/')}`, { hasUpdate: false });
}
for (const skill of skills) {
index.set(`skill:${skill.id}`, { hasUpdate: false });
}
return index;
}
/**
* Resolve whether a marketplace extension is installed.
*
* Marketplace entries always use `author/name`; skills may be stored under
* their bare name, so both keys are checked for that case.
*/
export function resolveInstalledState(
index: Map<string, InstalledIndexEntry>,
extension: { type?: string; author: string; pluginName: string },
): MarketplaceInstalledState {
const type = extension.type || 'plugin';
const keys = [
`${type}:${extension.author}/${extension.pluginName}`,
`${type}:${extension.pluginName}`,
];
for (const key of keys) {
const entry = index.get(key);
if (entry) {
return { installed: true, hasUpdate: entry.hasUpdate };
}
}
return { installed: false, hasUpdate: false };
}
/**
* Reactive installed-extension index derived from the sidebar data context.
* Recomputes automatically after an install finishes and the sidebar refreshes.
*/
export function useMarketplaceInstalledIndex(): Map<
string,
InstalledIndexEntry
> {
const { plugins, mcpServers, skills } = useSidebarData();
return useMemo(
() => buildInstalledIndex(plugins, mcpServers, skills),
[plugins, mcpServers, skills],
);
}
@@ -3,7 +3,14 @@ import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
import {
CheckCircle2,
Info,
Package,
ExternalLink,
Heart,
Loader2,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
@@ -48,6 +55,10 @@ export default function PluginMarketCardComponent({
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
// Already installed → swap the download count for an "installed" marker.
// Click behaviour stays identical to a normal card.
const isInstalled = cardVO.installed === true;
const showTypeBadge = cardVO.type;
const typeLabel =
cardVO.type === 'mcp'
@@ -320,23 +331,34 @@ export default function PluginMarketCardComponent({
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
>
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount?.toLocaleString() ?? '0'}
{/* Installed extensions replace the download count with an
"installed" marker so the card reflects local state. */}
{isInstalled ? (
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<CheckCircle2 className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-green-600 dark:text-green-400 flex-shrink-0" />
<div className="text-xs sm:text-sm text-green-600 dark:text-green-400 font-medium whitespace-nowrap">
{t('market.installed')}
</div>
</div>
</div>
) : (
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount?.toLocaleString() ?? '0'}
</div>
</div>
)}
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
@@ -12,6 +12,10 @@ export interface IPluginMarketCardVO {
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
/** Whether this extension is already installed in the current workspace. */
installed?: boolean;
/** Whether an installed extension has a newer marketplace version. */
hasUpdate?: boolean;
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -28,6 +32,8 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
installed?: boolean;
hasUpdate?: boolean;
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -43,5 +49,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.components = prop.components;
this.tags = prop.tags;
this.type = prop.type;
this.installed = prop.installed ?? false;
this.hasUpdate = prop.hasUpdate ?? false;
}
}
+2
View File
@@ -460,6 +460,8 @@ export interface AsyncTask {
name: string;
label: string;
task_type: string; // system or user
/** Unix epoch seconds (float) when the task was created. */
created_at?: number;
runtime: AsyncTaskRuntimeInfo;
task_context: AsyncTaskTaskContext;
}
@@ -39,6 +39,13 @@ export interface IDynamicFormItemSchema {
disable_if?: IShowIfCondition;
/** Tooltip shown next to the field label when ``disable_if`` is active. */
disabled_tooltip?: I18nObject;
/** Optional overrides evaluated in order when ``disable_if`` matches.
* The first matching ``when`` wins; otherwise use ``disabled_tooltip``.
* Conditions use the same operators and value lookup as ``disable_if``. */
disabled_tooltip_overrides?: {
when: IShowIfCondition;
tooltip: I18nObject;
}[];
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
scopes?: string[];
+80
View File
@@ -1304,12 +1304,92 @@ export class BackendClient extends BaseHttpClient {
invitation_registration_enabled?: boolean;
password_login_enabled?: boolean;
space_login_enabled?: boolean;
passkey_login_enabled?: boolean;
passkey_supported?: boolean;
}> {
return this.get('/api/v1/user/account-info', undefined, {
skipWorkspace: true,
});
}
// ============ Passkey (WebAuthn) API ============
public getPasskeyAuthOptions(
email?: string,
origin?: string,
): Promise<{ options: any; challenge_token: string }> {
return this.post(
'/api/v1/user/passkey/auth/options',
{ email, origin },
{ skipWorkspace: true },
);
}
public verifyPasskeyAuth(
challenge_token: string,
credential: any,
): Promise<{ token: string; user: string }> {
return this.post(
'/api/v1/user/passkey/auth/verify',
{ challenge_token, credential },
{ skipWorkspace: true },
);
}
public getPasskeyRegisterOptions(
origin?: string,
): Promise<{ options: any; challenge_token: string }> {
return this.post(
'/api/v1/user/passkey/register/options',
{ origin },
{ skipWorkspace: true },
);
}
public verifyPasskeyRegister(
challenge_token: string,
credential: any,
name?: string,
): Promise<{ uuid: string; name: string; created_at?: string }> {
return this.post(
'/api/v1/user/passkey/register/verify',
{ challenge_token, credential, name },
{ skipWorkspace: true },
);
}
public getPasskeys(): Promise<
Array<{
uuid: string;
name: string;
aaguid?: string;
transports?: string;
backed_up?: boolean;
created_at?: string;
last_used_at?: string;
}>
> {
return this.get('/api/v1/user/passkeys', undefined, {
skipWorkspace: true,
});
}
public renamePasskey(
uuid: string,
name: string,
): Promise<{ uuid: string; name: string }> {
return this.patch(
`/api/v1/user/passkey/${encodeURIComponent(uuid)}`,
{ name },
{ skipWorkspace: true },
);
}
public deletePasskey(uuid: string): Promise<void> {
return this.delete(`/api/v1/user/passkey/${encodeURIComponent(uuid)}`, {
skipWorkspace: true,
});
}
// ============ Workspace API ============
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
return this.get('/api/v1/workspaces/bootstrap', undefined, {
+51 -1
View File
@@ -35,7 +35,9 @@ import {
AlertCircle,
RefreshCw,
Layers,
Fingerprint,
} from 'lucide-react';
import { startAuthentication } from '@simplewebauthn/browser';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -63,6 +65,8 @@ export default function Login() {
const [spaceLoading, setSpaceLoading] = useState(false);
const [showLocalLogin, setShowLocalLogin] = useState(false);
const [showSpaceLogin, setShowSpaceLogin] = useState(false);
const [showPasskeyLogin, setShowPasskeyLogin] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
@@ -90,6 +94,9 @@ export default function Login() {
}
setShowLocalLogin(res.password_login_enabled !== false);
setShowSpaceLogin(res.space_login_enabled !== false);
setShowPasskeyLogin(
res.passkey_login_enabled !== false || Boolean(res.passkey_supported),
);
setLoading(false);
// Also check if already logged in
@@ -184,6 +191,30 @@ export default function Login() {
handleLogin(values.email, values.password);
}
async function handlePasskeyLogin() {
setPasskeyLoading(true);
try {
const { options, challenge_token } =
await httpClient.getPasskeyAuthOptions(
undefined,
window.location.origin,
);
const authResp = await startAuthentication({ optionsJSON: options });
const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp);
if (await finishLogin(res.token, res.user)) {
toast.success(t('common.passkeyLoginSuccess'));
}
} catch (error: any) {
if (error?.name === 'NotAllowedError') {
// User cancelled the biometric prompt
} else {
toast.error(error?.message || t('common.passkeyLoginFailed'));
}
} finally {
setPasskeyLoading(false);
}
}
function handleLogin(username: string, password: string) {
httpClient
.authUser(username, password)
@@ -324,8 +355,27 @@ export default function Login() {
</div>
)}
{showPasskeyLogin && (
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="w-full cursor-pointer"
onClick={handlePasskeyLogin}
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Fingerprint className="mr-2 h-4 w-4" />
)}
{t('common.loginWithPasskey')}
</Button>
</div>
)}
{/* Divider - only show if both login methods are available */}
{showSpaceLogin && showLocalLogin && (
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
+21
View File
@@ -86,6 +86,10 @@ const enUS = {
'Recommended: Use official stable model APIs and cloud services',
loginLocal: 'Login with local account',
loginWithPassword: 'Login with password',
loginWithPasskey: 'Sign in with Passkey',
passkeyLoginSuccess: 'Passkey verified successfully, signing in...',
passkeyLoginFailed: 'Failed to sign in with Passkey',
passkeyNotSupported: 'Passkey is not supported on this browser or device',
spaceLoginTitle: 'Login with LangBot Account',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
@@ -744,6 +748,9 @@ const enUS = {
'Are you sure you want to install plugin "{{name}}" ({{version}})?',
downloadComplete: 'Plugin "{{name}}" download completed',
installFailed: 'Installation failed, please try again later',
installed: 'Installed',
updateAvailable: 'Update available',
alreadyInstalled: '{{name}} is already installed',
loadFailed: 'Failed to get plugin list, please try again later',
noDescription: 'No description available',
recommendation: {
@@ -1339,6 +1346,20 @@ const enUS = {
bindSpaceWarning:
'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.',
bindSpaceSuccess: 'LangBot Account bound successfully',
passkeySectionTitle: 'Passkeys',
passkeySectionDesc:
'Sign in securely without passwords using biometrics or security keys',
addPasskey: 'Add Passkey',
passkeyName: 'Key Name',
passkeyNamePlaceholder: 'e.g., MacBook Touch ID, YubiKey',
passkeyCreated: 'Created on {{date}}',
passkeyLastUsed: 'Last used: {{date}}',
noPasskeys: 'No passkeys registered yet',
deletePasskeyConfirm:
'Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.',
passkeyAddedSuccess: 'Passkey added successfully',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyRenameSuccess: 'Passkey renamed successfully',
bindSpaceFailed: 'Failed to bind LangBot Account',
bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.',
+22
View File
@@ -89,6 +89,11 @@ const esES = {
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
loginLocal: 'Iniciar sesión con cuenta local',
loginWithPassword: 'Iniciar sesión con contraseña',
loginWithPasskey: 'Iniciar sesión con Passkey',
passkeyLoginSuccess: 'Passkey verificada con éxito, iniciando sesión...',
passkeyLoginFailed: 'Error al iniciar sesión con Passkey',
passkeyNotSupported:
'Passkey no es compatible en este navegador o dispositivo',
spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
spaceLoginDescription:
'Escanea el código QR o visita el enlace para autorizar',
@@ -764,6 +769,9 @@ const esES = {
installFailed: 'Error en la instalación, por favor inténtalo más tarde',
loadFailed:
'Error al obtener la lista de plugins, por favor inténtalo más tarde',
installed: 'Instalado',
updateAvailable: 'Actualización disponible',
alreadyInstalled: '{{name}} ya está instalado',
noDescription: 'No hay descripción disponible',
recommendation: {
pause: 'Pausar rotación automática',
@@ -1376,6 +1384,20 @@ const esES = {
bindSpaceWarning:
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.',
bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente',
passkeySectionTitle: 'Llaves de acceso (Passkeys)',
passkeySectionDesc:
'Inicia sesión de forma segura sin contraseñas usando biometría o llaves de seguridad',
addPasskey: 'Añadir llave de acceso',
passkeyName: 'Nombre de la llave',
passkeyNamePlaceholder: 'p. ej., MacBook Touch ID, YubiKey',
passkeyCreated: 'Creada el {{date}}',
passkeyLastUsed: 'Último uso: {{date}}',
noPasskeys: 'No hay llaves de acceso registradas',
deletePasskeyConfirm:
'¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.',
passkeyAddedSuccess: 'Llave de acceso añadida con éxito',
passkeyDeleteSuccess: 'Llave de acceso eliminada',
passkeyRenameSuccess: 'Nombre de llave de acceso modificado con éxito',
bindSpaceFailed: 'Error al vincular la cuenta de LangBot',
bindSpaceInvalidState:
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
+22
View File
@@ -87,6 +87,11 @@ const jaJP = {
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
loginLocal: 'ローカルアカウントでログイン',
loginWithPassword: 'パスワードでログイン',
loginWithPasskey: 'パスキーでログイン',
passkeyLoginSuccess: 'パスキーの認証に成功しました。ログイン中...',
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
passkeyNotSupported:
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
@@ -753,6 +758,9 @@ const jaJP = {
installFailed: 'インストールに失敗しました。後でもう一度お試しください',
loadFailed:
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
installed: 'インストール済み',
updateAvailable: '更新あり',
alreadyInstalled: '{{name}} はインストール済みです',
noDescription: '説明がありません',
recommendation: {
pause: '自動ローテーションを一時停止',
@@ -1345,6 +1353,20 @@ const jaJP = {
bindSpaceWarning:
'連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。',
bindSpaceSuccess: 'LangBot アカウントの連携に成功しました',
passkeySectionTitle: 'パスキー (Passkey)',
passkeySectionDesc:
'生体認証やセキュリティキーを使って、パスワード不要で安全にログインします',
addPasskey: 'パスキーを追加',
passkeyName: 'キー名',
passkeyNamePlaceholder: '例: MacBook Touch ID、YubiKey',
passkeyCreated: '作成日: {{date}}',
passkeyLastUsed: '最終使用: {{date}}',
noPasskeys: '登録されているパスキーはありません',
deletePasskeyConfirm:
'このパスキーを削除してもよろしいですか?削除後はこのキーでのログインができなくなります。',
passkeyAddedSuccess: 'パスキーが正常に追加されました',
passkeyDeleteSuccess: 'パスキーを削除しました',
passkeyRenameSuccess: 'パスキー名を変更しました',
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。',
+22
View File
@@ -86,6 +86,11 @@ const ruRU = {
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
loginLocal: 'Войти с локальной учётной записью',
loginWithPassword: 'Войти с паролем',
loginWithPasskey: 'Войти с помощью Passkey',
passkeyLoginSuccess: 'Passkey успешно подтверждён, вход...',
passkeyLoginFailed: 'Не удалось войти с помощью Passkey',
passkeyNotSupported:
'Passkey не поддерживается в этом браузере или на устройстве',
spaceLoginTitle: 'Войти с аккаунтом LangBot',
spaceLoginDescription:
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
@@ -758,6 +763,9 @@ const ruRU = {
downloadComplete: 'Плагин "{{name}}" загружен',
installFailed: 'Ошибка установки, попробуйте позже',
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
installed: 'Установлено',
updateAvailable: 'Доступно обновление',
alreadyInstalled: '{{name}} уже установлен',
noDescription: 'Описание отсутствует',
recommendation: {
pause: 'Приостановить авто-прокрутку',
@@ -1350,6 +1358,20 @@ const ruRU = {
bindSpaceWarning:
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.',
bindSpaceSuccess: 'Аккаунт LangBot успешно привязан',
passkeySectionTitle: 'Ключи доступа (Passkey)',
passkeySectionDesc:
'Безопасный вход без пароля с помощью биометрии или аппаратного ключа',
addPasskey: 'Добавить ключ доступа',
passkeyName: 'Название ключа',
passkeyNamePlaceholder: 'например, MacBook Touch ID, YubiKey',
passkeyCreated: 'Создан {{date}}',
passkeyLastUsed: 'Последнее использование: {{date}}',
noPasskeys: 'Нет зарегистрированных ключей доступа',
deletePasskeyConfirm:
'Вы уверены, что хотите удалить этот ключ доступа? Вы больше не сможете использовать его для входа.',
passkeyAddedSuccess: 'Ключ доступа успешно добавлен',
passkeyDeleteSuccess: 'Ключ доступа удален',
passkeyRenameSuccess: 'Ключ доступа успешно переименован',
bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
bindSpaceInvalidState:
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
+21
View File
@@ -86,6 +86,10 @@ const thTH = {
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
loginWithPasskey: 'เข้าสู่ระบบด้วย Passkey',
passkeyLoginSuccess: 'ยืนยัน Passkey สำเร็จ กำลังเข้าสู่ระบบ...',
passkeyLoginFailed: 'เข้าสู่ระบบด้วย Passkey ล้มเหลว',
passkeyNotSupported: 'เบราว์เซอร์หรืออุปกรณ์นี้ไม่รองรับ Passkey',
spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginDescription:
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
@@ -737,6 +741,9 @@ const thTH = {
downloadComplete: 'ดาวน์โหลดปลั๊กอิน "{{name}}" เสร็จสมบูรณ์',
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
installed: 'ติดตั้งแล้ว',
updateAvailable: 'มีอัปเดต',
alreadyInstalled: '{{name}} ติดตั้งแล้ว',
noDescription: 'ไม่มีคำอธิบาย',
recommendation: {
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
@@ -1321,6 +1328,20 @@ const thTH = {
bindSpaceWarning:
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot',
bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ',
passkeySectionTitle: 'พาสคีย์ (Passkey)',
passkeySectionDesc:
'เข้าสู่ระบบอย่างปลอดภัยโดยไม่ต้องใช้รหัสผ่านด้วยไบโอเมตริกซ์หรือคีย์ความปลอดภัย',
addPasskey: 'เพิ่มพาสคีย์',
passkeyName: 'ชื่อคีย์',
passkeyNamePlaceholder: 'เช่น MacBook Touch ID, YubiKey',
passkeyCreated: 'สร้างเมื่อ {{date}}',
passkeyLastUsed: 'ใช้งานล่าสุด: {{date}}',
noPasskeys: 'ยังไม่มีพาสคีย์ที่ลงทะเบียน',
deletePasskeyConfirm:
'คุณแน่ใจหรือไม่ว่าต้องการลบพาสคีย์นี้? คุณจะไม่สามารถใช้คีย์นี้เข้าสู่ระบบได้อีก',
passkeyAddedSuccess: 'เพิ่มพาสคีย์สำเร็จ',
passkeyDeleteSuccess: 'ลบพาสคีย์แล้ว',
passkeyRenameSuccess: 'เปลี่ยนชื่อพาสคีย์สำเร็จ',
bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
+21
View File
@@ -87,6 +87,10 @@ const viVN = {
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
loginLocal: 'Đăng nhập với tài khoản cục bộ',
loginWithPassword: 'Đăng nhập bằng mật khẩu',
loginWithPasskey: 'Đăng nhập bằng Passkey',
passkeyLoginSuccess: 'Xác thực Passkey thành công, đang đăng nhập...',
passkeyLoginFailed: 'Đăng nhập bằng Passkey thất bại',
passkeyNotSupported: 'Trình duyệt hoặc thiết bị này không hỗ trợ Passkey',
spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot',
spaceLoginDescription:
'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền',
@@ -752,6 +756,9 @@ const viVN = {
downloadComplete: 'Tải plugin "{{name}}" hoàn tất',
installFailed: 'Cài đặt thất bại, vui lòng thử lại sau',
loadFailed: 'Lấy danh sách plugin thất bại, vui lòng thử lại sau',
installed: 'Đã cài đặt',
updateAvailable: 'Có bản cập nhật',
alreadyInstalled: '{{name}} đã được cài đặt',
noDescription: 'Không có mô tả',
recommendation: {
pause: 'Tạm dừng tự động xoay',
@@ -1344,6 +1351,20 @@ const viVN = {
bindSpaceWarning:
'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.',
bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công',
passkeySectionTitle: 'Mã khóa truy cập (Passkey)',
passkeySectionDesc:
'Đăng nhập an toàn không cần mật khẩu bằng sinh trắc học hoặc khóa bảo mật',
addPasskey: 'Thêm mã khóa truy cập',
passkeyName: 'Tên khóa',
passkeyNamePlaceholder: 'ví dụ: MacBook Touch ID, YubiKey',
passkeyCreated: 'Được tạo vào {{date}}',
passkeyLastUsed: 'Sử dụng lần cuối: {{date}}',
noPasskeys: 'Chưa có mã khóa truy cập nào được đăng ký',
deletePasskeyConfirm:
'Bạn có chắc chắn muốn xóa mã khóa truy cập này? Bạn sẽ không thể sử dụng nó để đăng nhập nữa.',
passkeyAddedSuccess: 'Đã thêm mã khóa truy cập thành công',
passkeyDeleteSuccess: 'Đã xóa mã khóa truy cập',
passkeyRenameSuccess: 'Đã đổi tên mã khóa truy cập thành công',
bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại',
bindSpaceInvalidState:
'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.',
+20
View File
@@ -84,6 +84,10 @@ const zhHans = {
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
loginLocal: '使用本地账号登录',
loginWithPassword: '通过密码登录',
loginWithPasskey: '使用 Passkey 登录',
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
passkeyLoginFailed: 'Passkey 登录失败',
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
spaceLoginTitle: '通过 LangBot 账号登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码',
@@ -711,6 +715,9 @@ const zhHans = {
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
downloadComplete: '插件 "{{name}}" 下载完成',
installFailed: '安装失败,请稍后重试',
installed: '已安装',
updateAvailable: '有更新',
alreadyInstalled: '{{name}} 已安装',
loadFailed: '获取插件列表失败,请稍后重试',
noDescription: '暂无描述',
recommendation: {
@@ -1274,6 +1281,19 @@ const zhHans = {
bindSpaceWarning:
'绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot 账号的邮箱。',
bindSpaceSuccess: 'LangBot 账号绑定成功',
passkeySectionTitle: '通行密钥 (Passkey)',
passkeySectionDesc: '使用指纹、面容或硬件安全密钥免密安全登录',
addPasskey: '添加通行密钥',
passkeyName: '密钥名称',
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
passkeyCreated: '创建于 {{date}}',
passkeyLastUsed: '上次使用: {{date}}',
noPasskeys: '暂未绑定任何通行密钥',
deletePasskeyConfirm:
'确定要删除此通行密钥吗?删除后将无法使用该密钥登录。',
passkeyAddedSuccess: '通行密钥添加成功',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyRenameSuccess: '通行密钥重命名成功',
bindSpaceFailed: '绑定 LangBot 账号失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录',
+20
View File
@@ -84,6 +84,10 @@ const zhHant = {
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
loginLocal: '使用本地帳號登入',
loginWithPassword: '透過密碼登入',
loginWithPasskey: '使用 Passkey 登入',
passkeyLoginSuccess: 'Passkey 驗證成功,正在登入...',
passkeyLoginFailed: 'Passkey 登入失敗',
passkeyNotSupported: '目前瀏覽器或裝置不支援 Passkey',
spaceLoginTitle: '透過 LangBot 帳號登入',
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
spaceLoginUserCode: '您的驗證碼',
@@ -715,6 +719,9 @@ const zhHant = {
downloadComplete: '插件 "{{name}}" 下載完成',
installFailed: '安裝失敗,請稍後重試',
loadFailed: '取得插件列表失敗,請稍後重試',
installed: '已安裝',
updateAvailable: '有更新',
alreadyInstalled: '{{name}} 已安裝',
noDescription: '暫無描述',
recommendation: {
pause: '暫停自動輪播',
@@ -1275,6 +1282,19 @@ const zhHant = {
bindSpaceWarning:
'綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot 帳號的電子郵件。',
bindSpaceSuccess: 'LangBot 帳號綁定成功',
passkeySectionTitle: '通行密鑰 (Passkey)',
passkeySectionDesc: '使用指紋、面容或硬體安全金鑰免密安全登入',
addPasskey: '新增通行密鑰',
passkeyName: '金鑰名稱',
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
passkeyCreated: '建立於 {{date}}',
passkeyLastUsed: '上次使用: {{date}}',
noPasskeys: '尚未綁定任何通行密鑰',
deletePasskeyConfirm:
'確定要刪除此通行密鑰嗎?刪除後將無法使用該金鑰登入。',
passkeyAddedSuccess: '通行密鑰新增成功',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyRenameSuccess: '通行密鑰重新命名成功',
bindSpaceFailed: '綁定 LangBot 帳號失敗',
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
+232
View File
@@ -0,0 +1,232 @@
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: real app/components, intercepted APIs, no production Box.
// Load the shipped metadata rather than reproducing its tooltip conditions.
const requireFromTest = createRequire(__filename);
const { load } = createRequire(requireFromTest.resolve('eslint'))(
'js-yaml',
) as {
load: (source: string) => unknown;
};
const aiMetadata = load(
readFileSync(
resolve(
__dirname,
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
),
'utf8',
),
);
const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const forcedHint = '已强制使用全局沙箱,无法修改作用域。';
interface BoxState {
enabled: boolean;
available: boolean;
}
async function openPipeline(page: Page, box: BoxState, forced = '') {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_language: 'zh-Hans' },
});
await page.route('**/api/v1/system/info', (route) =>
route.fulfill({
json: {
code: 0,
data: {
debug: false,
version: 'sandbox-scope-ui-fixture',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
allow_modify_login_info: true,
disable_models_service: false,
limitation: {
max_bots: -1,
max_pipelines: -1,
max_extensions: -1,
force_box_session_id_template: forced,
},
outbound_ips: [],
wizard_status: 'completed',
wizard_progress: null,
},
},
}),
);
await page.route('**/api/v1/box/status', (route) =>
route.fulfill({
json: {
code: 0,
data: {
...box,
profile: 'UI fixture only',
recent_error_count: 0,
active_sessions: 0,
managed_processes: 0,
session_ttl_sec: 3600,
backend: { name: 'ui-fixture', available: box.available },
},
},
}),
);
await page.route(/\/api\/v1\/tools(?:\?.*)?$/, (route) =>
route.fulfill({ json: { code: 0, data: { tools: [] } } }),
);
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({ json: { code: 0, data: { configs: [aiMetadata] } } }),
);
await page.route('**/api/v1/pipelines/sandbox-scope-fixture', (route) =>
route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'sandbox-scope-fixture',
name: 'Sandbox scope — UI fixture only',
description: '',
emoji: '⚙️',
is_default: false,
config: {
ai: {
runner: { runner: 'local-agent' },
'local-agent': {
'box-session-id-template': '{launcher_type}_{launcher_id}',
},
},
trigger: {},
safety: {},
output: {},
},
},
},
},
}),
);
await page.goto('/home/pipelines?id=sandbox-scope-fixture');
await page.getByRole('button', { name: 'AI 能力', exact: true }).click();
// DynamicForm gates this control through its wrapper's pointer-events,
// and its label targets that wrapper rather than the nested select.
const scope = page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('沙箱作用域', { exact: true }) })
.getByRole('combobox');
await expect(scope).toBeVisible();
return scope;
}
async function expectWarning(page: Page, hint: string) {
const warning = page.getByRole('button', { name: hint, exact: true });
await expect(warning).toBeVisible();
await warning.hover();
await expect(page.getByRole('tooltip')).toHaveText(hint);
}
async function expectNoWarning(page: Page) {
await expect(page.getByRole('button', { name: unavailableHint })).toHaveCount(
0,
);
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(0);
await expect(page.getByRole('tooltip')).toHaveCount(0);
}
test.describe('sandbox scope disabled reason (UI fixtures only)', () => {
for (const scenario of [
{ name: 'Box disabled', enabled: false, available: false, forced: '' },
{ name: 'Box disconnected', enabled: true, available: false, forced: '' },
{
name: 'unavailable Box takes precedence over forced global',
enabled: true,
available: false,
forced: '{global}',
},
]) {
test(scenario.name, async ({ page }) => {
const scope = await openPipeline(page, scenario, scenario.forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(
0,
);
});
}
for (const forced of ['{global}', ' {global} ']) {
test(`available Box with forced global explains the deployment restriction (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openPipeline(
page,
{ enabled: true, available: true },
forced,
);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expect(scope).toHaveText('全局(所有人共享)');
await expectWarning(page, forcedHint);
await expect(
page.getByRole('button', { name: unavailableHint }),
).toHaveCount(0);
});
}
for (const forced of ['', ' ']) {
test(`available and unforced Box is editable without a disabled warning (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openPipeline(
page,
{ enabled: true, available: true },
forced,
);
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expect(scope).toHaveText('每个会话(推荐)');
await expectNoWarning(page);
await scope.click();
await page
.getByRole('option', { name: '全局(所有人共享)', exact: true })
.click();
await expect(scope).toHaveText('全局(所有人共享)');
await expectNoWarning(page);
});
}
for (const forced of ['', '{global}']) {
test(`Box status polls update the warning without remounting (${forced || 'unforced'})`, async ({
page,
}) => {
await page.clock.install();
const box = { enabled: true, available: false };
const scope = await openPipeline(page, box, forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await page.mouse.move(0, 0);
const recovered = page.waitForResponse('**/api/v1/box/status');
box.available = true;
await page.clock.fastForward(31_000);
await recovered;
if (forced) {
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, forcedHint);
} else {
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expectNoWarning(page);
}
await page.mouse.move(0, 0);
const disconnected = page.waitForResponse('**/api/v1/box/status');
box.available = false;
await page.clock.fastForward(31_000);
await disconnected;
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
});
}
});
@@ -0,0 +1,252 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import test from 'node:test';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const { load } = createRequire(require.resolve('eslint'))('js-yaml');
const metadata = load(
fs.readFileSync(
new URL(
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
import.meta.url,
),
'utf8',
),
);
const scope = metadata.stages
.find((stage) => stage.name === 'local-agent')
.config.find((item) => item.name === 'box-session-id-template');
const unavailable = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const globalForced = '已强制使用全局沙箱,无法修改作用域。';
const customForced = '已强制使用固定沙箱作用域,无法修改作用域。';
function loadSource(relativePath) {
const filename = new URL(`../../src/${relativePath}`, import.meta.url);
assert.ok(fs.existsSync(filename), `Missing policy module: ${relativePath}`);
const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
(name) => {
if (name === '@/app/infra/entities/form/dynamic')
return loadSource('app/infra/entities/form/dynamic.ts');
throw new Error(`Unexpected runtime import: ${name}`);
},
loaded,
loaded.exports,
);
return loaded.exports;
}
function policies() {
return {
...loadSource('app/home/components/dynamic-form/DynamicFormConditions.ts'),
...loadSource(
'app/home/pipelines/components/pipeline-form/BoxScopeContext.ts',
),
};
}
function scopeState(available, forcedTemplate) {
const { getBoxScopeContext, resolveDisabledState } = policies();
return resolveDisabledState(
scope,
{},
undefined,
getBoxScopeContext(available, forcedTemplate),
);
}
test('sandbox default tooltip explains only unavailability', () => {
assert.equal(scope.disabled_tooltip.zh_Hans, unavailable);
});
for (const [name, available, template, expected] of [
['Box disabled', false, '', unavailable],
['Box disconnected', false, undefined, unavailable],
[
'unavailable takes precedence over forced global',
false,
'{global}',
unavailable,
],
[
'unavailable takes precedence over forced custom',
false,
'{pipeline_id}',
unavailable,
],
['available forced global', true, '{global}', globalForced],
['available padded forced global', true, ' {global} ', globalForced],
['available whitespace-only editable', true, ' ', undefined],
['available forced custom', true, '{pipeline_id}', customForced],
['available forced literal', true, 'tenant-sandbox', customForced],
['available editable', true, '', undefined],
['available without limitation', true, undefined, undefined],
]) {
test(name, () => {
const state = scopeState(available, template);
assert.equal(state.isDisabledByCondition, expected !== undefined);
assert.equal(state.disabledTooltip?.zh_Hans, expected);
});
}
test('reason follows availability and forced-scope transitions without mutating metadata', () => {
const snapshot = structuredClone(scope);
for (const [available, template, expected] of [
[false, '{global}', unavailable],
[true, '{global}', globalForced],
[true, '{pipeline_id}', customForced],
[true, '', undefined],
[false, '', unavailable],
[true, '', undefined],
]) {
assert.equal(
scopeState(available, template).disabledTooltip?.zh_Hans,
expected,
);
}
assert.deepEqual(scope, snapshot);
});
test('all sandbox reason variants preserve the eight metadata locales', () => {
const locales = [
'en_US',
'zh_Hans',
'zh_Hant',
'ja_JP',
'vi_VN',
'th_TH',
'es_ES',
'ru_RU',
].sort();
assert.equal(scope.disabled_tooltip_overrides?.length, 2);
const messages = [
scope.disabled_tooltip,
...scope.disabled_tooltip_overrides.map((entry) => entry.tooltip),
];
for (const message of messages) {
assert.deepEqual(Object.keys(message).sort(), locales);
for (const locale of locales) assert.ok(message[locale].trim(), locale);
}
for (const locale of locales) {
assert.equal(
new Set(messages.map((message) => message[locale])).size,
3,
locale,
);
assert.equal(
scopeState(false, '{global}').disabledTooltip[locale],
messages[0][locale],
);
assert.equal(
scopeState(true, '{global}').disabledTooltip[locale],
messages[1][locale],
);
assert.equal(
scopeState(true, '{pipeline_id}').disabledTooltip[locale],
messages[2][locale],
);
}
});
test('ordinary static disabled tooltip remains compatible', () => {
const { resolveDisabledState } = policies();
const tooltip = { en_US: 'Read only' };
const config = {
disable_if: { field: 'locked', operator: 'eq', value: true },
disabled_tooltip: tooltip,
};
assert.deepEqual(resolveDisabledState(config, { locked: true }), {
isDisabledByCondition: true,
disabledTooltip: tooltip,
});
assert.deepEqual(resolveDisabledState(config, { locked: false }), {
isDisabledByCondition: false,
disabledTooltip: undefined,
});
assert.equal(
resolveDisabledState({ disabled_tooltip: tooltip }, {}).disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState({ disable_if: config.disable_if }, { locked: true })
.disabledTooltip,
undefined,
);
});
test('conditional overrides reuse eq, neq, in and live/external/system resolution', () => {
const { matchesFormCondition, resolveDisabledState } = policies();
const watched = { mode: 'live', empty: null, '__system.locked': false };
const external = { mode: 'external', fallback: 3, empty: 'external' };
const system = { locked: true };
for (const [condition, expected] of [
[{ field: 'mode', operator: 'eq', value: 'live' }, true],
[{ field: 'mode', operator: 'eq', value: 'external' }, false],
[{ field: 'fallback', operator: 'neq', value: 4 }, true],
[{ field: 'fallback', operator: 'in', value: [2, 3] }, true],
[{ field: 'fallback', operator: 'in', value: '3' }, false],
[{ field: 'fallback', operator: 'eq', value: '3' }, false],
[{ field: 'empty', operator: 'eq', value: null }, true],
[{ field: '__system.locked', operator: 'eq', value: true }, true],
[{ field: 'absent', operator: 'eq', value: true }, false],
])
assert.equal(
matchesFormCondition(condition, watched, external, system),
expected,
);
const config = {
disable_if: { field: '__system.locked', operator: 'eq', value: true },
disabled_tooltip: { en_US: 'Default' },
disabled_tooltip_overrides: [
{
when: { field: 'mode', operator: 'eq', value: 'external' },
tooltip: { en_US: 'Wrong' },
},
{
when: { field: 'fallback', operator: 'in', value: [3] },
tooltip: { en_US: 'First match' },
},
{
when: { field: 'mode', operator: 'neq', value: 'external' },
tooltip: { en_US: 'Later match' },
},
],
};
assert.equal(
resolveDisabledState(config, watched, external, system).disabledTooltip
.en_US,
'First match',
);
assert.equal(
resolveDisabledState(config, {}, {}, system).disabledTooltip.en_US,
'Later match',
);
assert.equal(
resolveDisabledState(config, watched, external, { locked: false })
.disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState(
{ ...config, disabled_tooltip_overrides: [] },
watched,
external,
system,
).disabledTooltip.en_US,
'Default',
);
const unmatched = {
...config,
disabled_tooltip_overrides: [config.disabled_tooltip_overrides[0]],
};
assert.equal(
resolveDisabledState(unmatched, watched, external, system).disabledTooltip
.en_US,
'Default',
);
});