mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-23 01:46:37 +08:00
feat(plugin): enforce certified archive admission (#2553)
* feat(plugin): add certified admission policy * feat(plugin): enforce certified archive admission * chore(plugin): pin certified SDK beta * fix(plugin): consume SDK beta 5 * style(plugin): format certification admission
This commit is contained in:
@@ -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/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.
|
- `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.
|
- 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`:
|
In `langbot-plugin-sdk`:
|
||||||
|
|
||||||
|
|||||||
@@ -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: "<base64 encoded 32-byte Ed25519 public key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
+1
-1
@@ -71,7 +71,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"langbot-plugin==0.5.8",
|
"langbot-plugin==0.6.0b5",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -923,10 +923,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(400, -1, 'file is required')
|
return self.http_status(400, -1, 'file is required')
|
||||||
|
|
||||||
file_bytes = file.read()
|
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)
|
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'plugin_file': file_bytes,
|
'plugin_file': file_bytes,
|
||||||
|
'administrator_force': administrator_force,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ _RUNTIME_POLICY_DEFAULTS = {
|
|||||||
},
|
},
|
||||||
'plugin': {
|
'plugin': {
|
||||||
'connect_timeout_seconds': 180.0,
|
'connect_timeout_seconds': 180.0,
|
||||||
|
'certification': {'trusted_public_keys': {}},
|
||||||
'worker': {
|
'worker': {
|
||||||
'max_cpus': 1.0,
|
'max_cpus': 1.0,
|
||||||
'max_memory_mb': 512,
|
'max_memory_mb': 512,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -14,6 +17,34 @@ _PLUGIN_METADATA_MAX_BYTES = 1024 * 1024
|
|||||||
_PLUGIN_REQUIREMENTS_MAX_ENTRIES = 1000
|
_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(
|
def _read_plugin_archive_member(
|
||||||
archive: zipfile.ZipFile,
|
archive: zipfile.ZipFile,
|
||||||
member: zipfile.ZipInfo,
|
member: zipfile.ZipInfo,
|
||||||
@@ -29,12 +60,30 @@ def _read_plugin_archive_member(
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
def inspect_plugin_archive_metadata(
|
def _inspect_certificate_declaration(manifest: dict) -> ArchiveCertificateDeclaration:
|
||||||
file_bytes: bytes,
|
declaration = manifest.get('certification')
|
||||||
*,
|
if declaration is None:
|
||||||
require_manifest: bool = True,
|
return ArchiveCertificateDeclaration(ArchiveCertificateState.ABSENT)
|
||||||
) -> tuple[dict, list[str], list[str]]:
|
if not isinstance(declaration, dict):
|
||||||
"""Validate archive size metadata and read only bounded preview fields."""
|
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:
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive:
|
||||||
members = archive.infolist()
|
members = archive.infolist()
|
||||||
@@ -95,4 +144,25 @@ def inspect_plugin_archive_metadata(
|
|||||||
for line in content.splitlines()
|
for line in content.splitlines()
|
||||||
if line.strip() and not line.strip().startswith('#')
|
if line.strip() and not line.strip().startswith('#')
|
||||||
][:_PLUGIN_REQUIREMENTS_MAX_ENTRIES]
|
][:_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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -22,6 +22,13 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
|||||||
from ..core import app
|
from ..core import app
|
||||||
from . import handler
|
from . import handler
|
||||||
from .archive import inspect_plugin_archive_metadata
|
from .archive import inspect_plugin_archive_metadata
|
||||||
|
from .certification import (
|
||||||
|
AdmissionDisposition,
|
||||||
|
PluginCertificationFacts,
|
||||||
|
VerifiedArchiveCertificate,
|
||||||
|
decide_plugin_admission,
|
||||||
|
verify_plugin_archive_certificate,
|
||||||
|
)
|
||||||
from .github import (
|
from .github import (
|
||||||
validate_github_plugin_install_info,
|
validate_github_plugin_install_info,
|
||||||
validate_github_release_asset_url,
|
validate_github_release_asset_url,
|
||||||
@@ -1683,6 +1690,45 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
)
|
)
|
||||||
return plugin_package, latest_version
|
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(
|
async def install_plugin(
|
||||||
self,
|
self,
|
||||||
install_source: PluginInstallSource,
|
install_source: PluginInstallSource,
|
||||||
@@ -1719,6 +1765,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
|
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)
|
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if not manifest_author or not manifest_name:
|
if not manifest_author or not manifest_name:
|
||||||
raise ValueError('Plugin package manifest identity is missing')
|
raise ValueError('Plugin package manifest identity is missing')
|
||||||
@@ -1749,6 +1796,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
plugin_author=plugin_author,
|
plugin_author=plugin_author,
|
||||||
plugin_name=plugin_name,
|
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(
|
await self._apply_desired_state(
|
||||||
PluginInstallationDesiredState(binding=binding, enabled=True),
|
PluginInstallationDesiredState(binding=binding, enabled=True),
|
||||||
artifact_package=file_bytes,
|
artifact_package=file_bytes,
|
||||||
|
|||||||
@@ -261,6 +261,12 @@ plugin:
|
|||||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||||
enable_marketplace: true
|
enable_marketplace: true
|
||||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
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:
|
worker:
|
||||||
# Instance-wide maximum for every plugin installation. Plugin
|
# Instance-wide maximum for every plugin installation. Plugin
|
||||||
# manifests cannot raise or override these limits.
|
# manifests cannot raise or override these limits.
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import io
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock, call
|
from unittest.mock import AsyncMock, Mock, call
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import quart
|
import quart
|
||||||
|
from quart.datastructures import FileStorage
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
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 response.status_code == 400
|
||||||
assert 'HTTPS GitHub release asset URL' in (await response.get_json())['msg']
|
assert 'HTTPS GitHub release asset URL' in (await response.get_json())['msg']
|
||||||
application.task_mgr.create_user_task.assert_not_called()
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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._inspect_plugin_package = Mock(return_value=('author', 'plugin'))
|
||||||
connector._store_artifact_package = AsyncMock()
|
connector._store_artifact_package = AsyncMock()
|
||||||
connector._persist_installation_package = AsyncMock(return_value=(binding, None, False))
|
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()
|
connector._wait_for_installed_plugin_ready = AsyncMock()
|
||||||
|
|
||||||
await connector.install_plugin(
|
await connector.install_plugin(
|
||||||
|
|||||||
@@ -1066,7 +1066,7 @@ name = "cuda-bindings"
|
|||||||
version = "13.3.1"
|
version = "13.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cuda-pathfinder" },
|
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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]
|
[package.optional-dependencies]
|
||||||
cudart = [
|
cudart = [
|
||||||
{ name = "nvidia-cuda-runtime" },
|
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cufft = [
|
cufft = [
|
||||||
{ name = "nvidia-cufft" },
|
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cufile = [
|
cufile = [
|
||||||
{ name = "nvidia-cufile" },
|
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cupti = [
|
cupti = [
|
||||||
{ name = "nvidia-cuda-cupti" },
|
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
curand = [
|
curand = [
|
||||||
{ name = "nvidia-curand" },
|
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cusolver = [
|
cusolver = [
|
||||||
{ name = "nvidia-cusolver" },
|
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cusparse = [
|
cusparse = [
|
||||||
{ name = "nvidia-cusparse" },
|
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvjitlink = [
|
nvjitlink = [
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvrtc = [
|
nvrtc = [
|
||||||
{ name = "nvidia-cuda-nvrtc" },
|
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvtx = [
|
nvtx = [
|
||||||
{ name = "nvidia-nvtx" },
|
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2180,7 +2180,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.5.8" },
|
{ name = "langbot-plugin", specifier = "==0.6.0b5" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2250,7 +2250,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.5.8"
|
version = "0.6.0b5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2271,9 +2271,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -3301,7 +3301,7 @@ name = "nvidia-cublas"
|
|||||||
version = "13.1.1.3"
|
version = "13.1.1.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cuda-nvrtc" },
|
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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"
|
version = "9.20.0.48"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas" },
|
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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"
|
version = "12.0.0.61"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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"
|
version = "12.0.4.66"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas" },
|
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "nvidia-cusparse" },
|
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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"
|
version = "12.6.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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"
|
version = "1.4.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pymysql" },
|
{ name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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"
|
version = "1.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "joblib" },
|
{ name = "joblib", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "scipy" },
|
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "threadpoolctl" },
|
{ 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" }
|
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 = [
|
wheels = [
|
||||||
@@ -5307,7 +5307,7 @@ name = "scipy"
|
|||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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" }
|
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 = [
|
wheels = [
|
||||||
@@ -5378,14 +5378,14 @@ name = "sentence-transformers"
|
|||||||
version = "5.2.3"
|
version = "5.2.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "huggingface-hub" },
|
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "scikit-learn" },
|
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "scipy" },
|
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "torch" },
|
{ name = "torch", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "transformers" },
|
{ name = "transformers", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "typing-extensions" },
|
{ 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" }
|
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 = [
|
wheels = [
|
||||||
@@ -5758,21 +5758,21 @@ name = "torch"
|
|||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
|
{ 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 = "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" },
|
{ name = "filelock", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "fsspec" },
|
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "networkx" },
|
{ name = "networkx", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "setuptools" },
|
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "sympy" },
|
{ name = "sympy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "triton", marker = "sys_platform == 'linux'" },
|
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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"
|
version = "5.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "huggingface-hub" },
|
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "regex" },
|
{ name = "regex", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "safetensors" },
|
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "tokenizers" },
|
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "typer" },
|
{ 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" }
|
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 = [
|
wheels = [
|
||||||
@@ -6083,9 +6083,9 @@ name = "valkey-glide"
|
|||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "sniffio" },
|
{ 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" }
|
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 = [
|
wheels = [
|
||||||
|
|||||||
Reference in New Issue
Block a user