Compare commits

...

9 Commits

Author SHA1 Message Date
Hyu 7803d56254 fix(plugin): keep pre-tenancy plugin storage readable (#2446)
* fix(plugin): adopt legacy scoped storage rows

* fix(plugin): delete adopted legacy storage rows

* test(plugin): cover legacy storage deletion

* fix(plugin): avoid mutating legacy storage on reads

* fix(plugin): make legacy storage adoption atomic

* fix(plugin): resolve concurrent legacy adoption

* fix(plugin): upsert concurrent storage writes

* fix(plugin): retry reads after legacy adoption

* fix(plugin): deduplicate migrated storage keys

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-17 11:49:26 +08:00
Hyu 54c96a18e1 test(migration): preserve legacy plugin storage payloads (#2444)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-17 10:31:03 +08:00
Hyu 579e3556e4 Merge pull request #2441 from langbot-app/fix/runtime-readonly-sdk-0.5.5
fix(runtime): adopt plugin SDK 0.5.5
2026-08-17 01:40:56 +08:00
dadachann a08a177a11 fix(runtime): adopt plugin SDK 0.5.5 2026-08-16 17:36:34 +00:00
Hyu c224f61c8c Merge pull request #2440 from langbot-app/fix/login-migration-20260817
fix(migrations): preserve legacy workspace ownership
2026-08-17 01:29:58 +08:00
dadachann b62cc9da45 fix(migrations): preserve legacy workspace ownership 2026-08-16 17:15:57 +00:00
Hyu 700104c015 Merge pull request #2439 from langbot-app/fix/recent-issues-20260817
fix recent migration and plugin command regressions
2026-08-17 01:04:41 +08:00
dadachann 717bd4b8bf fix(commands): pass trusted workspace scope to plugins 2026-08-16 17:01:02 +00:00
dadachann 0cc0e1b02d fix(migrations): retry backup reopen on bind mounts 2026-08-16 16:42:20 +00:00
12 changed files with 440 additions and 45 deletions
+1 -1
View File
@@ -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",
+9 -1
View File
@@ -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
+12 -1
View File
@@ -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:
+117 -13
View File
@@ -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()
+27
View File
@@ -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."""
+136 -6
View File
@@ -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)
Generated
+4 -4
View File
@@ -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]]