diff --git a/docs/architecture/certified-plugins.md b/docs/architecture/certified-plugins.md index 0f0b119d5..d30d11747 100644 --- a/docs/architecture/certified-plugins.md +++ b/docs/architecture/certified-plugins.md @@ -8,11 +8,12 @@ persistence, or a Plugin Runtime apply request. It calls the SDK public certificate envelope from the ZIP comment and verifies the signed normalized ZIP digest without extracting the payload. -Core retains the normalized digest (`normalized_zip_digest()`), verification -state, declared shared-runtime profile, key ID, selected admission profile, and -stable admission code in the durable plugin `install_info._certification` -record. The record belongs to the installation row; no schema migration is -needed for this additive JSON metadata. +Core retains the artifact SHA-256, normalized digest +(`normalized_zip_digest()`), verification state, declared shared-runtime +profile, key ID, selected admission profile, and stable admission code in the +durable plugin `install_info._certification` record. The record belongs to the +installation row; no schema migration is needed for this additive JSON +metadata. ## Trusted issuer configuration @@ -80,13 +81,14 @@ protects those endpoints. A force never creates a Cloud dedicated fallback. ## Runtime and logs -The current Plugin Runtime control protocol has one process-wide runtime profile -per Core instance. In Cloud that existing profile is `shared`; Cloud admission -therefore prevents an archive that did not select `shared-runtime-v1` from -reaching its apply API. In OSS the existing `oss_dev` runtime remains the -dedicated compatibility profile. Core records the selected profile for every -installation so a future multi-runtime control protocol can consume it without -re-verifying an already persisted archive. +SDK 0.6.2 carries an installation-level execution mode in both apply and +authoritative reconcile payloads. Core selects `shared-runtime-v1` only when +the persisted certification record says verification was valid, both the +certificate and admission profiles are `shared-runtime-v1`, the admission code +is shared-eligible, and the record's artifact SHA-256 exactly matches the +installation row. Missing, malformed, stale, invalid, or dedicated admission +facts select `dedicated`. Install, upgrade, configuration revision, restart, +and reconnect all use this same persisted-fact derivation. The existing public plugin-log boundary already applies the immutable installation binding (including workspace UUID) through @@ -98,7 +100,5 @@ same existing installation scope. ## SDK versioning -Core intentionally continues to declare `langbot-plugin==0.5.8` until the SDK -beta containing this public certification API is released. Local development -and the integration tests may install the SDK source checkout, but this Core -change does not publish or pin a prerelease. +Core pins `langbot-plugin==0.6.2`, the first published SDK release carrying the +canonical installation execution-mode contract. diff --git a/pyproject.toml b/pyproject.toml index fd9c7a177..fbbf55421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,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.6.1", + "langbot-plugin==0.6.11", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/src/langbot/pkg/persistence/alembic/versions/0032_certification_artifact_digest_backfill.py b/src/langbot/pkg/persistence/alembic/versions/0032_certification_artifact_digest_backfill.py new file mode 100644 index 000000000..1dc4e613f --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0032_certification_artifact_digest_backfill.py @@ -0,0 +1,138 @@ +"""Backfill raw artifact digests for legacy shared certificates. + +Revision ID: 0032_cert_artifact_digest +Revises: 0031_merge_totp_assistant + +Older certified-plugin rows persisted every shared-admission fact except the raw +archive digest. Placement now requires that digest to match the installation's +immutable artifact digest, so this migration fills only records whose remaining +facts already prove the exact legacy shared-admission shape. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +import sqlalchemy as sa +from alembic import op + +revision = '0032_cert_artifact_digest' +down_revision = '0031_merge_totp_assistant' +branch_labels = None +depends_on = None + +_TABLE = 'plugin_settings' +_SHARED_RUNTIME = 'shared-runtime-v1' +_SHARED_ADMISSION_CODE = 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE' +_LOWER_HEX = frozenset('0123456789abcdef') +_HEX = frozenset('0123456789abcdefABCDEF') + + +def _is_digest(value: object, *, lowercase: bool) -> bool: + allowed = _LOWER_HEX if lowercase else _HEX + return isinstance(value, str) and len(value) == 64 and all(character in allowed for character in value) + + +def _eligible_certification(install_info: object, artifact_digest: object) -> Mapping[object, object] | None: + if not _is_digest(artifact_digest, lowercase=True) or not isinstance(install_info, Mapping): + return None + certification = install_info.get('_certification') + if not isinstance(certification, Mapping) or 'artifact_digest' in certification: + return None + certificate_id = certification.get('certificate_id') + if not isinstance(certificate_id, str) or not certificate_id.strip(): + return None + if not _is_digest(certification.get('normalized_digest'), lowercase=False): + return None + required_facts = { + 'verification': 'valid', + 'certificate_runtime_profile': _SHARED_RUNTIME, + 'runtime_profile': _SHARED_RUNTIME, + 'admission_code': _SHARED_ADMISSION_CODE, + } + if not all(certification.get(key) == value for key, value in required_facts.items()): + return None + return certification + + +def _suspend_postgres_rls(conn: sa.Connection) -> tuple[bool, bool]: + if conn.dialect.name != 'postgresql': + return False, False + state = conn.execute( + sa.text('SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = to_regclass(:table_name)'), + {'table_name': _TABLE}, + ).one() + rls_enabled, rls_forced = bool(state.relrowsecurity), bool(state.relforcerowsecurity) + if rls_forced: + conn.execute(sa.text(f'ALTER TABLE {_TABLE} NO FORCE ROW LEVEL SECURITY')) + if rls_enabled: + conn.execute(sa.text(f'ALTER TABLE {_TABLE} DISABLE ROW LEVEL SECURITY')) + return rls_enabled, rls_forced + + +def _restore_postgres_rls(conn: sa.Connection, state: tuple[bool, bool]) -> None: + if conn.dialect.name != 'postgresql': + return + rls_enabled, rls_forced = state + if rls_enabled: + conn.execute(sa.text(f'ALTER TABLE {_TABLE} ENABLE ROW LEVEL SECURITY')) + if rls_forced: + conn.execute(sa.text(f'ALTER TABLE {_TABLE} FORCE ROW LEVEL SECURITY')) + + +def backfill_certification_artifact_digests(conn: sa.Connection) -> None: + inspector = sa.inspect(conn) + if _TABLE not in inspector.get_table_names(): + return + columns = {column['name'] for column in inspector.get_columns(_TABLE)} + required_columns = { + 'workspace_uuid', + 'plugin_author', + 'plugin_name', + 'artifact_digest', + 'install_info', + } + if not required_columns <= columns: + return + + plugin_settings = sa.table( + _TABLE, + sa.column('workspace_uuid', sa.String(36)), + sa.column('plugin_author', sa.String(255)), + sa.column('plugin_name', sa.String(255)), + sa.column('artifact_digest', sa.String(64)), + sa.column('install_info', sa.JSON()), + ) + rows = conn.execute(sa.select(plugin_settings)).mappings().all() + for row in rows: + certification = _eligible_certification(row['install_info'], row['artifact_digest']) + if certification is None: + continue + updated_certification = dict(certification) + updated_certification['artifact_digest'] = row['artifact_digest'] + updated_install_info = dict(row['install_info']) + updated_install_info['_certification'] = updated_certification + conn.execute( + plugin_settings.update() + .where(plugin_settings.c.workspace_uuid == row['workspace_uuid']) + .where(plugin_settings.c.plugin_author == row['plugin_author']) + .where(plugin_settings.c.plugin_name == row['plugin_name']) + .values(install_info=updated_install_info) + ) + + +def upgrade() -> None: + conn = op.get_bind() + if _TABLE not in sa.inspect(conn).get_table_names(): + return + rls_state = _suspend_postgres_rls(conn) + try: + backfill_certification_artifact_digests(conn) + finally: + _restore_postgres_rls(conn, rls_state) + + +def downgrade() -> None: + # The source digest cannot be distinguished from a digest persisted by + # current Core, so downgrade intentionally preserves the safe enrichment. + pass diff --git a/src/langbot/pkg/plugin/certification.py b/src/langbot/pkg/plugin/certification.py index 1263da518..6a538980b 100644 --- a/src/langbot/pkg/plugin/certification.py +++ b/src/langbot/pkg/plugin/certification.py @@ -14,6 +14,7 @@ from enum import Enum from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from langbot_plugin.certification import normalized_zip_digest, verify_archive +from langbot_plugin.entities.io.context import PluginExecutionMode SHARED_RUNTIME_V1 = 'shared-runtime-v1' @@ -270,3 +271,37 @@ def decide_plugin_log_visibility(facts: PluginCertificationFacts) -> PluginLogVi if facts.certificate.is_valid_shared_runtime: return PluginLogVisibility.TENANT_SCOPED return PluginLogVisibility.DETAILED_PROCESS + + +def execution_mode_for_persisted_installation( + *, + artifact_digest: str, + install_info: object, +) -> PluginExecutionMode: + """Derive placement only from persisted facts bound to the exact artifact.""" + + if len(artifact_digest) != 64 or any(character not in '0123456789abcdef' for character in artifact_digest.lower()): + return PluginExecutionMode.DEDICATED + if not isinstance(install_info, Mapping): + return PluginExecutionMode.DEDICATED + certification = install_info.get('_certification') + if not isinstance(certification, Mapping): + return PluginExecutionMode.DEDICATED + normalized_digest = certification.get('normalized_digest') + if not isinstance(normalized_digest, str) or len(normalized_digest) != 64: + return PluginExecutionMode.DEDICATED + if any(character not in '0123456789abcdef' for character in normalized_digest.lower()): + return PluginExecutionMode.DEDICATED + certificate_id = certification.get('certificate_id') + if not isinstance(certificate_id, str) or not certificate_id.strip(): + return PluginExecutionMode.DEDICATED + shared_facts = { + 'artifact_digest': artifact_digest, + 'verification': CertificateVerification.VALID.value, + 'certificate_runtime_profile': SHARED_RUNTIME_V1, + 'runtime_profile': SHARED_RUNTIME_V1, + 'admission_code': AdmissionCode.SHARED_ELIGIBLE.value, + } + if all(certification.get(key) == value for key, value in shared_facts.items()): + return PluginExecutionMode.SHARED_CERTIFIED + return PluginExecutionMode.DEDICATED diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 47b54c20f..e9675d147 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -34,6 +34,7 @@ from .certification import ( PluginCertificationFacts, VerifiedArchiveCertificate, decide_plugin_admission, + execution_mode_for_persisted_installation, verify_plugin_archive_certificate, ) from .github import ( @@ -62,6 +63,7 @@ from langbot_plugin.runtime.security import ( ) from langbot_plugin.entities.io.context import ( InstallationBinding, + PluginExecutionMode, PluginInstallationDesiredState, PluginWorkerPolicy, RuntimeIdentity, @@ -343,6 +345,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): artifact_digest=setting.artifact_digest, ) + @staticmethod + def _execution_mode_from_setting(setting: persistence_plugin.PluginSetting) -> PluginExecutionMode: + return execution_mode_for_persisted_installation( + artifact_digest=setting.artifact_digest, + install_info=setting.install_info, + ) + def _legacy_oss_bridge_binding(self, execution_context: ExecutionContext) -> InstallationBinding: seed = f'langbot:oss-plugin-bridge:{execution_context.instance_uuid}:{execution_context.workspace_uuid}' return InstallationBinding( @@ -511,6 +520,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): PluginInstallationDesiredState( binding=binding, enabled=setting.enabled, + execution_mode=self._execution_mode_from_setting(setting), ) ) return tuple(desired_states) @@ -526,6 +536,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): desired.binding, artifact_package=artifact_package, enabled=desired.enabled, + execution_mode=desired.execution_mode, ) self._raise_apply_failure(desired, result) if result.get('state') != 'artifact_missing': @@ -551,6 +562,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): desired.binding, artifact_package=persisted_package, enabled=desired.enabled, + execution_mode=desired.execution_mode, ) self._raise_apply_failure(desired, repaired) if repaired.get('state') == 'artifact_missing': @@ -1793,6 +1805,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): }: raise ValueError(decision.code.value) certification_info = { + 'artifact_digest': hashlib.sha256(file_bytes).hexdigest(), 'normalized_digest': facts.artifact_digest, 'verification': facts.certificate.verification.value, 'certificate_runtime_profile': facts.certificate.runtime_profile, @@ -1893,7 +1906,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): install_info=install_info, artifact_digest=artifact_digest, ) - desired = PluginInstallationDesiredState(binding=binding, enabled=True) + desired = PluginInstallationDesiredState( + binding=binding, + enabled=True, + execution_mode=execution_mode_for_persisted_installation( + artifact_digest=artifact_digest, + install_info=install_info, + ), + ) runtime_handler.register_installation_binding( binding, plugin_author=plugin_author, @@ -2143,6 +2163,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): desired = PluginInstallationDesiredState( binding=binding, enabled=setting.enabled, + execution_mode=self._execution_mode_from_setting(setting), ) is_legacy_oss = self.runtime_profile == 'oss_dev' and ( not isinstance(setting.install_info, dict) diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 90e9e9a17..9df92e778 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -24,6 +24,7 @@ from langbot_plugin.entities.io.context import ( ActionContext, ApplyPluginInstallationRequest, InstallationBinding, + PluginExecutionMode, PluginInstallationDesiredState, PluginWorkerPolicy, ReconcilePluginInstallationsRequest, @@ -2793,6 +2794,7 @@ class RuntimeConnectionHandler(handler.Handler): *, artifact_package: bytes | None, enabled: bool, + execution_mode: PluginExecutionMode = PluginExecutionMode.DEDICATED, ) -> dict[str, Any]: with self.installation_scope(binding): artifact_file_key = None @@ -2801,6 +2803,7 @@ class RuntimeConnectionHandler(handler.Handler): request = ApplyPluginInstallationRequest( artifact_file_key=artifact_file_key, enabled=enabled, + execution_mode=execution_mode, ) return await self.call_action( LangBotToRuntimeAction.APPLY_PLUGIN_INSTALLATION, diff --git a/tests/integration/persistence/test_migration_branch_convergence.py b/tests/integration/persistence/test_migration_branch_convergence.py index 5b03caa29..759b46b03 100644 --- a/tests/integration/persistence/test_migration_branch_convergence.py +++ b/tests/integration/persistence/test_migration_branch_convergence.py @@ -16,6 +16,7 @@ import uuid import pytest import sqlalchemy as sa from sqlalchemy.ext.asyncio import create_async_engine +from langbot_plugin.entities.io.context import PluginExecutionMode from langbot.pkg.entity import persistence from langbot.pkg.entity.persistence.base import Base @@ -25,6 +26,7 @@ from langbot.pkg.persistence.alembic_runner import ( run_alembic_downgrade, run_alembic_upgrade, ) +from langbot.pkg.plugin.certification import execution_mode_for_persisted_installation from langbot.pkg.utils import importutil @@ -353,6 +355,143 @@ async def test_empty_database_startup_schema_then_real_migrations(convergence_en assert await get_alembic_current(engine) == get_alembic_head() +def _legacy_shared_certification(**overrides): + certification = { + 'normalized_digest': 'B' * 64, + 'verification': 'valid', + 'certificate_runtime_profile': 'shared-runtime-v1', + 'certificate_id': 'ed25519:trusted-issuer', + 'runtime_profile': 'shared-runtime-v1', + 'admission_code': 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE', + 'preserved_certificate_key': {'nested': True}, + } + certification.update(overrides) + return certification + + +@pytest.mark.asyncio +async def test_certification_artifact_digest_backfill_is_safe_and_enables_shared_placement(convergence_engine): + engine = convergence_engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await run_alembic_upgrade(engine, '0031_merge_totp_assistant') + + valid_digest = 'a' * 64 + rows = { + 'eligible-a': (_legacy_shared_certification(), valid_digest), + 'eligible-b': (_legacy_shared_certification(), valid_digest), + 'present-matching': (_legacy_shared_certification(artifact_digest=valid_digest), valid_digest), + 'present-mismatched': (_legacy_shared_certification(artifact_digest='c' * 64), valid_digest), + 'invalid': (_legacy_shared_certification(verification='invalid'), valid_digest), + 'incomplete': (_legacy_shared_certification(certificate_id=' '), valid_digest), + 'missing-fact': ( + { + key: value + for key, value in _legacy_shared_certification().items() + if key != 'certificate_runtime_profile' + }, + valid_digest, + ), + 'malformed-normalized': (_legacy_shared_certification(normalized_digest='g' * 64), valid_digest), + 'dedicated-certificate': ( + _legacy_shared_certification(certificate_runtime_profile='dedicated'), + valid_digest, + ), + 'dedicated': (_legacy_shared_certification(runtime_profile='dedicated'), valid_digest), + 'wrong-admission': (_legacy_shared_certification(admission_code='SHARED_ELIGIBLE'), valid_digest), + 'uppercase-row-digest': (_legacy_shared_certification(), 'A' * 64), + 'nonhex-row-digest': (_legacy_shared_certification(), 'g' * 64), + } + plugin_settings = Base.metadata.tables['plugin_settings'] + workspaces = Base.metadata.tables['workspaces'] + async with engine.begin() as conn: + for index, (name, (certification, artifact_digest)) in enumerate(rows.items(), start=1): + workspace_uuid = f'41100000-0000-4000-8000-{index:012d}' + await conn.execute( + workspaces.insert().values( + uuid=workspace_uuid, + instance_uuid=f'cert-backfill-{index}', + name=name, + slug=name, + ) + ) + await conn.execute( + plugin_settings.insert().values( + workspace_uuid=workspace_uuid, + plugin_author='langbot', + plugin_name=name, + installation_uuid=f'51100000-0000-4000-8000-{index:012d}', + artifact_digest=artifact_digest, + runtime_revision=1, + install_info={ + '_certification': certification, + 'preserved_install_key': ['keep', {'nested': True}], + }, + ) + ) + + await run_alembic_upgrade(engine, 'head') + # Re-running the data revision itself must also be harmless. + migration = __import__( + 'langbot.pkg.persistence.alembic.versions.0032_certification_artifact_digest_backfill', + fromlist=['upgrade'], + ) + async with engine.begin() as conn: + await conn.run_sync(lambda sync: migration.backfill_certification_artifact_digests(sync)) + stored = { + name: (artifact_digest, install_info) + for name, artifact_digest, install_info in ( + await conn.execute( + sa.select( + plugin_settings.c.plugin_name, + plugin_settings.c.artifact_digest, + plugin_settings.c.install_info, + ) + ) + ).all() + } + + for name in ('eligible-a', 'eligible-b'): + eligible_digest, eligible_info = stored[name] + assert eligible_info['preserved_install_key'] == ['keep', {'nested': True}] + assert eligible_info['_certification']['preserved_certificate_key'] == {'nested': True} + assert eligible_info['_certification']['artifact_digest'] == eligible_digest == valid_digest + assert ( + execution_mode_for_persisted_installation( + artifact_digest=eligible_digest, + install_info=eligible_info, + ) + is PluginExecutionMode.SHARED_CERTIFIED + ) + + for name, (original_certification, artifact_digest) in rows.items(): + if name in {'eligible-a', 'eligible-b'}: + continue + stored_digest, stored_info = stored[name] + assert stored_digest == artifact_digest + assert stored_info['_certification'] == original_certification + if name != 'present-matching': + assert ( + execution_mode_for_persisted_installation( + artifact_digest=stored_digest, + install_info=stored_info, + ) + is PluginExecutionMode.DEDICATED + ) + + +@pytest.mark.asyncio +async def test_certification_artifact_digest_backfill_accepts_fresh_current_schema(convergence_engine): + engine = convergence_engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await run_alembic_upgrade(engine, 'head') + await run_alembic_upgrade(engine, 'head') + + assert await get_alembic_current(engine) == get_alembic_head() + + @pytest.mark.asyncio async def test_merge_only_downgrade_preserves_both_branch_schemas(convergence_engine): engine = convergence_engine diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index db3625848..babe2c207 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -329,6 +329,76 @@ class TestPostgreSQLMigrationBaseline: rev = await get_alembic_current(postgres_engine) assert rev == '0001_baseline' + @pytest.mark.asyncio + async def test_certification_artifact_digest_backfill_updates_only_complete_legacy_shared_rows( + self, + postgres_engine, + clean_tables, + clean_alembic_version, + ): + async with postgres_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await run_alembic_stamp(postgres_engine, '0031_merge_totp_assistant') + + plugin_settings = Base.metadata.tables['plugin_settings'] + workspaces = Base.metadata.tables['workspaces'] + digest = 'a' * 64 + complete = { + 'normalized_digest': 'B' * 64, + 'verification': 'valid', + 'certificate_runtime_profile': 'shared-runtime-v1', + 'certificate_id': 'ed25519:trusted-issuer', + 'runtime_profile': 'shared-runtime-v1', + 'admission_code': 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE', + 'preserved': {'key': True}, + } + rows = { + 'eligible': complete, + 'invalid': {**complete, 'verification': 'invalid'}, + 'present': {**complete, 'artifact_digest': 'c' * 64}, + } + async with postgres_engine.begin() as conn: + for index, (name, certification) in enumerate(rows.items(), start=1): + workspace_uuid = f'61100000-0000-4000-8000-{index:012d}' + await conn.execute( + workspaces.insert().values( + uuid=workspace_uuid, + instance_uuid=f'postgres-cert-backfill-{index}', + name=name, + slug=name, + ) + ) + await conn.execute( + plugin_settings.insert().values( + workspace_uuid=workspace_uuid, + plugin_author='langbot', + plugin_name=name, + installation_uuid=f'71100000-0000-4000-8000-{index:012d}', + artifact_digest=digest, + runtime_revision=1, + install_info={'_certification': certification, 'preserved': ['install-info']}, + ) + ) + + await run_alembic_upgrade(postgres_engine, 'head') + + async with postgres_engine.connect() as conn: + stored = dict( + (await conn.execute(sa.select(plugin_settings.c.plugin_name, plugin_settings.c.install_info))).all() + ) + assert stored['eligible'] == { + '_certification': {**complete, 'artifact_digest': digest}, + 'preserved': ['install-info'], + } + assert stored['invalid'] == { + '_certification': rows['invalid'], + 'preserved': ['install-info'], + } + assert stored['present'] == { + '_certification': rows['present'], + 'preserved': ['install-info'], + } + @pytest.mark.asyncio async def test_fresh_postgres_schema_accepts_application_casefold_identity( self, diff --git a/tests/integration/plugin/test_certified_plugin_admission.py b/tests/integration/plugin/test_certified_plugin_admission.py index 0a7fb92ff..0a5126ad9 100644 --- a/tests/integration/plugin/test_certified_plugin_admission.py +++ b/tests/integration/plugin/test_certified_plugin_admission.py @@ -17,6 +17,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.plugin.connector import PluginRuntimeConnector from langbot_plugin.entities.io.context import InstallationBinding +from langbot_plugin.entities.io.context import PluginExecutionMode from langbot_plugin.runtime.plugin.mgr import PluginInstallSource @@ -57,11 +58,19 @@ async def test_install_plugin_admits_archive_before_persistence_and_applies_sele ) persisted_info = connector._persist_installation_package.await_args.kwargs['install_info'] assert persisted_info['_certification']['runtime_profile'] == expected_profile + assert persisted_info['_certification']['artifact_digest'] == hashlib.sha256(package).hexdigest() assert persisted_info['_certification']['normalized_digest'] == _normalized_digest(package) + if archive_kind == 'signed_shared': + assert persisted_info['_certification']['certificate_id'] == 'ephemeral' connector.handler.apply_plugin_installation.assert_awaited_once_with( binding, artifact_package=package, enabled=True, + execution_mode=( + PluginExecutionMode.SHARED_CERTIFIED + if expected_profile == 'shared-runtime-v1' + else PluginExecutionMode.DEDICATED + ), ) @@ -124,6 +133,7 @@ async def test_oss_admits_unresolvable_declaration_on_the_dedicated_profile() -> binding, artifact_package=package, enabled=True, + execution_mode=PluginExecutionMode.DEDICATED, ) @@ -183,7 +193,10 @@ async def test_marketplace_version_selection_keeps_certificate_gate_and_single_a assert persisted_info['plugin_version'] == '1.0.0' assert persisted_info['_certification']['runtime_profile'] == 'shared-runtime-v1' connector.handler.apply_plugin_installation.assert_awaited_once_with( - binding, artifact_package=package, enabled=True + binding, + artifact_package=package, + enabled=True, + execution_mode=PluginExecutionMode.SHARED_CERTIFIED, ) connector._refresh_runner_registry.assert_awaited_once() assert task_context.metadata['progress_percent'] == 100 diff --git a/tests/unit_tests/plugin/test_certified_plugin_policy.py b/tests/unit_tests/plugin/test_certified_plugin_policy.py index 402dbc032..0f5d80da2 100644 --- a/tests/unit_tests/plugin/test_certified_plugin_policy.py +++ b/tests/unit_tests/plugin/test_certified_plugin_policy.py @@ -6,6 +6,8 @@ import zipfile import pytest +from langbot_plugin.entities.io.context import PluginExecutionMode + @pytest.mark.parametrize( ('deployment', 'certificate', 'certificate_id', 'force', 'expected_disposition', 'expected_code'), @@ -187,6 +189,162 @@ def test_log_visibility_policy_only_scopes_valid_shared_certifications( assert visibility.value == expected_visibility +def _complete_persisted_certification() -> dict[str, object]: + return { + 'artifact_digest': 'a' * 64, + 'normalized_digest': 'b' * 64, + 'verification': 'valid', + 'certificate_runtime_profile': 'shared-runtime-v1', + 'certificate_id': 'ed25519:trusted-issuer', + 'runtime_profile': 'shared-runtime-v1', + 'admission_code': 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE', + } + + +@pytest.mark.parametrize( + ('certification', 'expected_mode'), + [ + (_complete_persisted_certification(), PluginExecutionMode.SHARED_CERTIFIED), + (None, PluginExecutionMode.DEDICATED), + ({}, PluginExecutionMode.DEDICATED), + ( + { + **_complete_persisted_certification(), + 'verification': 'invalid', + }, + PluginExecutionMode.DEDICATED, + ), + ( + { + **_complete_persisted_certification(), + 'artifact_digest': 'b' * 64, + }, + PluginExecutionMode.DEDICATED, + ), + ( + { + **_complete_persisted_certification(), + 'runtime_profile': 'dedicated', + }, + PluginExecutionMode.DEDICATED, + ), + ( + { + **_complete_persisted_certification(), + 'admission_code': 'CERTIFIED_PLUGIN_OSS_FORCED_DEDICATED', + }, + PluginExecutionMode.DEDICATED, + ), + ], +) +def test_persisted_certification_selects_shared_execution_only_for_exact_admitted_artifact( + certification: dict[str, object] | None, + expected_mode: PluginExecutionMode, +) -> None: + from langbot.pkg.plugin.certification import execution_mode_for_persisted_installation + + install_info = {} if certification is None else {'_certification': certification} + + assert ( + execution_mode_for_persisted_installation( + artifact_digest='a' * 64, + install_info=install_info, + ) + is expected_mode + ) + + +@pytest.mark.parametrize( + 'missing_field', + [ + 'artifact_digest', + 'normalized_digest', + 'verification', + 'certificate_runtime_profile', + 'certificate_id', + 'runtime_profile', + 'admission_code', + ], +) +def test_persisted_certification_requires_every_shared_admission_fact(missing_field: str) -> None: + from langbot.pkg.plugin.certification import execution_mode_for_persisted_installation + + certification = _complete_persisted_certification() + del certification[missing_field] + + assert ( + execution_mode_for_persisted_installation( + artifact_digest='a' * 64, + install_info={'_certification': certification}, + ) + is PluginExecutionMode.DEDICATED + ) + + +@pytest.mark.parametrize( + ('field', 'malformed_value'), + [ + ('artifact_digest', None), + ('artifact_digest', 123), + ('artifact_digest', ''), + ('normalized_digest', None), + ('normalized_digest', 123), + ('normalized_digest', ''), + ('normalized_digest', ' ' * 64), + ('normalized_digest', 'b' * 63), + ('normalized_digest', 'g' * 64), + ('certificate_id', None), + ('certificate_id', 123), + ('certificate_id', ''), + ('certificate_id', ' '), + ('verification', None), + ('verification', 123), + ('verification', ''), + ('certificate_runtime_profile', None), + ('certificate_runtime_profile', 123), + ('certificate_runtime_profile', ''), + ('runtime_profile', None), + ('runtime_profile', 123), + ('runtime_profile', ''), + ('admission_code', None), + ('admission_code', 123), + ('admission_code', ''), + ], +) +def test_persisted_certification_rejects_malformed_shared_admission_fact( + field: str, + malformed_value: object, +) -> None: + from langbot.pkg.plugin.certification import execution_mode_for_persisted_installation + + certification = _complete_persisted_certification() + certification[field] = malformed_value + + assert ( + execution_mode_for_persisted_installation( + artifact_digest='a' * 64, + install_info={'_certification': certification}, + ) + is PluginExecutionMode.DEDICATED + ) + + +@pytest.mark.parametrize('malformed_digest', ['', 'a' * 63, 'g' * 64]) +def test_persisted_certification_rejects_malformed_matching_raw_digest(malformed_digest: str) -> None: + from langbot.pkg.plugin.certification import execution_mode_for_persisted_installation + + certification = _complete_persisted_certification() + certification['artifact_digest'] = malformed_digest + + assert ( + execution_mode_for_persisted_installation( + artifact_digest=malformed_digest, + install_info={'_certification': certification}, + ) + is PluginExecutionMode.DEDICATED + ) + + def _archive_bytes(manifest: dict[str, object]) -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, 'w') as archive: diff --git a/tests/unit_tests/plugin/test_connector_methods.py b/tests/unit_tests/plugin/test_connector_methods.py index 7ccd0b8a1..716b0f1bf 100644 --- a/tests/unit_tests/plugin/test_connector_methods.py +++ b/tests/unit_tests/plugin/test_connector_methods.py @@ -17,7 +17,7 @@ from unittest.mock import AsyncMock, Mock from importlib import import_module from tests.factories import text_query -from langbot_plugin.entities.io.context import InstallationBinding +from langbot_plugin.entities.io.context import InstallationBinding, PluginExecutionMode from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.workspace.errors import WorkspaceNotFoundError @@ -758,7 +758,18 @@ class TestSetPluginConfig: runtime_revision=1, artifact_digest=TEST_INSTALLATION_BINDING.artifact_digest, enabled=True, - install_info={'_artifact_storage': 'tenant_binary_storage_v1'}, + install_info={ + '_artifact_storage': 'tenant_binary_storage_v1', + '_certification': { + 'artifact_digest': TEST_INSTALLATION_BINDING.artifact_digest, + 'normalized_digest': 'b' * 64, + 'verification': 'valid', + 'certificate_runtime_profile': 'shared-runtime-v1', + 'certificate_id': 'ed25519:trusted-issuer', + 'runtime_profile': 'shared-runtime-v1', + 'admission_code': 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE', + }, + }, ) connector._setting_for_plugin = AsyncMock(return_value=(TEST_EXECUTION_CONTEXT, setting)) connector.ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=1)) @@ -777,6 +788,7 @@ class TestSetPluginConfig: applied_binding, artifact_package=None, enabled=True, + execution_mode=PluginExecutionMode.SHARED_CERTIFIED, ) diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index 2a7ea1e1e..fde178fa2 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -8,7 +8,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest -from langbot_plugin.entities.io.context import InstallationBinding +from langbot_plugin.entities.io.context import InstallationBinding, PluginExecutionMode from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from langbot.pkg.api.http.context import ExecutionContext @@ -45,7 +45,18 @@ def mock_archive_admission(connector: PluginRuntimeConnector, digest: str) -> No # is exercised by integration/plugin/test_certified_plugin_admission.py. connector._admit_plugin_archive = Mock( side_effect=lambda _package, info: ( - {**info, '_certification': {'normalized_digest': digest}}, + { + **info, + '_certification': { + 'artifact_digest': digest, + 'normalized_digest': digest, + 'verification': 'valid', + 'certificate_runtime_profile': 'shared-runtime-v1', + 'certificate_id': 'ed25519:trusted-issuer', + 'runtime_profile': 'shared-runtime-v1', + 'admission_code': 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE', + }, + }, SimpleNamespace(for_installation=lambda _uuid: SimpleNamespace(artifact_digest=digest)), ) ) @@ -56,6 +67,7 @@ def plugin_setting( artifact_digest: str, *, durable: bool = True, + certification: dict[str, str] | None = None, ) -> SimpleNamespace: return SimpleNamespace( plugin_author='author', @@ -67,7 +79,10 @@ def plugin_setting( priority=0, created_at=datetime.datetime(2026, 1, 1), install_source='local', - install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if durable else {}, + install_info={ + **({'_artifact_storage': 'tenant_binary_storage_v1'} if durable else {}), + **({'_certification': certification} if certification is not None else {}), + }, ) @@ -163,6 +178,45 @@ async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_proje assert set(connector._known_desired_states) == {setting_a.installation_uuid} +@pytest.mark.asyncio +async def test_reconcile_reload_projects_certified_exact_artifact_to_shared_execution(): + binding = execution_binding('workspace-a') + digest = 'a' * 64 + setting = plugin_setting( + '01', + digest, + certification={ + 'artifact_digest': digest, + 'normalized_digest': 'b' * 64, + 'verification': 'valid', + 'certificate_runtime_profile': 'shared-runtime-v1', + 'certificate_id': 'ed25519:trusted-issuer', + 'runtime_profile': 'shared-runtime-v1', + 'admission_code': 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE', + }, + ) + connector = shared_connector([[binding]], {'workspace-a': [setting]}) + connector.handler = runtime_handler() + + await connector._prepare_connected_runtime() + + desired = connector.handler.reconcile_plugin_installations.await_args.args[0][0] + assert desired.execution_mode is PluginExecutionMode.SHARED_CERTIFIED + + +@pytest.mark.asyncio +async def test_reconcile_reload_defaults_legacy_installation_to_dedicated_execution(): + binding = execution_binding('workspace-a') + setting = plugin_setting('01', 'a' * 64) + connector = shared_connector([[binding]], {'workspace-a': [setting]}) + connector.handler = runtime_handler() + + await connector._prepare_connected_runtime() + + desired = connector.handler.reconcile_plugin_installations.await_args.args[0][0] + assert desired.execution_mode is PluginExecutionMode.DEDICATED + + @pytest.mark.asyncio async def test_empty_projected_workspaces_do_not_retain_installation_sets(): binding_a = execution_binding('workspace-a') @@ -223,6 +277,7 @@ async def test_fresh_shared_runtime_cache_replays_persisted_local_package(): desired.binding, artifact_package=package, enabled=True, + execution_mode=PluginExecutionMode.DEDICATED, ) @@ -304,6 +359,7 @@ async def test_local_install_persists_verified_package_before_runtime_apply(): binding, artifact_package=package, enabled=True, + execution_mode=PluginExecutionMode.DEDICATED, ) @@ -396,6 +452,9 @@ async def test_marketplace_upgrade_reports_multistep_progress(): 'download_current': 0, 'download_speed': 0, } + assert connector.handler.apply_plugin_installation.await_args.kwargs['execution_mode'] is ( + PluginExecutionMode.SHARED_CERTIFIED + ) @pytest.mark.asyncio @@ -430,7 +489,13 @@ async def test_workspace_reads_do_not_wait_for_an_installation_apply(): connector._persist_installation_package = AsyncMock(return_value=(binding, None, False)) connector._wait_for_installed_plugin_ready = AsyncMock() connector._load_workspace_desired_states = AsyncMock( - return_value=[PluginInstallationDesiredState(binding=binding, enabled=True)] + return_value=[ + PluginInstallationDesiredState( + binding=binding, + enabled=True, + execution_mode=PluginExecutionMode.SHARED_CERTIFIED, + ) + ] ) apply_started = asyncio.Event() release_apply = asyncio.Event() diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index 860d16989..23be3490b 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -10,7 +10,12 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest from langbot_plugin.entities.io.actions.enums import LangBotToRuntimeAction, PluginToRuntimeAction -from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginInstallationDesiredState +from langbot_plugin.entities.io.context import ( + ActionContext, + InstallationBinding, + PluginExecutionMode, + PluginInstallationDesiredState, +) def make_handler(app): @@ -90,7 +95,25 @@ async def test_reconcile_plugin_installations_accepts_configured_cold_start_time await runtime_handler.reconcile_plugin_installations((desired,), timeout=900) - assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900 + assert runtime_handler.call_action.await_args.kwargs['timeout'] == 900 + + +@pytest.mark.asyncio +async def test_apply_plugin_installation_serializes_certified_shared_execution_mode(): + runtime_handler = make_handler(SimpleNamespace()) + runtime_handler.send_file = AsyncMock(return_value='artifact-file') + runtime_handler.call_action = AsyncMock(return_value={'state': 'starting'}) + binding = next(iter(runtime_handler._installation_bindings.values()))[0] + + await runtime_handler.apply_plugin_installation( + binding, + artifact_package=b'package', + enabled=True, + execution_mode=PluginExecutionMode.SHARED_CERTIFIED, + ) + + assert runtime_handler.call_action.await_args.args[0] == LangBotToRuntimeAction.APPLY_PLUGIN_INSTALLATION + assert runtime_handler.call_action.await_args.args[1]['execution_mode'] == 'shared-runtime-v1' class TestHandlerQueryVariables: diff --git a/uv.lock b/uv.lock index 1c261c4ef..1ddf4e82a 100644 --- a/uv.lock +++ b/uv.lock @@ -1066,7 +1066,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1099,34 +1099,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime" }, + { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufft = [ - { name = "nvidia-cufft" }, + { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufile = [ - { name = "nvidia-cufile" }, + { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cupti = [ - { name = "nvidia-cuda-cupti" }, + { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] curand = [ - { name = "nvidia-curand" }, + { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusolver = [ - { name = "nvidia-cusolver" }, + { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusparse = [ - { name = "nvidia-cusparse" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvtx = [ - { name = "nvidia-nvtx" }, + { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] [[package]] @@ -2185,7 +2185,7 @@ requires-dist = [ { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, - { name = "langbot-plugin", specifier = "==0.6.1" }, + { name = "langbot-plugin", specifier = "==0.6.11" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2255,7 +2255,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.6.1" +version = "0.6.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2276,9 +2276,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/70/ade1e2e71e666a80acfffde0fc753173e74b7a528ef495a1705f53c40ea8/langbot_plugin-0.6.1.tar.gz", hash = "sha256:214e415c2cd3c286f4b07cfbbe11e6b865839596ad500c2100944b00b3ba5f2b", size = 625357, upload-time = "2026-09-24T14:55:08.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/13/7f913f88494205abb1a6390746e88de4e1148fbf6d0a273bbe2450f5fb0c/langbot_plugin-0.6.11.tar.gz", hash = "sha256:2ecddc2ef3a2eb92925c08cf0f19887e9be573b93e7d5de49494c01c083cfac7", size = 655797, upload-time = "2026-09-26T18:15:19.81Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/06/4d8a889b0c28dcbf7ccf5ca40bcc7125ac9639942f7d05340768b28d159c/langbot_plugin-0.6.1-py3-none-any.whl", hash = "sha256:5607b4c787a92259b4a1f7beb4bdca9f371e37b316eaf8f316d2448e888da77e", size = 413828, upload-time = "2026-09-24T14:55:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/25/10/00e3d5d985d82aa08dd5f2f01aa2b85e8b46880dcfa2aa9b923f7be285da/langbot_plugin-0.6.11-py3-none-any.whl", hash = "sha256:85458919c943894780db85bbd96c0a61354e231bc396a3f74072a8eddc978b07", size = 427659, upload-time = "2026-09-26T18:15:18.003Z" }, ] [[package]] @@ -3306,7 +3306,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3345,7 +3345,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3357,7 +3357,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3387,9 +3387,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3401,7 +3401,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4494,7 +4494,7 @@ name = "pylibseekdb" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pymysql" }, + { name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" }, @@ -5262,10 +5262,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5312,7 +5312,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5383,14 +5383,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "torch", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "transformers", marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5772,21 +5772,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "triton", marker = "sys_platform == 'linux'" }, - { name = "typing-extensions" }, + { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "filelock", marker = "python_full_version >= '3.14'" }, + { name = "fsspec", marker = "python_full_version >= '3.14'" }, + { name = "jinja2", marker = "python_full_version >= '3.14'" }, + { name = "networkx", marker = "python_full_version >= '3.14'" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.14'" }, + { name = "sympy", marker = "python_full_version >= '3.14'" }, + { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5828,15 +5828,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "packaging", marker = "python_full_version >= '3.14'" }, + { name = "pyyaml", marker = "python_full_version >= '3.14'" }, + { name = "regex", marker = "python_full_version >= '3.14'" }, + { name = "safetensors", marker = "python_full_version >= '3.14'" }, + { name = "tokenizers", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "typer", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -6097,9 +6097,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "protobuf" }, - { name = "sniffio" }, + { name = "anyio", marker = "sys_platform != 'win32'" }, + { name = "protobuf", marker = "sys_platform != 'win32'" }, + { name = "sniffio", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [