mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 20:35:22 +08:00
feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
d099033d79
commit
3a77306c3c
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate categorized plugin sources without importing plugin code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CATEGORIES = ('Runner', 'KnowledgeEngine', 'misc')
|
||||
REPOSITORY = 'https://github.com/langbot-app/LangBot/tree/master/plugins/'
|
||||
|
||||
|
||||
def check_catalog(root: Path = ROOT) -> list[dict]:
|
||||
manifests = sorted(p for category in CATEGORIES for p in (root / category).glob('*/manifest.yaml'))
|
||||
if not manifests:
|
||||
raise ValueError('No categorized plugin manifests found')
|
||||
plugins = []
|
||||
identities = set()
|
||||
for path in manifests:
|
||||
plugin = path.parent
|
||||
manifest = yaml.safe_load(path.read_text())
|
||||
metadata = manifest['metadata']
|
||||
identity = f'{metadata["author"]}/{metadata["name"]}'
|
||||
if identity in identities:
|
||||
raise ValueError(f'Duplicate plugin ID: {identity}')
|
||||
identities.add(identity)
|
||||
kinds = manifest['spec']['components']
|
||||
category = 'Runner' if 'Runner' in kinds else 'KnowledgeEngine' if 'KnowledgeEngine' in kinds else 'misc'
|
||||
assert plugin.parent.name == category, path
|
||||
assert metadata['repository'] == REPOSITORY + plugin.relative_to(root).as_posix(), path
|
||||
assert re.fullmatch(r'\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', str(metadata['version'])), path
|
||||
for required in ('README.md', metadata['icon'], manifest['execution']['python']['path']):
|
||||
assert (plugin / required).is_file(), (path, required)
|
||||
for kind, declaration in kinds.items():
|
||||
for directory in declaration.get('fromDirs', []):
|
||||
assert (plugin / directory['path']).is_dir(), (path, kind, directory)
|
||||
for source in declaration.get('fromFiles', []):
|
||||
assert (plugin / source).is_file(), (path, kind, source)
|
||||
for source in plugin.rglob('*.py'):
|
||||
if not any(part in {'.venv', '__pycache__', 'dist'} for part in source.parts):
|
||||
ast.parse(source.read_text(), filename=str(source))
|
||||
plugins.append(
|
||||
{
|
||||
'id': identity,
|
||||
'version': metadata['version'],
|
||||
'category': category,
|
||||
'path': plugin.relative_to(root).as_posix(),
|
||||
'repository': metadata['repository'],
|
||||
}
|
||||
)
|
||||
return plugins
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--write', action='store_true')
|
||||
args = parser.parse_args()
|
||||
catalog = check_catalog()
|
||||
index = ROOT / 'catalog.json'
|
||||
if args.write:
|
||||
index.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + '\n')
|
||||
elif json.loads(index.read_text()) != catalog:
|
||||
raise SystemExit('Plugin catalog is stale; run python plugins/scripts/check_catalog.py --write')
|
||||
print(f'Validated {len(catalog)} plugins: {dict(Counter(p["category"] for p in catalog))}')
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
import yaml
|
||||
|
||||
from check_catalog import ROOT, check_catalog
|
||||
|
||||
|
||||
def package_plugin(plugin: str, output: Path) -> Path:
|
||||
catalog = {entry['path']: entry for entry in check_catalog()}
|
||||
if plugin not in catalog:
|
||||
raise ValueError('Select an exact relative plugin path from plugins/catalog.json')
|
||||
source = ROOT / plugin
|
||||
destination = output.resolve()
|
||||
if destination == ROOT or ROOT in destination.parents:
|
||||
raise ValueError('Build output must be outside the plugin source tree')
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
metadata = yaml.safe_load((source / 'manifest.yaml').read_text())['metadata']
|
||||
archive = destination / f'{metadata["author"]}-{metadata["name"]}-{metadata["version"]}.lbpkg'
|
||||
if archive.exists():
|
||||
raise ValueError(f'Build output already exists: {archive.name}')
|
||||
subprocess.run(['lbp', 'build', '--output', str(destination)], cwd=source, check=True)
|
||||
with zipfile.ZipFile(archive) as package:
|
||||
manifest = yaml.safe_load(package.read('manifest.yaml'))
|
||||
for field in ('author', 'name', 'version', 'repository'):
|
||||
if manifest['metadata'][field] != metadata[field]:
|
||||
raise ValueError(f'Built plugin metadata differs: {field}')
|
||||
return archive
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('plugin')
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
print(package_plugin(args.plugin, args.output))
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
import yaml
|
||||
|
||||
from check_catalog import ROOT, check_catalog
|
||||
from package_plugin import package_plugin
|
||||
|
||||
CORE = ROOT.parent
|
||||
spec = importlib.util.spec_from_file_location('core_artifacts', CORE / 'scripts/check_core_artifacts.py')
|
||||
core_artifacts = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(core_artifacts)
|
||||
|
||||
|
||||
def workflow(name):
|
||||
return yaml.safe_load((CORE / '.github/workflows' / name).read_text())
|
||||
|
||||
|
||||
class RepositoryTests(unittest.TestCase):
|
||||
def test_imported_debug_configuration_is_not_lost(self):
|
||||
relative = 'misc/AgenticRAG/.vscode/launch.json'
|
||||
self.assertTrue((ROOT / relative).is_file())
|
||||
self.assertIn('!misc/AgenticRAG/.vscode/', (ROOT / '.gitignore').read_text())
|
||||
|
||||
def test_release_zip_excludes_sources_and_packages(self):
|
||||
data = workflow('build-release-artifacts.yaml')
|
||||
steps = data['jobs']['build-artifacts']['steps']
|
||||
script = next(step['run'] for step in steps if step.get('name') == 'Upload To Release')
|
||||
command = next(line for line in script.splitlines() if line.strip().startswith('zip -r '))
|
||||
command = command.replace('${{ steps.check_version.outputs.version }}', 'test')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'source'
|
||||
(source / 'plugins/misc/HelloPlugin').mkdir(parents=True)
|
||||
(source / 'plugins/misc/HelloPlugin/main.py').write_text('plugin')
|
||||
(source / 'unexpected.lbpkg').write_text('package')
|
||||
(source / 'main.py').write_text('core')
|
||||
subprocess.run(
|
||||
['bash', '-c', command],
|
||||
cwd=source,
|
||||
env={**os.environ, 'RUNNER_TEMP': directory},
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
artifact = root / 'langbot-test-all.zip'
|
||||
core_artifacts.check_artifact(artifact)
|
||||
with zipfile.ZipFile(artifact) as archive:
|
||||
self.assertIn('main.py', archive.namelist())
|
||||
|
||||
def test_fnos_real_staging_command_excludes_sources_and_packages(self):
|
||||
script = (CORE / 'packaging/fnos/build.sh').read_text()
|
||||
command = 'rsync -a ' + script.split('rsync -a ', 1)[1].split('\n\n', 1)[0]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'source'
|
||||
destination = root / 'fnos'
|
||||
(source / 'plugins/Runner/LocalAgent').mkdir(parents=True)
|
||||
(source / 'plugins/Runner/LocalAgent/main.py').write_text('plugin')
|
||||
(source / 'unexpected.lbpkg').write_text('package')
|
||||
(source / 'main.py').write_text('core')
|
||||
(destination / 'app/langbot').mkdir(parents=True)
|
||||
subprocess.run(
|
||||
['bash', '-c', command],
|
||||
cwd=source,
|
||||
env={**os.environ, 'FPK_DIR': str(destination)},
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
core_artifacts.check_artifact(destination / 'app/langbot')
|
||||
self.assertTrue((destination / 'app/langbot/main.py').is_file())
|
||||
|
||||
def test_catalog_and_readme_cover_all_plugins(self):
|
||||
catalog = check_catalog()
|
||||
self.assertEqual(json.loads((ROOT / 'catalog.json').read_text()), catalog)
|
||||
readme = (ROOT / 'README.md').read_text()
|
||||
for entry in catalog:
|
||||
self.assertIn(f'({entry["path"]}/README.md)', readme)
|
||||
|
||||
def test_original_identities_are_preserved(self):
|
||||
imported = json.loads((ROOT / 'SOURCE.json').read_text())
|
||||
current = {entry['id']: entry['path'] for entry in check_catalog()}
|
||||
for identity, relative in imported['plugins'].items():
|
||||
self.assertEqual(current[identity], relative)
|
||||
|
||||
def test_plugin_workflows_do_not_release_core_or_checkout_old_repo(self):
|
||||
for path in (CORE / '.github/workflows').glob('plugins-*.yml'):
|
||||
text = path.read_text()
|
||||
data = workflow(path.name)
|
||||
events = data.get('on', data.get(True))
|
||||
self.assertNotIn('release', events)
|
||||
self.assertNotIn('langbot-app/langbot-plugins', text)
|
||||
self.assertNotIn('secrets.', text)
|
||||
for event in ('push', 'pull_request'):
|
||||
if event in events:
|
||||
self.assertTrue(
|
||||
all(
|
||||
pattern.startswith(('plugins/', '.github/workflows/plugins-'))
|
||||
for pattern in events[event]['paths']
|
||||
)
|
||||
)
|
||||
for job in data['jobs'].values():
|
||||
default = job.get('defaults', {}).get('run', {}).get('working-directory', '.')
|
||||
self.assertTrue((CORE / default).is_dir())
|
||||
for step in job['steps']:
|
||||
directory = step.get('working-directory', default)
|
||||
self.assertTrue((CORE / directory).is_dir(), directory)
|
||||
self.assertNotIn('uv publish', step.get('run', ''))
|
||||
|
||||
def test_every_plugin_category_has_independent_ci(self):
|
||||
for name in ('runner', 'local-agent', 'langrag', 'misc', 'knowledgeengine-shared'):
|
||||
data = workflow(f'plugins-{name}-tests.yml')
|
||||
self.assertIn('workflow_dispatch', data.get('on', data.get(True)))
|
||||
self.assertEqual(data['permissions'], {'contents': 'read'})
|
||||
|
||||
def test_core_release_jobs_reject_plugin_tags(self):
|
||||
for filename, job in (
|
||||
('build-release-artifacts.yaml', 'build-artifacts'),
|
||||
('build-fnos-fpk.yaml', 'build-fnos-fpk'),
|
||||
('build-docker-image.yml', 'publish-docker-image'),
|
||||
('publish-to-pypi.yml', 'build-and-publish'),
|
||||
('discord-release.yml', 'announce'),
|
||||
):
|
||||
condition = workflow(filename)['jobs'][job]['if']
|
||||
self.assertIn("!startsWith(github.event.release.tag_name, 'plugins/')", condition)
|
||||
|
||||
def test_core_consumer_hashes_match_in_tree_sources(self):
|
||||
lock = json.loads((CORE / 'tests/fixtures/legacy_plugin_identity_sources.json').read_text())
|
||||
self.assertEqual(lock['root'], 'plugins')
|
||||
self.assertEqual(lock['repository'], 'langbot-app/LangBot')
|
||||
for key in ('files', 'local_agent_files'):
|
||||
for relative, expected in lock[key].items():
|
||||
self.assertEqual(hashlib.sha256((ROOT / relative).read_bytes()).hexdigest(), expected, relative)
|
||||
|
||||
def test_packaging_exclusions_are_present(self):
|
||||
self.assertIn('prune plugins', (CORE / 'MANIFEST.in').read_text())
|
||||
self.assertIn('plugins/', (CORE / '.dockerignore').read_text().splitlines())
|
||||
self.assertIn('/plugins export-ignore', (CORE / '.gitattributes').read_text())
|
||||
self.assertIn("--exclude='/plugins/'", (CORE / 'packaging/fnos/build.sh').read_text())
|
||||
release = (CORE / '.github/workflows/build-release-artifacts.yaml').read_text()
|
||||
self.assertIn('!plugins/**', release)
|
||||
self.assertIn("-x 'plugins/*' '*.lbpkg'", release)
|
||||
|
||||
def test_core_artifact_checker_rejects_plugin_content(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for member in (
|
||||
'plugins/misc/HelloPlugin/main.py',
|
||||
'langbot-1/plugins/catalog.json',
|
||||
'nested/HelloPlugin.lbpkg',
|
||||
):
|
||||
artifact = Path(directory) / 'core.zip'
|
||||
with zipfile.ZipFile(artifact, 'w') as archive:
|
||||
archive.writestr(member, 'unexpected')
|
||||
with self.assertRaises(ValueError):
|
||||
core_artifacts.check_artifact(artifact)
|
||||
|
||||
def test_core_artifact_checker_accepts_host_plugin_bridge(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
artifact = Path(directory) / 'core.zip'
|
||||
with zipfile.ZipFile(artifact, 'w') as archive:
|
||||
archive.writestr('langbot/pkg/plugin/connector.py', 'host')
|
||||
core_artifacts.check_artifact(artifact)
|
||||
|
||||
def test_package_selection_rejects_traversal_and_root(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch('package_plugin.subprocess.run') as build:
|
||||
for selected in ('..', '.', 'Runner', 'Runner/LocalAgent/../LocalAgent', '/tmp/plugin'):
|
||||
with self.assertRaises(ValueError):
|
||||
package_plugin(selected, Path(directory))
|
||||
build.assert_not_called()
|
||||
|
||||
def test_no_plugin_build_output_inside_sources(self):
|
||||
with patch('package_plugin.subprocess.run') as build:
|
||||
with self.assertRaises(ValueError):
|
||||
package_plugin('Runner/LocalAgent', ROOT / 'dist')
|
||||
build.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user