mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-28 13:17:14 +00:00
Compare commits
9 Commits
v4.10.7
...
7803d56254
| Author | SHA1 | Date | |
|---|---|---|---|
| 7803d56254 | |||
| 54c96a18e1 | |||
| 579e3556e4 | |||
| a08a177a11 | |||
| c224f61c8c | |||
| b62cc9da45 | |||
| 700104c015 | |||
| 717bd4b8bf | |||
| 0cc0e1b02d |
+1
-1
@@ -71,7 +71,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.5.3",
|
"langbot-plugin==0.5.5",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
|
from ..api.http.context import ExecutionContext
|
||||||
from ..core import app
|
from ..core import app
|
||||||
from . import operator
|
from . import operator
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
@@ -66,7 +67,14 @@ class CommandManager:
|
|||||||
|
|
||||||
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
||||||
if require_context is not None:
|
if require_context is not None:
|
||||||
result = require_context(context)
|
result = require_context(
|
||||||
|
ExecutionContext(
|
||||||
|
instance_uuid=context.instance_uuid,
|
||||||
|
workspace_uuid=context.workspace_uuid,
|
||||||
|
placement_generation=context.placement_generation,
|
||||||
|
query_uuid=context.query_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ class PersistenceManager:
|
|||||||
await self._validate_cloud_runtime()
|
await self._validate_cloud_runtime()
|
||||||
return
|
return
|
||||||
|
|
||||||
self._enable_sqlite_foreign_keys()
|
|
||||||
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
||||||
async with self._release_migration_lock():
|
async with self._release_migration_lock():
|
||||||
await self._initialize_managed_schema()
|
await self._initialize_managed_schema()
|
||||||
@@ -185,6 +184,7 @@ class PersistenceManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._initialize_managed_schema()
|
await self._initialize_managed_schema()
|
||||||
|
await self._enable_sqlite_foreign_keys_after_migration()
|
||||||
|
|
||||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
@@ -373,6 +373,17 @@ class PersistenceManager:
|
|||||||
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
||||||
self._oss_tenant_scope_listener_installed = True
|
self._oss_tenant_scope_listener_installed = True
|
||||||
|
|
||||||
|
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
|
||||||
|
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
|
||||||
|
engine = self.get_db_engine()
|
||||||
|
if engine.dialect.name != 'sqlite':
|
||||||
|
return
|
||||||
|
await engine.dispose()
|
||||||
|
self._enable_sqlite_foreign_keys()
|
||||||
|
# Dispose again so every runtime connection is opened through the new
|
||||||
|
# listener instead of reusing a pre-migration pooled connection.
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
def _enable_sqlite_foreign_keys(self) -> None:
|
def _enable_sqlite_foreign_keys(self) -> None:
|
||||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||||
engine = self.get_db_engine()
|
engine = self.get_db_engine()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
@@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
|||||||
temporary_path.unlink(missing_ok=True)
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _fsync_file(path: pathlib.Path) -> None:
|
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
||||||
|
|
||||||
|
descriptor: int | None = None
|
||||||
|
for attempt in range(reopen_attempts):
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY)
|
||||||
|
break
|
||||||
|
except FileNotFoundError:
|
||||||
|
if attempt + 1 >= reopen_attempts:
|
||||||
|
raise
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert descriptor is not None
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import traceback
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
import sqlalchemy.dialects.postgresql
|
||||||
|
import sqlalchemy.dialects.sqlite
|
||||||
|
|
||||||
from langbot_plugin.runtime.io import handler
|
from langbot_plugin.runtime.io import handler
|
||||||
from langbot_plugin.runtime.io.connection import Connection
|
from langbot_plugin.runtime.io.connection import Connection
|
||||||
@@ -431,6 +433,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
return f'{identity.plugin_author}/{identity.plugin_name}'
|
return f'{identity.plugin_author}/{identity.plugin_name}'
|
||||||
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _legacy_binary_storage_key(
|
||||||
|
action_context: ActionContext,
|
||||||
|
*,
|
||||||
|
owner_type: str,
|
||||||
|
owner: str,
|
||||||
|
key: str,
|
||||||
|
) -> str:
|
||||||
|
"""Return the pre-tenancy key shape for a row already scoped to this Workspace."""
|
||||||
|
|
||||||
|
legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
|
||||||
|
return f'{owner_type}:{legacy_owner}:{key}'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _binary_storage_key(
|
def _binary_storage_key(
|
||||||
cls,
|
cls,
|
||||||
@@ -896,25 +911,82 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
)
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is not None:
|
||||||
|
update_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
.values(unique_key=unique_key, value=value)
|
||||||
|
)
|
||||||
|
if update_result.rowcount:
|
||||||
|
return handler.ActionResponse.success(data={})
|
||||||
|
canonical_update = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
.values(value=value)
|
||||||
|
)
|
||||||
|
if canonical_update.rowcount:
|
||||||
|
return handler.ActionResponse.success(data={})
|
||||||
|
storage = None
|
||||||
|
|
||||||
if result.first() is not None:
|
if storage is not None:
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
.values(value=value)
|
.values(value=value)
|
||||||
)
|
)
|
||||||
else:
|
return handler.ActionResponse.success(data={})
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
|
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||||
workspace_uuid=action_context.workspace_uuid,
|
insert = {
|
||||||
unique_key=unique_key,
|
'postgresql': sqlalchemy.dialects.postgresql.insert,
|
||||||
key=key,
|
'sqlite': sqlalchemy.dialects.sqlite.insert,
|
||||||
owner_type=owner_type,
|
}.get(dialect_name)
|
||||||
owner=owner,
|
if insert is None:
|
||||||
value=value,
|
return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
|
||||||
)
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
insert(persistence_bstorage.BinaryStorage)
|
||||||
|
.values(
|
||||||
|
workspace_uuid=action_context.workspace_uuid,
|
||||||
|
unique_key=unique_key,
|
||||||
|
key=key,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
value=value,
|
||||||
)
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=['workspace_uuid', 'unique_key'],
|
||||||
|
set_={'value': value},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={},
|
data={},
|
||||||
@@ -946,6 +1018,29 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
storage = result.first()
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
retry_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
)
|
||||||
|
storage = retry_result.first()
|
||||||
if storage is None:
|
if storage is None:
|
||||||
return handler.ActionResponse.error(
|
return handler.ActionResponse.error(
|
||||||
message=f'Storage with key {key} not found',
|
message=f'Storage with key {key} not found',
|
||||||
@@ -981,10 +1076,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
message=str(e),
|
message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
)
|
)
|
||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
@@ -1012,7 +1116,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={
|
data={
|
||||||
'keys': result.scalars().all(),
|
'keys': list(dict.fromkeys(result.scalars().all())),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
|||||||
sa.Column('key', sa.String(255), nullable=False),
|
sa.Column('key', sa.String(255), nullable=False),
|
||||||
sa.Column('owner_type', sa.String(255), nullable=False),
|
sa.Column('owner_type', sa.String(255), nullable=False),
|
||||||
sa.Column('owner', sa.String(255), nullable=False),
|
sa.Column('owner', sa.String(255), nullable=False),
|
||||||
|
sa.Column('value', sa.LargeBinary, nullable=False),
|
||||||
)
|
)
|
||||||
mcp_servers = _uuid_table(
|
mcp_servers = _uuid_table(
|
||||||
metadata,
|
metadata,
|
||||||
@@ -210,7 +211,13 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
|||||||
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
||||||
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
|
binary_storages.insert().values(
|
||||||
|
unique_key='plugin:demo:key',
|
||||||
|
key='key',
|
||||||
|
owner_type='plugin',
|
||||||
|
owner='demo',
|
||||||
|
value=b'legacy-plugin-value',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
||||||
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
||||||
|
|||||||
@@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
|||||||
)
|
)
|
||||||
assert legacy_kb['collection_id'] == 'collection-1'
|
assert legacy_kb['collection_id'] == 'collection-1'
|
||||||
assert legacy_kb['legacy_vector_collection'] == 1
|
assert legacy_kb['legacy_vector_collection'] == 1
|
||||||
|
legacy_binary_storage = (
|
||||||
|
(
|
||||||
|
await conn.execute(
|
||||||
|
sa.text(
|
||||||
|
'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
|
||||||
|
"FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert legacy_binary_storage == {
|
||||||
|
'workspace_uuid': workspace_uuid,
|
||||||
|
'unique_key': 'plugin:demo:key',
|
||||||
|
'key': 'key',
|
||||||
|
'owner_type': 'plugin',
|
||||||
|
'owner': 'demo',
|
||||||
|
'value': b'legacy-plugin-value',
|
||||||
|
}
|
||||||
assert (
|
assert (
|
||||||
await conn.scalar(
|
await conn.scalar(
|
||||||
sa.text(
|
sa.text(
|
||||||
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
'INSERT INTO binary_storages '
|
'INSERT INTO binary_storages '
|
||||||
'(workspace_uuid, unique_key, key, owner_type, owner) '
|
'(workspace_uuid, unique_key, key, owner_type, owner, value) '
|
||||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
|
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': second_workspace_uuid},
|
{'workspace_uuid': second_workspace_uuid},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import pytest
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from langbot.pkg.persistence import alembic_runner
|
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
|
|
||||||
from .resource_migration_support import create_legacy_resource_schema
|
from .resource_migration_support import create_legacy_resource_schema
|
||||||
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
|||||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
||||||
|
database_path = tmp_path / 'legacy-bind-mount.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||||
|
real_open = os.open
|
||||||
|
transient_failures = 0
|
||||||
|
|
||||||
|
def transient_open(path, flags, *args, **kwargs):
|
||||||
|
nonlocal transient_failures
|
||||||
|
candidate = pathlib.Path(path)
|
||||||
|
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
|
||||||
|
transient_failures += 1
|
||||||
|
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
|
||||||
|
return real_open(path, flags, *args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
|
||||||
|
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||||
|
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
|
||||||
|
|
||||||
|
await _manager(engine)._run_alembic_migrations()
|
||||||
|
|
||||||
|
assert transient_failures == 1
|
||||||
|
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||||
|
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
@@ -179,13 +179,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|||||||
)
|
)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(schema.create_all)
|
await conn.run_sync(schema.create_all)
|
||||||
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
|
||||||
|
),
|
||||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
||||||
)
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
|
||||||
{'uuid': old_workspace_uuid},
|
{'uuid': old_workspace_uuid},
|
||||||
)
|
)
|
||||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
||||||
@@ -193,8 +197,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|||||||
await run_alembic_upgrade(engine, 'head')
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
|
||||||
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@@ -411,6 +415,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
database_path = tmp_path / 'startup-foreign-keys.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||||
|
try:
|
||||||
|
await _create_legacy_schema(engine)
|
||||||
|
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
|
||||||
|
application = type('Application', (), {})()
|
||||||
|
application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
|
||||||
|
application.instance_config = type(
|
||||||
|
'InstanceConfig',
|
||||||
|
(),
|
||||||
|
{'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
|
||||||
|
)()
|
||||||
|
manager = PersistenceManager(application)
|
||||||
|
|
||||||
|
await manager.initialize()
|
||||||
|
try:
|
||||||
|
async with manager.get_db_engine().connect() as conn:
|
||||||
|
workspace = (
|
||||||
|
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
||||||
|
)
|
||||||
|
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
|
||||||
|
foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
|
||||||
|
|
||||||
|
assert workspace['created_by_account_uuid'] == membership['account_uuid']
|
||||||
|
assert membership['role'] == 'owner'
|
||||||
|
assert membership['status'] == 'active'
|
||||||
|
assert foreign_keys == 1
|
||||||
|
finally:
|
||||||
|
await manager.shutdown()
|
||||||
|
|
||||||
|
|
||||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||||
try:
|
try:
|
||||||
@@ -425,7 +468,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
assert instance_uuid
|
assert instance_uuid
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': old_uuid},
|
{'workspace_uuid': old_uuid},
|
||||||
@@ -433,7 +476,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
||||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
),
|
),
|
||||||
{'workspace_uuid': old_uuid},
|
{'workspace_uuid': old_uuid},
|
||||||
)
|
)
|
||||||
@@ -442,12 +485,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
||||||
assert await conn.scalar(
|
assert (
|
||||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
await conn.scalar(
|
||||||
) == expected_uuid
|
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||||
assert await conn.scalar(
|
)
|
||||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
== expected_uuid
|
||||||
) == expected_uuid
|
)
|
||||||
|
assert (
|
||||||
|
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
|
||||||
|
== expected_uuid
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
|
|||||||
|
|
||||||
from langbot.pkg.command import operator
|
from langbot.pkg.command import operator
|
||||||
from langbot.pkg.command.cmdmgr import CommandManager
|
from langbot.pkg.command.cmdmgr import CommandManager
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
from tests.factories import FakeApp, command_query
|
from tests.factories import FakeApp, command_query
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
|
|||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].text == 'plugin response'
|
assert results[0].text == 'plugin response'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_selects_workspace_with_trusted_context(self):
|
||||||
|
"""Plugin command discovery receives the typed runtime scope."""
|
||||||
|
|
||||||
|
fake_app = FakeApp()
|
||||||
|
mgr = CommandManager(fake_app)
|
||||||
|
mgr.cmd_list = []
|
||||||
|
fake_app.plugin_connector.require_workspace_context = AsyncMock()
|
||||||
|
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
ctx = self._create_context(command='help')
|
||||||
|
ctx.instance_uuid = 'instance-a'
|
||||||
|
ctx.workspace_uuid = 'workspace-a'
|
||||||
|
ctx.placement_generation = 4
|
||||||
|
ctx.query_uuid = 'query-a'
|
||||||
|
|
||||||
|
async for _ in mgr._execute(ctx, mgr.cmd_list):
|
||||||
|
pass
|
||||||
|
|
||||||
|
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
|
||||||
|
assert isinstance(selected, ExecutionContext)
|
||||||
|
assert selected.instance_uuid == 'instance-a'
|
||||||
|
assert selected.workspace_uuid == 'workspace-a'
|
||||||
|
assert selected.placement_generation == 4
|
||||||
|
assert selected.query_uuid == 'query-a'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_with_bound_plugins(self):
|
async def test_execute_with_bound_plugins(self):
|
||||||
"""_execute passes bound_plugins to plugin connector."""
|
"""_execute passes bound_plugins to plugin connector."""
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ class TestSetBinaryStorage:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
mock_app.persistence_mgr = Mock()
|
mock_app.persistence_mgr = Mock()
|
||||||
|
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
|
||||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
||||||
mock_app.logger = Mock()
|
mock_app.logger = Mock()
|
||||||
return mock_app
|
return mock_app
|
||||||
@@ -270,8 +271,8 @@ class TestSetBinaryStorage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
assert app.persistence_mgr.execute_async.await_count == 2
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
assert insert_params['workspace_uuid'] == 'workspace-a'
|
assert insert_params['workspace_uuid'] == 'workspace-a'
|
||||||
assert insert_params['unique_key'] == canonical_binary_key(
|
assert insert_params['unique_key'] == canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
@@ -301,6 +302,69 @@ class TestSetBinaryStorage:
|
|||||||
assert expected_key in update_params.values()
|
assert expected_key in update_params.values()
|
||||||
assert update_params['value'] == b'new'
|
assert update_params['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_adopts_legacy_storage_before_updating(self, app):
|
||||||
|
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
adopted = SimpleNamespace(rowcount=1)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
adopted,
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
|
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
|
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||||
|
assert expected_key in adoption_params.values()
|
||||||
|
assert adoption_params['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
lost_race = SimpleNamespace(rowcount=0)
|
||||||
|
canonical_winner = SimpleNamespace(rowcount=1)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
lost_race,
|
||||||
|
canonical_winner,
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 4
|
||||||
|
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
|
||||||
|
assert winner_update['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
lost_race = SimpleNamespace(rowcount=0)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
lost_race,
|
||||||
|
SimpleNamespace(rowcount=0),
|
||||||
|
make_result(),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 5
|
||||||
|
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
|
||||||
|
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||||
|
assert insert_params['value'] == b'new'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||||
@@ -525,6 +589,46 @@ class TestGetBinaryStorage:
|
|||||||
in statement_params.values()
|
in statement_params.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reads_legacy_storage_without_mutating_key(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(
|
||||||
|
unique_key='plugin:test-author/test-plugin:test-key',
|
||||||
|
value=b'legacy bytes',
|
||||||
|
)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||||
|
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
canonical_storage = SimpleNamespace(value=b'adopted bytes')
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(),
|
||||||
|
make_result(canonical_storage),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||||
|
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
|
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_error_when_not_found(self, app):
|
async def test_returns_error_when_not_found(self, app):
|
||||||
"""Missing binary storage rows return an error response."""
|
"""Missing binary storage rows return an error response."""
|
||||||
@@ -567,21 +671,47 @@ class TestDeleteAndListBinaryStorage:
|
|||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
assert 'workspace-a' in statement_params.values()
|
flat_values = [
|
||||||
|
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||||
|
]
|
||||||
|
assert 'workspace-a' in flat_values
|
||||||
assert (
|
assert (
|
||||||
canonical_binary_key(
|
canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
'test-author/test-plugin',
|
'test-author/test-plugin',
|
||||||
'test-key',
|
'test-key',
|
||||||
)
|
)
|
||||||
in statement_params.values()
|
in flat_values
|
||||||
)
|
)
|
||||||
assert 'forged-owner' not in statement_params.values()
|
assert 'forged-owner' not in flat_values
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
|
||||||
|
{
|
||||||
|
'key': 'test-key',
|
||||||
|
'owner_type': 'plugin',
|
||||||
|
'owner': 'forged-owner',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
|
values = [
|
||||||
|
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||||
|
]
|
||||||
|
assert 'workspace-a' in values
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
|
||||||
|
assert 'plugin:test-author/test-plugin:test-key' in values
|
||||||
|
assert 'test-author/test-plugin' in values
|
||||||
|
assert 'forged-owner' not in values
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||||
result = Mock()
|
result = Mock()
|
||||||
result.scalars.return_value.all.return_value = ['first', 'second']
|
result.scalars.return_value.all.return_value = ['first', 'second', 'first']
|
||||||
app.persistence_mgr.execute_async.return_value = result
|
app.persistence_mgr.execute_async.return_value = result
|
||||||
runtime_handler = make_handler(app)
|
runtime_handler = make_handler(app)
|
||||||
|
|
||||||
|
|||||||
@@ -2125,7 +2125,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.5.3" },
|
{ name = "langbot-plugin", specifier = "==0.5.5" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2191,7 +2191,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.5.3"
|
version = "0.5.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2212,9 +2212,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/55/1d/a54daa3bc699f5186b9970946c2ecf0e9cf219f77738934e04aaf5a0c20a/langbot_plugin-0.5.3.tar.gz", hash = "sha256:2324b1f7e1f55e3692e75c8b1e427ea497474b0150ec6ca83b49b5d77ec224c6", size = 472149, upload-time = "2026-08-13T10:17:45.529Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/5f/ae6ed59773cc9d941fbb28b4ac07ce2a29e689d693e29ad71c40c5b39aa5/langbot_plugin-0.5.3-py3-none-any.whl", hash = "sha256:75dea1b6fb79ec6087ec3284f6698fb701d5feebf5f0318a7ae51fbd17a2f41f", size = 304559, upload-time = "2026-08-13T10:17:44.385Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user