diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2b5defb82..552a104dc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -158,6 +158,7 @@ In this repo: - `pkg/plugin/handler.py` exposes LangBot actions to the runtime and calls runtime actions for plugin operations. - `pkg/provider/tools/loaders/plugin.py` exposes plugin Tool components to LLM runners. - Pipeline handlers emit SDK events such as normal-message events and prompt-processing events. +- [Certified plugin policy](docs/architecture/certified-plugins.md) defines Core's archive-fact, admission, and tenant-log-visibility boundary; the SDK remains responsible for certificate verification. In `langbot-plugin-sdk`: diff --git a/docs/architecture/certified-plugins.md b/docs/architecture/certified-plugins.md new file mode 100644 index 000000000..f4c9400ac --- /dev/null +++ b/docs/architecture/certified-plugins.md @@ -0,0 +1,74 @@ +# Certified Plugins + +## Admission boundary + +Core verifies a plugin archive **before** artifact storage, `PluginSetting` +persistence, or a Plugin Runtime apply request. It calls the SDK public +`langbot_plugin.certification.verify_archive()` API, which reads the strict +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. + +## Trusted issuer configuration + +Configure the non-secret Ed25519 public-key ring in `data/config.yaml`: + +```yaml +plugin: + certification: + trusted_public_keys: + issuer-2026-q3: "" +``` + +Key IDs must match the SDK envelope. Values are standard base64 raw public +keys, not private/signing keys. An invalid key-ring configuration is rejected +rather than weakening verification. Keep active issuer keys during a rotation +until archives signed by retired IDs are no longer installed. + +## Admission matrix + +| Deployment | SDK verification | Explicit `administrator_force` | Result | +| --- | --- | --- | --- | +| Cloud | valid envelope declaring `shared-runtime-v1` | any | admitted to the shared profile | +| Cloud | absent | any | reject before storage with `CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_REQUIRED` | +| Cloud | malformed, untrusted, invalid, or non-shared | any | reject before storage with `CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID` | +| OSS | absent legacy envelope | any | admitted to the dedicated profile | +| OSS | valid envelope declaring `shared-runtime-v1` | any | selected shared profile | +| OSS | malformed or invalid declaration | false | reject with `CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED` | +| OSS | malformed or invalid declaration | true | admitted to the dedicated profile | + +`administrator_force` is deliberately strict: it is recognized only when the +install request carries boolean `true`. The local upload endpoint accepts the +multipart field `administrator_force=true`; GitHub and marketplace install +payloads carry the same field. The existing resource-manage authorization fence +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. + +The existing public plugin-log boundary already applies the immutable +installation binding (including workspace UUID) through +`RuntimeConnectionHandler.installation_scope()` before requesting logs. This is +the actual tenant exposure boundary, so valid shared certificates use that +binding-scoped transport; Core does not invent a second log stream or expose +process-wide log output. Dedicated and invalid/legacy installations use the +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. diff --git a/pyproject.toml b/pyproject.toml index b8880a6b6..2914a6ec6 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.5.8", + "langbot-plugin==0.6.0b5", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 77d710693..9c048bd71 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -923,10 +923,13 @@ class PluginsRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'file is required') file_bytes = file.read() + form = await quart.request.form + administrator_force = form.get('administrator_force', '').strip().lower() == 'true' execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) data = { 'plugin_file': file_bytes, + 'administrator_force': administrator_force, } ctx = taskmgr.TaskContext.new() diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index e34c740bd..3d62d85d9 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -42,6 +42,7 @@ _RUNTIME_POLICY_DEFAULTS = { }, 'plugin': { 'connect_timeout_seconds': 180.0, + 'certification': {'trusted_public_keys': {}}, 'worker': { 'max_cpus': 1.0, 'max_memory_mb': 512, diff --git a/src/langbot/pkg/plugin/archive.py b/src/langbot/pkg/plugin/archive.py index f3a8511a4..28b23cbff 100644 --- a/src/langbot/pkg/plugin/archive.py +++ b/src/langbot/pkg/plugin/archive.py @@ -1,7 +1,10 @@ from __future__ import annotations +import hashlib import io import zipfile +from dataclasses import dataclass +from enum import Enum import yaml @@ -14,6 +17,34 @@ _PLUGIN_METADATA_MAX_BYTES = 1024 * 1024 _PLUGIN_REQUIREMENTS_MAX_ENTRIES = 1000 +class ArchiveCertificateState(str, Enum): + """Syntactic certificate declaration state; this is not verification.""" + + ABSENT = 'absent' + MALFORMED = 'malformed' + DECLARED = 'declared' + + +@dataclass(frozen=True) +class ArchiveCertificateDeclaration: + """Bounded, verifier-facing certificate declaration from ``manifest.yaml``.""" + + state: ArchiveCertificateState + runtime_profile: str | None = None + payload: dict[str, object] | None = None + + +@dataclass(frozen=True) +class PluginArchiveInspection: + """Validated archive metadata plus unverified certificate declaration facts.""" + + manifest: dict + requirements: list[str] + names: list[str] + artifact_digest: str + certificate: ArchiveCertificateDeclaration + + def _read_plugin_archive_member( archive: zipfile.ZipFile, member: zipfile.ZipInfo, @@ -29,12 +60,30 @@ def _read_plugin_archive_member( return content -def inspect_plugin_archive_metadata( - file_bytes: bytes, - *, - require_manifest: bool = True, -) -> tuple[dict, list[str], list[str]]: - """Validate archive size metadata and read only bounded preview fields.""" +def _inspect_certificate_declaration(manifest: dict) -> ArchiveCertificateDeclaration: + declaration = manifest.get('certification') + if declaration is None: + return ArchiveCertificateDeclaration(ArchiveCertificateState.ABSENT) + if not isinstance(declaration, dict): + return ArchiveCertificateDeclaration(ArchiveCertificateState.MALFORMED) + + runtime_profile = declaration.get('runtime_profile') + payload = declaration.get('certificate') + if not isinstance(runtime_profile, str) or not runtime_profile.strip() or not isinstance(payload, dict): + return ArchiveCertificateDeclaration(ArchiveCertificateState.MALFORMED) + return ArchiveCertificateDeclaration( + ArchiveCertificateState.DECLARED, + runtime_profile=runtime_profile, + payload=payload, + ) + + +def inspect_plugin_archive(file_bytes: bytes, *, require_manifest: bool = True) -> PluginArchiveInspection: + """Validate an archive and expose certificate declaration facts for a verifier. + + Certificate signatures and issuer trust are deliberately not evaluated here; + callers must pass the declaration and artifact digest to an SDK verifier. + """ with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive: members = archive.infolist() @@ -95,4 +144,25 @@ def inspect_plugin_archive_metadata( for line in content.splitlines() if line.strip() and not line.strip().startswith('#') ][:_PLUGIN_REQUIREMENTS_MAX_ENTRIES] - return manifest, requirements, names + + return PluginArchiveInspection( + manifest=manifest, + requirements=requirements, + names=names, + artifact_digest=hashlib.sha256(file_bytes).hexdigest(), + certificate=_inspect_certificate_declaration(manifest), + ) + + +def inspect_plugin_archive_metadata( + file_bytes: bytes, + *, + require_manifest: bool = True, +) -> tuple[dict, list[str], list[str]]: + """Legacy tuple API for archive metadata callers. + + Use ``inspect_plugin_archive`` when certificate declaration facts are needed. + """ + + inspection = inspect_plugin_archive(file_bytes, require_manifest=require_manifest) + return inspection.manifest, inspection.requirements, inspection.names diff --git a/src/langbot/pkg/plugin/certification.py b/src/langbot/pkg/plugin/certification.py new file mode 100644 index 000000000..d1a233847 --- /dev/null +++ b/src/langbot/pkg/plugin/certification.py @@ -0,0 +1,234 @@ +"""Pure certified-plugin facts and admission policies. + +This module intentionally does not verify signatures. An SDK-backed verifier +must produce ``CertificateFacts`` from an inspected archive before admission. +""" + +from __future__ import annotations + +import base64 +from collections.abc import Callable, Mapping +from dataclasses import dataclass +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 + + +SHARED_RUNTIME_V1 = 'shared-runtime-v1' +DEDICATED_RUNTIME = 'dedicated' + + +class CertificateVerification(str, Enum): + ABSENT = 'absent' + MALFORMED = 'malformed' + INVALID = 'invalid' + VALID = 'valid' + + +class DeploymentMode(str, Enum): + CLOUD = 'cloud' + OSS = 'oss' + + +class AdmissionDisposition(str, Enum): + SHARED_ELIGIBLE = 'shared_eligible' + DEDICATED_ALLOWED = 'dedicated_allowed' + REJECTED = 'rejected' + ADMINISTRATOR_FORCE_REQUIRED = 'administrator_force_required' + + +class AdmissionCode(str, Enum): + SHARED_ELIGIBLE = 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE' + CLOUD_CERTIFICATE_REQUIRED = 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_REQUIRED' + CLOUD_CERTIFICATE_INVALID = 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID' + OSS_LEGACY_DEDICATED = 'CERTIFIED_PLUGIN_OSS_LEGACY_DEDICATED' + OSS_FORCE_REQUIRED = 'CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED' + OSS_FORCED_DEDICATED = 'CERTIFIED_PLUGIN_OSS_FORCED_DEDICATED' + OSS_CERTIFIED_DEDICATED = 'CERTIFIED_PLUGIN_OSS_CERTIFIED_DEDICATED' + + +class PluginLogVisibility(str, Enum): + TENANT_SCOPED = 'tenant_scoped' + DETAILED_PROCESS = 'detailed_process' + + +@dataclass(frozen=True) +class CertificateFacts: + """Certificate result supplied by an archive verifier. + + ``VALID`` means the verifier has validated both the certificate and its + binding to the immutable artifact digest in ``PluginCertificationFacts``. + """ + + verification: CertificateVerification + runtime_profile: str | None = None + certificate_id: str | None = None + + @property + def is_valid_shared_runtime(self) -> bool: + return self.verification is CertificateVerification.VALID and self.runtime_profile == SHARED_RUNTIME_V1 + + @property + def is_declared(self) -> bool: + return self.verification is not CertificateVerification.ABSENT + + +@dataclass(frozen=True) +class PluginCertificationFacts: + """Immutable Core-side facts for one plugin installation artifact.""" + + installation_uuid: str + artifact_digest: str + certificate: CertificateFacts + + def __post_init__(self) -> None: + if len(self.artifact_digest) != 64 or any( + character not in '0123456789abcdef' for character in self.artifact_digest.lower() + ): + raise ValueError('artifact_digest must be a lowercase-or-uppercase SHA-256 hex digest') + + +@dataclass(frozen=True) +class VerifiedArchiveCertificate: + """SDK verification facts bound to the comment-normalized ZIP digest.""" + + normalized_digest: str + certificate: CertificateFacts + + def for_installation(self, installation_uuid: str) -> PluginCertificationFacts: + return PluginCertificationFacts( + installation_uuid=installation_uuid, + artifact_digest=self.normalized_digest, + certificate=self.certificate, + ) + + +def trusted_public_key_ring(config: object) -> dict[str, Callable[[bytes, bytes], bool]]: + """Build the non-secret Ed25519 verifier ring from instance configuration. + + ``plugin.certification.trusted_public_keys`` is a mapping of key IDs to + standard base64-encoded 32-byte Ed25519 public keys. Configuration errors + are explicit so an operator never silently gets a weaker trust policy. + """ + + if config is None: + return {} + if not isinstance(config, Mapping): + raise ValueError('plugin.certification.trusted_public_keys must be a mapping') + + ring: dict[str, Callable[[bytes, bytes], bool]] = {} + for raw_key_id, raw_public_key in config.items(): + key_id = str(raw_key_id).strip() + if not key_id or not isinstance(raw_public_key, str): + raise ValueError('plugin.certification.trusted_public_keys entries must have string IDs and values') + try: + public_key_bytes = base64.b64decode(raw_public_key.encode('ascii'), validate=True) + public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) + except (UnicodeEncodeError, ValueError) as exc: + raise ValueError(f'plugin.certification trusted public key {key_id!r} is invalid') from exc + + def verify(payload: bytes, signature: bytes, *, verifier: Ed25519PublicKey = public_key) -> bool: + try: + verifier.verify(signature, payload) + except (InvalidSignature, TypeError, ValueError): + return False + return True + + ring[key_id] = verify + return ring + + +def verify_plugin_archive_certificate( + archive: bytes, + *, + trusted_public_keys: object, +) -> VerifiedArchiveCertificate: + """Use the SDK ZIP-comment API and retain its normalized-digest binding.""" + + verification = verify_archive(archive, trusted_public_key_ring(trusted_public_keys).get) + envelope = verification.envelope + runtime_profile = envelope.shared_runtime if envelope is not None else None + certificate_id = envelope.key_id if envelope is not None else None + state = { + 'absent': CertificateVerification.ABSENT, + 'malformed': CertificateVerification.MALFORMED, + 'valid': CertificateVerification.VALID, + }.get(verification.status, CertificateVerification.INVALID) + return VerifiedArchiveCertificate( + normalized_digest=normalized_zip_digest(archive), + certificate=CertificateFacts( + verification=state, + runtime_profile=runtime_profile, + certificate_id=certificate_id, + ), + ) + + +@dataclass(frozen=True) +class PluginAdmissionDecision: + disposition: AdmissionDisposition + code: AdmissionCode + runtime_profile: str + + +def decide_plugin_admission( + *, + deployment: DeploymentMode | str, + facts: PluginCertificationFacts, + administrator_force: bool = False, +) -> PluginAdmissionDecision: + """Apply Cloud fail-closed and OSS administrator-force admission rules.""" + + mode = DeploymentMode(deployment) + certificate = facts.certificate + if certificate.is_valid_shared_runtime: + return PluginAdmissionDecision( + AdmissionDisposition.SHARED_ELIGIBLE, + AdmissionCode.SHARED_ELIGIBLE, + SHARED_RUNTIME_V1, + ) + + if mode is DeploymentMode.CLOUD: + code = ( + AdmissionCode.CLOUD_CERTIFICATE_REQUIRED + if certificate.verification is CertificateVerification.ABSENT + else AdmissionCode.CLOUD_CERTIFICATE_INVALID + ) + return PluginAdmissionDecision(AdmissionDisposition.REJECTED, code, DEDICATED_RUNTIME) + + if certificate.verification is CertificateVerification.ABSENT: + return PluginAdmissionDecision( + AdmissionDisposition.DEDICATED_ALLOWED, + AdmissionCode.OSS_LEGACY_DEDICATED, + DEDICATED_RUNTIME, + ) + + if certificate.verification is CertificateVerification.VALID: + return PluginAdmissionDecision( + AdmissionDisposition.DEDICATED_ALLOWED, + AdmissionCode.OSS_CERTIFIED_DEDICATED, + DEDICATED_RUNTIME, + ) + + if administrator_force: + return PluginAdmissionDecision( + AdmissionDisposition.DEDICATED_ALLOWED, + AdmissionCode.OSS_FORCED_DEDICATED, + DEDICATED_RUNTIME, + ) + + return PluginAdmissionDecision( + AdmissionDisposition.ADMINISTRATOR_FORCE_REQUIRED, + AdmissionCode.OSS_FORCE_REQUIRED, + DEDICATED_RUNTIME, + ) + + +def decide_plugin_log_visibility(facts: PluginCertificationFacts) -> PluginLogVisibility: + """Select the minimum log visibility compatible with a verified shared runtime.""" + + if facts.certificate.is_valid_shared_runtime: + return PluginLogVisibility.TENANT_SCOPED + return PluginLogVisibility.DETAILED_PROCESS diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 7df06aefe..3648d3fce 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -22,6 +22,13 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session from ..core import app from . import handler from .archive import inspect_plugin_archive_metadata +from .certification import ( + AdmissionDisposition, + PluginCertificationFacts, + VerifiedArchiveCertificate, + decide_plugin_admission, + verify_plugin_archive_certificate, +) from .github import ( validate_github_plugin_install_info, validate_github_release_asset_url, @@ -1683,6 +1690,45 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): ) return plugin_package, latest_version + def _admit_plugin_archive( + self, + file_bytes: bytes, + install_info: dict[str, Any], + ) -> tuple[dict[str, Any], VerifiedArchiveCertificate]: + """Verify and admit one archive before it can reach durable storage or Runtime.""" + + certification_config = self.ap.instance_config.data.get('plugin', {}).get('certification', {}) + if not isinstance(certification_config, dict): + raise ValueError('plugin.certification must be a mapping') + verified = verify_plugin_archive_certificate( + file_bytes, + trusted_public_keys=certification_config.get('trusted_public_keys', {}), + ) + facts = PluginCertificationFacts( + installation_uuid='pending-installation', + artifact_digest=verified.normalized_digest, + certificate=verified.certificate, + ) + decision = decide_plugin_admission( + deployment=getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss'), + facts=facts, + administrator_force=install_info.get('administrator_force') is True, + ) + if decision.disposition not in { + AdmissionDisposition.DEDICATED_ALLOWED, + AdmissionDisposition.SHARED_ELIGIBLE, + }: + raise ValueError(decision.code.value) + certification_info = { + 'normalized_digest': facts.artifact_digest, + 'verification': facts.certificate.verification.value, + 'certificate_runtime_profile': facts.certificate.runtime_profile, + 'certificate_id': facts.certificate.certificate_id, + 'runtime_profile': decision.runtime_profile, + 'admission_code': decision.code.value, + } + return {**install_info, '_certification': certification_info}, verified + async def install_plugin( self, install_source: PluginInstallSource, @@ -1719,6 +1765,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): else: raise ValueError(f'Unsupported plugin install source: {install_source.value}') + install_info, verified_certificate = self._admit_plugin_archive(file_bytes, install_info) manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context) if not manifest_author or not manifest_name: raise ValueError('Plugin package manifest identity is missing') @@ -1749,6 +1796,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): plugin_author=plugin_author, plugin_name=plugin_name, ) + certification_facts = verified_certificate.for_installation(binding.installation_uuid) + if certification_facts.artifact_digest != install_info['_certification']['normalized_digest']: + raise RuntimeError('Plugin certification digest changed before Runtime apply') await self._apply_desired_state( PluginInstallationDesiredState(binding=binding, enabled=True), artifact_package=file_bytes, diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 24efaba09..df1828216 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -261,6 +261,12 @@ plugin: runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' enable_marketplace: true display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws' + certification: + # Non-secret Ed25519 issuer key ring used to verify the SDK ZIP-comment + # certification envelope. Values are standard base64-encoded raw public + # keys; add keys during issuer rotation and remove retired IDs only after + # every affected archive has been upgraded. + trusted_public_keys: {} worker: # Instance-wide maximum for every plugin installation. Plugin # manifests cannot raise or override these limits. diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py index 5f79f716a..52442e371 100644 --- a/tests/integration/api/test_plugins_security.py +++ b/tests/integration/api/test_plugins_security.py @@ -3,11 +3,13 @@ from __future__ import annotations import copy +import io from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, call import pytest import quart +from quart.datastructures import FileStorage pytestmark = pytest.mark.integration @@ -287,3 +289,34 @@ async def test_github_install_rejects_internal_asset_url_before_task_creation( assert response.status_code == 400 assert 'HTTPS GitHub release asset URL' in (await response.get_json())['msg'] application.task_mgr.create_user_task.assert_not_called() + + +@pytest.mark.asyncio +async def test_local_install_forwards_explicit_administrator_force(plugin_security_api): + application, client, _ = plugin_security_api + execution_context = SimpleNamespace( + instance_uuid='instance-test', + workspace_uuid=WORKSPACE_UUID, + placement_generation=1, + ) + application.persistence_mgr.tenant_scope = None + application.plugin_connector.require_workspace_context = AsyncMock(return_value=execution_context) + application.plugin_connector.install_plugin = AsyncMock() + application.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id='task-certification')) + + response = await client.post( + '/api/v1/plugins/install/local', + headers=_headers('manager-token'), + files={ + 'file': FileStorage(stream=io.BytesIO(b'archive'), filename='plugin.lbpkg'), + }, + form={'administrator_force': 'true'}, + ) + + assert response.status_code == 200 + operation = application.task_mgr.create_user_task.call_args.args[0] + await operation + assert application.plugin_connector.install_plugin.await_args.args[1] == { + 'plugin_file': b'archive', + 'administrator_force': True, + } diff --git a/tests/integration/plugin/test_certified_plugin_admission.py b/tests/integration/plugin/test_certified_plugin_admission.py new file mode 100644 index 000000000..330e59e06 --- /dev/null +++ b/tests/integration/plugin/test_certified_plugin_admission.py @@ -0,0 +1,169 @@ +"""Certified archive admission through the public Core installation API.""" + +from __future__ import annotations + +import base64 +import hashlib +import io +import zipfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import yaml +from cryptography.hazmat.primitives import serialization +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.runtime.plugin.mgr import PluginInstallSource + + +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('deployment', 'archive_kind', 'administrator_force', 'expected_profile'), + [ + ('cloud', 'signed_shared', False, 'shared-runtime-v1'), + ('oss', 'signed_shared', False, 'shared-runtime-v1'), + ('oss', 'legacy', False, 'dedicated'), + ('oss', 'invalid_shared', True, 'dedicated'), + ], +) +async def test_install_plugin_admits_archive_before_persistence_and_applies_selected_profile( + deployment: str, + archive_kind: str, + administrator_force: bool, + expected_profile: str, +) -> None: + package, trusted_public_keys = _archive(archive_kind) + connector, execution_context, binding = _connector(deployment, trusted_public_keys) + + await connector.install_plugin( + PluginInstallSource.LOCAL, + { + 'plugin_file': package, + 'administrator_force': administrator_force, + }, + ) + + connector._store_artifact_package.assert_awaited_once_with( + execution_context, + hashlib.sha256(package).hexdigest(), + package, + ) + persisted_info = connector._persist_installation_package.await_args.kwargs['install_info'] + assert persisted_info['_certification']['runtime_profile'] == expected_profile + assert persisted_info['_certification']['normalized_digest'] == _normalized_digest(package) + connector.handler.apply_plugin_installation.assert_awaited_once_with( + binding, + artifact_package=package, + enabled=True, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('archive_kind', ['legacy', 'invalid_shared']) +async def test_cloud_rejects_untrusted_archive_before_storage_persistence_or_runtime_apply(archive_kind: str) -> None: + package, trusted_public_keys = _archive(archive_kind) + connector, _execution_context, _binding = _connector('cloud', trusted_public_keys) + + with pytest.raises(ValueError, match='CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_'): + await connector.install_plugin(PluginInstallSource.LOCAL, {'plugin_file': package}) + + connector._store_artifact_package.assert_not_awaited() + connector._persist_installation_package.assert_not_awaited() + connector.handler.apply_plugin_installation.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_oss_requires_explicit_administrator_force_for_declared_invalid_archive() -> None: + package, trusted_public_keys = _archive('invalid_shared') + connector, _execution_context, _binding = _connector('oss', trusted_public_keys) + + with pytest.raises(ValueError, match='CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED'): + await connector.install_plugin(PluginInstallSource.LOCAL, {'plugin_file': package}) + + connector._store_artifact_package.assert_not_awaited() + connector._persist_installation_package.assert_not_awaited() + connector.handler.apply_plugin_installation.assert_not_awaited() + + +def _connector(deployment: str, trusted_public_keys: dict[str, str]): + package_digest = 'a' * 64 + execution_context = ExecutionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + ) + binding = InstallationBinding( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + installation_uuid='00000000-0000-4000-8000-000000000001', + runtime_revision=1, + artifact_digest=package_digest, + ) + app = SimpleNamespace( + instance_config=SimpleNamespace( + data={ + 'plugin': { + 'enable': True, + 'certification': {'trusted_public_keys': trusted_public_keys}, + } + } + ), + deployment=SimpleNamespace(mode=deployment), + logger=Mock(), + ) + connector = PluginRuntimeConnector(app, AsyncMock()) + connector.handler = SimpleNamespace( + register_installation_binding=Mock(), + apply_plugin_installation=AsyncMock(return_value={'state': 'running'}), + ) + connector._current_execution_context = AsyncMock(return_value=execution_context) + connector._store_artifact_package = AsyncMock() + connector._persist_installation_package = AsyncMock(return_value=(binding, None, False)) + connector._wait_for_installed_plugin_ready = AsyncMock() + return connector, execution_context, binding + + +def _archive(kind: str) -> tuple[bytes, dict[str, str]]: + manifest = { + 'metadata': {'author': 'certified', 'name': 'example', 'version': '1.0.0'}, + 'execution': {'sharedRuntime': 'shared-runtime-v1'}, + } + if kind == 'legacy': + manifest.pop('execution') + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w') as package: + package.writestr('manifest.yaml', yaml.safe_dump(manifest)) + raw_archive = archive.getvalue() + if kind == 'legacy': + return raw_archive, {} + + from langbot_plugin.certification import create_envelope, write_envelope + + signing_key = Ed25519PrivateKey.generate() + signed_archive = write_envelope( + raw_archive, + create_envelope( + raw_archive, + 'wrong-key' if kind == 'invalid_shared' else 'ephemeral', + signing_key.sign, + ), + ) + trusted_key = signing_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + return signed_archive, {'ephemeral': base64.b64encode(trusted_key).decode('ascii')} + + +def _normalized_digest(archive: bytes) -> str: + from langbot_plugin.certification import normalized_zip_digest + + return normalized_zip_digest(archive) diff --git a/tests/unit_tests/plugin/test_certified_plugin_policy.py b/tests/unit_tests/plugin/test_certified_plugin_policy.py new file mode 100644 index 000000000..521d0380a --- /dev/null +++ b/tests/unit_tests/plugin/test_certified_plugin_policy.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import hashlib +import io +import zipfile + +import pytest + + +@pytest.mark.parametrize( + ('deployment', 'certificate', 'force', 'expected_disposition', 'expected_code'), + [ + ('cloud', ('valid', 'shared-runtime-v1'), False, 'shared_eligible', 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE'), + ('cloud', ('absent', None), False, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_REQUIRED'), + ('cloud', ('malformed', None), False, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID'), + ('cloud', ('invalid', 'shared-runtime-v1'), True, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID'), + ('oss', ('absent', None), False, 'dedicated_allowed', 'CERTIFIED_PLUGIN_OSS_LEGACY_DEDICATED'), + ('oss', ('valid', 'shared-runtime-v1'), False, 'shared_eligible', 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE'), + ('oss', ('invalid', 'shared-runtime-v1'), False, 'administrator_force_required', 'CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED'), + ('oss', ('invalid', 'shared-runtime-v1'), True, 'dedicated_allowed', 'CERTIFIED_PLUGIN_OSS_FORCED_DEDICATED'), + ], +) +def test_admission_policy_enforces_certification_matrix( + deployment: str, + certificate: tuple[str, str | None], + force: bool, + expected_disposition: str, + expected_code: str, +) -> None: + from langbot.pkg.plugin.certification import ( + CertificateFacts, + CertificateVerification, + PluginCertificationFacts, + decide_plugin_admission, + ) + + verification, runtime_profile = certificate + facts = PluginCertificationFacts( + installation_uuid='00000000-0000-4000-8000-000000000001', + artifact_digest='a' * 64, + certificate=CertificateFacts( + verification=CertificateVerification(verification), + runtime_profile=runtime_profile, + ), + ) + + decision = decide_plugin_admission( + deployment=deployment, + facts=facts, + administrator_force=force, + ) + + assert decision.disposition.value == expected_disposition + assert decision.code.value == expected_code + assert decision.runtime_profile == ( + 'shared-runtime-v1' if expected_disposition == 'shared_eligible' else 'dedicated' + ) + + +def test_archive_inspection_preserves_legacy_tuple_and_exposes_certificate_facts() -> None: + from langbot.pkg.plugin.archive import ( + ArchiveCertificateState, + inspect_plugin_archive, + inspect_plugin_archive_metadata, + ) + + manifest = { + 'kind': 'Plugin', + 'metadata': {'name': 'example'}, + 'certification': { + 'runtime_profile': 'shared-runtime-v1', + 'certificate': {'issuer': 'sdk-test', 'signature': 'not-verified-by-core'}, + }, + } + archive_bytes = _archive_bytes(manifest) + + inspection = inspect_plugin_archive(archive_bytes) + + assert inspection.artifact_digest == hashlib.sha256(archive_bytes).hexdigest() + assert inspection.certificate.state is ArchiveCertificateState.DECLARED + assert inspection.certificate.runtime_profile == 'shared-runtime-v1' + assert inspection.certificate.payload == manifest['certification']['certificate'] + assert inspect_plugin_archive_metadata(archive_bytes) == ( + inspection.manifest, + inspection.requirements, + inspection.names, + ) + + +@pytest.mark.parametrize( + ('certification', 'expected_state'), + [ + (None, 'absent'), + ({'runtime_profile': 123, 'certificate': {}}, 'malformed'), + ({'runtime_profile': 'shared-runtime-v1'}, 'malformed'), + ], +) +def test_archive_inspection_reports_nonverifying_certificate_states( + certification: object, + expected_state: str, +) -> None: + from langbot.pkg.plugin.archive import inspect_plugin_archive + + manifest: dict[str, object] = {'kind': 'Plugin', 'metadata': {'name': 'example'}} + if certification is not None: + manifest['certification'] = certification + + inspection = inspect_plugin_archive(_archive_bytes(manifest)) + + assert inspection.certificate.state.value == expected_state + + +@pytest.mark.parametrize( + ('verification', 'expected_visibility'), + [ + ('valid', 'tenant_scoped'), + ('absent', 'detailed_process'), + ('malformed', 'detailed_process'), + ('invalid', 'detailed_process'), + ], +) +def test_log_visibility_policy_only_scopes_valid_shared_certifications( + verification: str, + expected_visibility: str, +) -> None: + from langbot.pkg.plugin.certification import ( + CertificateFacts, + CertificateVerification, + PluginCertificationFacts, + decide_plugin_log_visibility, + ) + + facts = PluginCertificationFacts( + installation_uuid='00000000-0000-4000-8000-000000000001', + artifact_digest='a' * 64, + certificate=CertificateFacts( + verification=CertificateVerification(verification), + runtime_profile='shared-runtime-v1', + ), + ) + + visibility = decide_plugin_log_visibility(facts) + + assert visibility.value == expected_visibility + + +def _archive_bytes(manifest: dict[str, object]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w') as archive: + import yaml + + archive.writestr('manifest.yaml', yaml.safe_dump(manifest)) + return buffer.getvalue() diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index d4d9b6b5b..f1c2ac4cb 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -272,6 +272,12 @@ async def test_local_install_persists_verified_package_before_runtime_apply(): connector._inspect_plugin_package = Mock(return_value=('author', 'plugin')) connector._store_artifact_package = AsyncMock() connector._persist_installation_package = AsyncMock(return_value=(binding, None, False)) + connector._admit_plugin_archive = Mock( + return_value=( + {'_certification': {'normalized_digest': digest}}, + SimpleNamespace(for_installation=lambda _installation_uuid: SimpleNamespace(artifact_digest=digest)), + ) + ) connector._wait_for_installed_plugin_ready = AsyncMock() await connector.install_plugin( diff --git a/uv.lock b/uv.lock index 107889d6b..e323b1763 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]] @@ -2180,7 +2180,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.5.8" }, + { name = "langbot-plugin", specifier = "==0.6.0b5" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2250,7 +2250,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.8" +version = "0.6.0b5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2271,9 +2271,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/ab/8d8bd6b8355c5b30b4aab2b5322fd28d8f36158f36d6b4ee33f4df4bc861/langbot_plugin-0.5.8.tar.gz", hash = "sha256:46fbdf948f4a2d110607738ab35633c9ab22a30784edce3a4e684cd19bab84ff", size = 487972, upload-time = "2026-09-11T09:27:58.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/27/c23c5bab4137e755096f7ad32afcec834cb9b2e08129beb8bf797c1b8c5b/langbot_plugin-0.6.0b5.tar.gz", hash = "sha256:8cd1024ec1a8a131b8afe48dd3ff6c002626d6ab885ab99b8078985e4a8f53be", size = 613773, upload-time = "2026-09-20T10:18:18.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/13/4939205e2f7922ec09113e390e35f9355ce6d93e1b380a4b3c49441130f5/langbot_plugin-0.5.8-py3-none-any.whl", hash = "sha256:4fbbcfa55f1dcb9af8392b48de8b7877ea79c880dfd268d651404702614d182e", size = 311552, upload-time = "2026-09-11T09:27:57.082Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b0c7c009e166650d82bcb58784e89a64ec04de5156f95da7fb7d59d2c4a3/langbot_plugin-0.6.0b5-py3-none-any.whl", hash = "sha256:15c5a60765db34e5a0216eab205178fb06c6447d286f648008aa89207e75d667", size = 408564, upload-time = "2026-09-20T10:18:16.967Z" }, ] [[package]] @@ -3301,7 +3301,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" }, @@ -3340,7 +3340,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" }, @@ -3352,7 +3352,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" }, @@ -3382,9 +3382,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" }, @@ -3396,7 +3396,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" }, @@ -4489,7 +4489,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" }, @@ -5257,10 +5257,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 = [ @@ -5307,7 +5307,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 = [ @@ -5378,14 +5378,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 = [ @@ -5758,21 +5758,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" }, @@ -5814,15 +5814,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 = [ @@ -6083,9 +6083,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 = [