Files
mini-openclaw-v2/backend/services/skill_packages.py
T

166 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BR-64: managed, immutable standard Skill packages; scripts are never executed."""
from __future__ import annotations
import base64
import hashlib
import io
import json
import os
import re
import shutil
import zipfile
from pathlib import Path, PurePosixPath
import yaml
from services import assistant_store as store, component_registry as registry, knowledge_cards
from services.assistant_materials import FIELDS
from services.lease_case_library import LibraryError
ROOT = Path(__file__).resolve().parents[1] / 'xingbang_data' / 'skill_packages'
MAX_TOTAL = 10 * 1024 * 1024
def validate_files(files):
if not 1 <= len(files) <= 100:
raise LibraryError('SKILL_PACKAGE_INVALID', 'Skills 文件夹需要 1–100 个文件')
decoded, total = {}, 0
roots = set()
for item in files:
path = str(item['path']).replace('\\', '/')
parts = PurePosixPath(path).parts
if not parts or len(parts) < 2 or path.startswith('/') or any(
p in ('.', '..') or ':' in p or not p.strip() or p.endswith((' ', '.'))
or re.match(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)', p, re.I)
for p in path.split('/')):
raise LibraryError('SKILL_PACKAGE_INVALID', '文件路径不安全,请选择一个标准 Skills 文件夹')
roots.add(parts[0])
relative = '/'.join(parts[1:])
if relative in decoded:
raise LibraryError('SKILL_PACKAGE_INVALID', '文件夹存在重复路径')
try:
raw = base64.b64decode(item['content_base64'], validate=True)
except (ValueError, TypeError):
raise LibraryError('SKILL_PACKAGE_INVALID', '文件内容编码无效') from None
total += len(raw)
if len(raw) > 2 * 1024 * 1024 or total > MAX_TOTAL:
raise LibraryError('SKILL_PACKAGE_INVALID', '文件超过单文件 2MiB 或文件夹 10MiB 上限')
decoded[relative] = raw
if len(roots) != 1 or 'SKILL.md' not in decoded:
raise LibraryError('SKILL_PACKAGE_INVALID', '请选择单个文件夹,根目录必须有 SKILL.md')
try:
text = decoded['SKILL.md'].decode('utf-8-sig')
if len(decoded['SKILL.md']) > 65536:
raise ValueError()
match = re.match(r'\A---\s*\r?\n(.*?)\r?\n---\s*\r?\n([\s\S]+)\Z', text, re.S)
meta = yaml.safe_load(match.group(1)) if match else None
if not isinstance(meta, dict):
raise ValueError()
name, description = meta.get('name'), meta.get('description')
if not isinstance(name, str) or not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', name) or len(name) > 64:
raise ValueError()
if not isinstance(description, str) or not description.strip() or len(description) > 1024 or name != next(iter(roots)):
raise ValueError()
except (UnicodeError, ValueError, yaml.YAMLError):
raise LibraryError('SKILL_PACKAGE_INVALID', 'SKILL.md 需要标准 YAML name/description,name 与文件夹名一致') from None
return name, description, text, decoded
def write_bundle(db, component_id, version, name, files, root=None):
root = Path(root or ROOT).resolve()
root.mkdir(parents=True, exist_ok=True)
package_id = store.uid('package')
package_root = root / package_id
folder = package_root / name
if package_root.parent != root:
raise ValueError('不安全的托管路径')
folder.mkdir(parents=True)
try:
manifest = []
for relative, raw in sorted(files.items()):
target = folder.joinpath(*PurePosixPath(relative).parts)
if not target.resolve().is_relative_to(folder.resolve()):
raise ValueError('不安全的文件路径')
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(raw)
manifest.append({'path': relative, 'size': len(raw), 'sha256': hashlib.sha256(raw).hexdigest()})
body = {'id': package_id, 'component_id': component_id, 'version': version, 'name': name,
'manifest': manifest, 'content_hash': store.digest(manifest), 'created_at': store.now()}
store.put(db, 'skill_package', body, immutable=True)
return body
except Exception:
shutil.rmtree(package_root) # Only the fresh UUID folder created above.
raise
def import_package(db, project, actor, files, required_field=None, root=None):
name, description, text, decoded = validate_files(files)
if required_field and required_field not in set(FIELDS.values()):
raise LibraryError('SKILL_PACKAGE_INVALID', '核查字段不在允许的标准字段中')
for kind in ('component_draft', 'component_version'):
if any(row.get('package_name') == name and row.get('project_id') == project['id'] for row in store.listing(db, kind)):
raise LibraryError('SKILL_PACKAGE_INVALID', '该项目已导入同名 Skill,请修订版本,不覆盖原文件夹')
body = {'id': store.uid('skill.folder'), 'kind': 'skill', 'name': description[:80],
'package_name': name, 'scope': project['data_domain'], 'version': '1.0.0',
'engine': 'required' if required_field else 'model', 'inputs': [required_field] if required_field else [],
'code': 'folder.' + name, 'title': description[:120], 'instructions': text,
'description': description, 'import_method': 'folder', 'allowed_tools': [] if required_field else sorted(registry.TOOLS),
'dimensions': [{'id': 'folder.' + name, 'name': description[:20], 'label': description[:20],
'scale_id': 'stage', 'fields': [required_field]}] if required_field else []}
draft = knowledge_cards.propose(db, body, actor, project['id'], origin='manual')
package = write_bundle(db, draft['component_id'], draft['version'], name, decoded, root)
store.event(db, project['id'], 'skill.folder_import', actor,
{'draft_id': draft['id'], 'package_id': package['id'], 'content_hash': package['content_hash']})
return dict(draft, package=package)
def standard_package(db, component, root=None):
component_id = component.get('component_id') or component['id']
version = component['version']
packages = [p for p in store.listing(db, 'skill_package') if p['component_id'] == component_id and p['version'] == version]
if packages:
return packages[0]
name = re.sub('[^a-z0-9-]', '-', component_id.lower()).strip('-')[:64]
description = str(component.get('description') or component['name'])[:1024]
text = '---\n' + yaml.safe_dump({'name': name, 'description': description}, allow_unicode=True, sort_keys=False) + '---\n\n'
text += '# ' + component['name'] + '\n\n' + str(component.get('instructions') or '按 references/executor.json 中的受控执行器、适用字段与规则进行核查。')
text += '\n\n## 执行边界\n只读核查;不执行脚本,不扩大工具权限,不替代正式评审会。\n'
return write_bundle(db, component_id, version, name,
{'SKILL.md': text.encode('utf-8'), 'references/executor.json': json.dumps(component, ensure_ascii=False, indent=2).encode('utf-8')}, root)
def content(db, component, root=None):
package = standard_package(db, component, root)
folder = Path(root or ROOT).resolve() / package['id'] / package['name']
if folder.is_symlink() or not folder.resolve().is_relative_to(Path(root or ROOT).resolve()):
raise LibraryError('SKILL_PACKAGE_INVALID', '托管 Skills 文件夹路径无效')
files = []
for entry in package['manifest']:
path = folder / entry['path']
if path.is_symlink() or not path.resolve().is_relative_to(folder.resolve()):
raise LibraryError('SKILL_PACKAGE_INVALID', '托管文件路径无效')
raw = path.read_bytes()
if hashlib.sha256(raw).hexdigest() != entry['sha256']:
raise LibraryError('SKILL_PACKAGE_INVALID', 'Skills 文件已改变,请通过新版本修订')
try:
text = raw.decode('utf-8-sig')
except UnicodeError:
text = None
files.append({**entry, 'text': text})
return {**package, 'files': files}
def archive(db, component, root=None):
package = content(db, component, root)
folder = Path(root or ROOT).resolve() / package['id'] / package['name']
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_DEFLATED) as zipped:
for entry in package['manifest']:
zipped.writestr(package['name'] + '/' + entry['path'], (folder / entry['path']).read_bytes())
return package['name'], stream.getvalue()
def open_folder(db, component, root=None):
package = content(db, component, root)
if os.name != 'nt':
raise LibraryError('SKILL_FOLDER_UNAVAILABLE', '当前部署不支持打开本机文件夹,请下载标准包')
os.startfile(str(Path(root or ROOT).resolve() / package['id'] / package['name']))
return {'opened': True, 'package_id': package['id']}