mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-12 04:47:14 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9db6650274 | |||
| b594cf23e4 | |||
| 45d77c3926 | |||
| 1ea9cd3f6f |
@@ -57,3 +57,6 @@ testsdk/
|
||||
|
||||
# Next.js build cache (legacy)
|
||||
web/.next/
|
||||
web/.pnpm-home
|
||||
.tmp
|
||||
Caddyfile
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,315 @@ 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,40 @@
|
||||
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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 sandbox:Box 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}"
|
||||
|
||||
@@ -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,138 @@
|
||||
"""
|
||||
Integration smoke tests for Passkey API endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.integration.api.test_smoke import (
|
||||
fake_api_app,
|
||||
mock_circular_import_chain,
|
||||
quart_test_client,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')]
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -2085,6 +2133,7 @@ dependencies = [
|
||||
{ name = "urllib3" },
|
||||
{ name = "uv" },
|
||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
|
||||
{ name = "webauthn" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
@@ -2129,7 +2178,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" },
|
||||
@@ -2180,6 +2229,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 +2246,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 +2267,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]]
|
||||
@@ -4073,6 +4123,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"
|
||||
@@ -4490,6 +4561,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"
|
||||
@@ -6166,6 +6250,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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+17
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
@@ -1339,6 +1343,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.',
|
||||
|
||||
@@ -87,6 +87,11 @@ const jaJP = {
|
||||
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
||||
loginLocal: 'ローカルアカウントでログイン',
|
||||
loginWithPassword: 'パスワードでログイン',
|
||||
loginWithPasskey: 'パスキーでログイン',
|
||||
passkeyLoginSuccess: 'パスキーの認証に成功しました。ログイン中...',
|
||||
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
|
||||
passkeyNotSupported:
|
||||
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
|
||||
spaceLoginTitle: 'LangBot アカウントでログイン',
|
||||
spaceLoginDescription:
|
||||
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
|
||||
@@ -1345,6 +1350,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:
|
||||
'無効な連携リクエストです。アカウント設定から再度お試しください。',
|
||||
|
||||
@@ -84,6 +84,10 @@ const zhHans = {
|
||||
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
||||
loginLocal: '使用本地账号登录',
|
||||
loginWithPassword: '通过密码登录',
|
||||
loginWithPasskey: '使用 Passkey 登录',
|
||||
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
|
||||
passkeyLoginFailed: 'Passkey 登录失败',
|
||||
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
|
||||
spaceLoginTitle: '通过 LangBot 账号登录',
|
||||
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
|
||||
spaceLoginUserCode: '您的验证码',
|
||||
@@ -1274,6 +1278,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: '设置密码后可使用邮箱密码登录',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user