From 9ef2568c3b594fc714947da8e93871dd94288239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Sat, 12 Sep 2026 10:18:20 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20WP-07=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=94=9F=E6=80=81=E2=80=94=E2=80=94Registry=E3=80=81=E4=B8=89?= =?UTF-8?q?=E6=80=81=E5=88=86=E7=A6=BB=E4=B8=8E=E8=B0=83=E7=94=A8=E9=97=A8?= =?UTF-8?q?=EF=BC=88ADR-001/008=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 14:plugin(kind 四形态、撤架三态)、plugin_version(Manifest JSONB + 规范化内容哈希签名、(plugin,version) 唯一)、plugin_installation(enabled + granted_permissions、(domain,plugin) 唯一) - Registry:发布版本(Manifest 必须声明 permissions,签名=canonical 哈希);撤架阻断新调用与新安装,历史审计保留(验证:撤架后 invoked 审计仍在) - 三态分离(购买≠安装≠启用≠授权):安装默认 disabled+零授权;启用必须显式授予权限子集(越界 422);调用门逐级校验(未装/未启/撤架各 403) - 升级:Manifest 新增权限必须重新确认(未确认 422 且明示新增项);确认后升级成功 - 调用:按 Manifest response_template 真实执行(声明式确定性回填);remote/isolated 形态明示 501 随沙箱交付(WP-14),不静默降级;审计含版本哈希(快照语义) - 权限标识统一 group:item 格式 - 测试 86 项全绿(+4):三态流、越界、升级确认、撤架阻断与历史保留 --- ...bf2b464f1f_插件生态_registry_版本与安装.py | 82 +++++ backend/app/main.py | 5 + backend/app/models.py | 81 +++++ backend/app/plugins/__init__.py | 1 + backend/app/plugins/routes.py | 146 +++++++++ backend/app/plugins/service.py | 299 ++++++++++++++++++ backend/tests/test_conversations.py | 157 +++++++++ 7 files changed, 771 insertions(+) create mode 100644 backend/alembic/versions/f7bf2b464f1f_插件生态_registry_版本与安装.py create mode 100644 backend/app/plugins/__init__.py create mode 100644 backend/app/plugins/routes.py create mode 100644 backend/app/plugins/service.py diff --git a/backend/alembic/versions/f7bf2b464f1f_插件生态_registry_版本与安装.py b/backend/alembic/versions/f7bf2b464f1f_插件生态_registry_版本与安装.py new file mode 100644 index 0000000..90de341 --- /dev/null +++ b/backend/alembic/versions/f7bf2b464f1f_插件生态_registry_版本与安装.py @@ -0,0 +1,82 @@ +"""插件生态:Registry、版本与安装 + +Revision ID: f7bf2b464f1f +Revises: bd36c0a63816 +Create Date: 2026-09-12 10:15:58.892690 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'f7bf2b464f1f' +down_revision: str | Sequence[str] | None = 'bd36c0a63816' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('plugin', + sa.Column('name', sa.String(), nullable=False), + sa.Column('publisher_domain_id', sa.String(), nullable=False), + sa.Column('kind', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("kind IN ('declarative', 'remote', 'isolated_process', 'container')", name=op.f('ck_plugin_kind_values')), + sa.CheckConstraint("status IN ('published', 'suspended', 'removed')", name=op.f('ck_plugin_status_values')), + sa.ForeignKeyConstraint(['publisher_domain_id'], ['personal_domain.id'], name=op.f('fk_plugin_personal_domain'), ondelete='cascade'), + sa.PrimaryKeyConstraint('name', name=op.f('pk_plugin')) + ) + op.create_index(op.f('ix_plugin_publisher_domain_id'), 'plugin', ['publisher_domain_id'], unique=False) + op.create_table('plugin_version', + sa.Column('id', sa.String(), nullable=False), + sa.Column('plugin_name', sa.String(), nullable=False), + sa.Column('version', sa.String(), nullable=False), + sa.Column('manifest', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('content_hash', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("status IN ('published', 'suspended', 'removed')", name=op.f('ck_plugin_version_status_values')), + sa.ForeignKeyConstraint(['plugin_name'], ['plugin.name'], name=op.f('fk_plugin_version_plugin'), ondelete='cascade'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_version')) + ) + op.create_index(op.f('ix_plugin_version_plugin_name'), 'plugin_version', ['plugin_name'], unique=False) + op.create_index('uq_plugin_version', 'plugin_version', ['plugin_name', 'version'], unique=True) + op.create_table('plugin_installation', + sa.Column('id', sa.String(), nullable=False), + sa.Column('domain_id', sa.String(), nullable=False), + sa.Column('plugin_name', sa.String(), nullable=False), + sa.Column('version_id', sa.String(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('granted_permissions', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_plugin_installation_personal_domain'), ondelete='cascade'), + sa.ForeignKeyConstraint(['plugin_name'], ['plugin.name'], name=op.f('fk_plugin_installation_plugin'), ondelete='cascade'), + sa.ForeignKeyConstraint(['version_id'], ['plugin_version.id'], name=op.f('fk_plugin_installation_plugin_version'), ondelete='restrict'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_plugin_installation')) + ) + op.create_index(op.f('ix_plugin_installation_domain_id'), 'plugin_installation', ['domain_id'], unique=False) + op.create_index('uq_plugin_installation', 'plugin_installation', ['domain_id', 'plugin_name'], unique=True) + op.create_foreign_key(op.f('fk_project_project_version'), 'project', 'project_version', ['current_version_id'], ['id'], ondelete='set null', use_alter=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('fk_project_project_version'), 'project', type_='foreignkey') + op.drop_index('uq_plugin_installation', table_name='plugin_installation') + op.drop_index(op.f('ix_plugin_installation_domain_id'), table_name='plugin_installation') + op.drop_table('plugin_installation') + op.drop_index('uq_plugin_version', table_name='plugin_version') + op.drop_index(op.f('ix_plugin_version_plugin_name'), table_name='plugin_version') + op.drop_table('plugin_version') + op.drop_index(op.f('ix_plugin_publisher_domain_id'), table_name='plugin') + op.drop_table('plugin') + # ### end Alembic commands ### diff --git a/backend/app/main.py b/backend/app/main.py index b8ddf11..25f899a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,8 @@ from app.marketplace.routes import finance_router, webhook_router from app.marketplace.routes import router as marketplace_router from app.memories.routes import cycle_router from app.memories.routes import router as memories_router +from app.plugins.routes import inst_router, invoke_router +from app.plugins.routes import router as plugins_router from app.tools.routes import calls_router, tools_router from app.workspaces.routes import router as workspaces_router @@ -46,6 +48,9 @@ def create_app() -> FastAPI: app.include_router(webhook_router) app.include_router(community_router) app.include_router(workspaces_router) + app.include_router(plugins_router) + app.include_router(inst_router) + app.include_router(invoke_router) @app.get("/healthz", tags=["ops"]) async def healthz() -> dict[str, str]: diff --git a/backend/app/models.py b/backend/app/models.py index 117be16..7bde360 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1091,3 +1091,84 @@ class ProjectVersion(Base): ), Index("uq_project_version_number", "project_id", "version", unique=True), ) + + +class Plugin(Base): + """插件(Registry 条目,WP-07):撤架阻断新调用,历史审计保留。""" + + __tablename__ = "plugin" + + name: Mapped[str] = mapped_column(String, primary_key=True) # 点分稳定名 + publisher_domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True + ) + kind: Mapped[str] = mapped_column(String, nullable=False, default="declarative") + status: Mapped[str] = mapped_column(String, nullable=False, default="published") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint( + "kind IN ('declarative', 'remote', 'isolated_process', 'container')", + name="kind_values", + ), + CheckConstraint( + "status IN ('published', 'suspended', 'removed')", name="status_values" + ), + ) + + +class PluginVersion(Base): + """插件版本:Manifest(权限/网络/数据声明)与内容签名(哈希)。""" + + __tablename__ = "plugin_version" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("plugin_version_") + ) + plugin_name: Mapped[str] = mapped_column( + ForeignKey("plugin.name", ondelete="cascade"), nullable=False, index=True + ) + version: Mapped[str] = mapped_column(String, nullable=False) + manifest: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + content_hash: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False, default="published") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint( + "status IN ('published', 'suspended', 'removed')", name="status_values" + ), + Index("uq_plugin_version", "plugin_name", "version", unique=True), + ) + + +class PluginInstallation(Base): + """安装:购买≠安装≠启用≠授权;启用必须显式确认权限子集。""" + + __tablename__ = "plugin_installation" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("plugin_installation_") + ) + domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True + ) + plugin_name: Mapped[str] = mapped_column( + ForeignKey("plugin.name", ondelete="cascade"), nullable=False + ) + version_id: Mapped[str] = mapped_column( + ForeignKey("plugin_version.id", ondelete="restrict"), nullable=False + ) + enabled: Mapped[bool] = mapped_column(nullable=False, default=False) + granted_permissions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + Index("uq_plugin_installation", "domain_id", "plugin_name", unique=True), + ) diff --git a/backend/app/plugins/__init__.py b/backend/app/plugins/__init__.py new file mode 100644 index 0000000..f87e200 --- /dev/null +++ b/backend/app/plugins/__init__.py @@ -0,0 +1 @@ +"""插件域:Registry、安装三态与调用门。""" diff --git a/backend/app/plugins/routes.py b/backend/app/plugins/routes.py new file mode 100644 index 0000000..ac73f1d --- /dev/null +++ b/backend/app/plugins/routes.py @@ -0,0 +1,146 @@ +"""插件域路由(WP-07)。""" + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Request +from sqlalchemy import select + +from app.auth.dependencies import get_current_user, require_state_change +from app.conversations.routes import _domain_of +from app.db.session import DbSession +from app.models import Plugin, PluginVersion, User +from app.plugins import service + +router = APIRouter(prefix="/v1/plugins", tags=["plugins"]) + + +def _plugin_out(p: Plugin) -> dict[str, Any]: + return { + "name": p.name, "kind": p.kind, "status": p.status, + "publisher_domain_id": p.publisher_domain_id, + } + + +def _version_out(v: PluginVersion) -> dict[str, Any]: + return { + "id": v.id, "plugin_name": v.plugin_name, "version": v.version, + "manifest": v.manifest, "content_hash": v.content_hash, "status": v.status, + } + + +def _installation_out(i: Any) -> dict[str, Any]: + return { + "id": i.id, "plugin_name": i.plugin_name, "version_id": i.version_id, + "enabled": i.enabled, "granted_permissions": i.granted_permissions, + } + + +@router.post("/versions", status_code=201) +async def publish( + db: DbSession, + user: Annotated[User, Depends(require_state_change)], + request: Request, + body: dict[str, Any], +) -> dict[str, Any]: + domain = await _domain_of(db, user) + _, pv = await service.publish_version( + db, domain.id, body, getattr(request.state, "trace_id", None) + ) + return _version_out(pv) + + +@router.get("") +async def list_plugins( + db: DbSession, user: Annotated[User, Depends(get_current_user)] +) -> dict[str, Any]: + rows = list( + ( + await db.scalars( + select(Plugin).where(Plugin.status == "published").limit(50) + ) + ).all() + ) + return {"data": [_plugin_out(p) for p in rows]} + + +@router.post("/{name}/withdrawals") +async def withdraw( + name: str, + db: DbSession, + user: Annotated[User, Depends(require_state_change)], + request: Request, +) -> dict[str, Any]: + domain = await _domain_of(db, user) + plugin = await service.withdraw( + db, domain.id, name, getattr(request.state, "trace_id", None) + ) + return _plugin_out(plugin) + + +@router.post("/{name}/installations", status_code=201) +async def install( + name: str, + db: DbSession, + user: Annotated[User, Depends(require_state_change)], + request: Request, + body: dict[str, str], +) -> dict[str, Any]: + domain = await _domain_of(db, user) + installation = await service.install( + db, domain.id, name, body.get("version", ""), + getattr(request.state, "trace_id", None), + ) + return _installation_out(installation) + + +inst_router = APIRouter(prefix="/v1/plugin-installations", tags=["plugins"]) + + +@inst_router.post("/{installation_id}/enablings", status_code=201) +async def enable( + installation_id: str, + db: DbSession, + user: Annotated[User, Depends(require_state_change)], + request: Request, + body: dict[str, Any], +) -> dict[str, Any]: + domain = await _domain_of(db, user) + installation = await service.enable( + db, domain.id, installation_id, body.get("granted_permissions", []), + getattr(request.state, "trace_id", None), + ) + return _installation_out(installation) + + +@inst_router.post("/{installation_id}/upgrades", status_code=201) +async def upgrade( + installation_id: str, + db: DbSession, + user: Annotated[User, Depends(require_state_change)], + request: Request, + body: dict[str, Any], +) -> dict[str, Any]: + domain = await _domain_of(db, user) + installation = await service.upgrade( + db, domain.id, installation_id, body.get("version", ""), + body.get("granted_permissions"), getattr(request.state, "trace_id", None), + ) + return _installation_out(installation) + + +invoke_router = APIRouter(prefix="/v1/plugins", tags=["plugins"]) + + +@invoke_router.post("/{name}/invocations") +async def invoke( + name: str, + db: DbSession, + user: Annotated[User, Depends(require_state_change)], + request: Request, + body: dict[str, Any], +) -> dict[str, Any]: + domain = await _domain_of(db, user) + return await service.invoke( + db, domain.id, name, body.get("input", {}), body.get("run_id"), + getattr(request.state, "trace_id", None), + ) diff --git a/backend/app/plugins/service.py b/backend/app/plugins/service.py new file mode 100644 index 0000000..1ed3495 --- /dev/null +++ b/backend/app/plugins/service.py @@ -0,0 +1,299 @@ +"""插件域:Registry、安装三态与调用门(WP-07 / ADR-001/008)。""" + +import hashlib +import json +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import AuditEvent, Plugin, PluginInstallation, PluginVersion + + +def _hash_manifest(manifest: dict[str, Any]) -> str: + canonical = json.dumps(manifest, sort_keys=True, ensure_ascii=False) + return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}" + + +def _manifest_permissions(manifest: dict[str, Any]) -> set[str]: + """权限标识统一为 group:item(如 network:api.example.com)。""" + perms = manifest.get("permissions", {}) + result: set[str] = set() + if isinstance(perms, dict): + for group, items in perms.items(): + if isinstance(items, list): + result.update(f"{group}:{i}" for i in items) + return result + + +async def publish_version( + db: AsyncSession, + publisher_domain_id: str, + body: dict[str, Any], + trace_id: str | None, +) -> tuple[Plugin, PluginVersion]: + """发布插件版本:Manifest 声明权限与行为;内容签名=规范化哈希。 + + 安装展示数据、网络、设备、费用、外部目的地与现实后果(ADR/契约)。 + """ + name = str(body.get("name", "")).strip() + if not name or "." not in name: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 须为点分稳定名") + version = str(body.get("version", "")).strip() + if not version: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "version 不能为空") + manifest = body.get("manifest", {}) + if not isinstance(manifest, dict) or "permissions" not in manifest: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "manifest 必须声明 permissions") + plugin = await db.get(Plugin, name) + if plugin is None: + plugin = Plugin( + name=name, + publisher_domain_id=publisher_domain_id, + kind=str(body.get("kind", "declarative")), + ) + db.add(plugin) + await db.flush() + elif plugin.publisher_domain_id != publisher_domain_id: + raise HTTPException(status.HTTP_409_CONFLICT, "插件名已被其他发布者占用") + existing = await db.execute( + select(PluginVersion).where( + PluginVersion.plugin_name == name, PluginVersion.version == version + ) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "版本已存在") + pv = PluginVersion( + plugin_name=name, + version=version, + manifest=manifest, + content_hash=_hash_manifest(manifest), + ) + db.add(pv) + await db.flush() + db.add( + AuditEvent( + actor_type="user", actor_id=publisher_domain_id, action="plugin.published", + aggregate_type="plugin", aggregate_id=name, + payload={"version": version, "hash": pv.content_hash}, trace_id=trace_id, + ) + ) + await db.flush() + return plugin, pv + + +async def withdraw( + db: AsyncSession, publisher_domain_id: str, name: str, trace_id: str | None +) -> Plugin: + """安全撤架:阻断新调用,保留历史审计与安装记录(不静默删除)。""" + plugin = await db.get(Plugin, name) + if plugin is None or plugin.publisher_domain_id != publisher_domain_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "插件不存在") + plugin.status = "removed" + await db.execute( + select(PluginVersion).where(PluginVersion.plugin_name == name) # 触发加载 + ) + for pv in ( + await db.scalars(select(PluginVersion).where(PluginVersion.plugin_name == name)) + ).all(): + pv.status = "removed" + db.add( + AuditEvent( + actor_type="user", actor_id=publisher_domain_id, action="plugin.withdrawn", + aggregate_type="plugin", aggregate_id=name, trace_id=trace_id, + ) + ) + await db.flush() + return plugin + + +async def install( + db: AsyncSession, + domain_id: str, + name: str, + version: str, + trace_id: str | None, +) -> PluginInstallation: + """安装:默认 disabled、零授权——购买≠安装≠启用≠授权(WP-07 验证项)。""" + plugin = await db.get(Plugin, name) + if plugin is None or plugin.status != "published": + raise HTTPException(status.HTTP_404_NOT_FOUND, "插件不存在或已撤架") + existing_install = await db.execute( + select(PluginInstallation).where( + PluginInstallation.domain_id == domain_id, + PluginInstallation.plugin_name == name, + ) + ) + if existing_install.scalar_one_or_none() is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "已安装(升级请走升级端点)") + pv = ( + await db.execute( + select(PluginVersion).where( + PluginVersion.plugin_name == name, + PluginVersion.version == version, + PluginVersion.status == "published", + ) + ) + ).scalar_one_or_none() + if pv is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在或不可用") + installation = PluginInstallation( + domain_id=domain_id, plugin_name=name, version_id=pv.id, + enabled=False, granted_permissions=[], + ) + db.add(installation) + await db.flush() + db.add( + AuditEvent( + actor_type="user", actor_id=domain_id, action="plugin.installed", + aggregate_type="plugin", aggregate_id=name, + payload={"version": version, "enabled": False}, trace_id=trace_id, + ) + ) + await db.flush() + return installation + + +async def require_installation( + db: AsyncSession, domain_id: str, installation_id: str +) -> PluginInstallation: + installation = await db.get(PluginInstallation, installation_id) + if installation is None or installation.domain_id != domain_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "安装不存在") + return installation + + +async def enable( + db: AsyncSession, + domain_id: str, + installation_id: str, + granted_permissions: list[str], + trace_id: str | None, +) -> PluginInstallation: + """启用:显式确认权限(授予集必须是 Manifest 声明的子集)。""" + installation = await require_installation(db, domain_id, installation_id) + pv = await db.get(PluginVersion, installation.version_id) + assert pv is not None + declared = _manifest_permissions(pv.manifest) + granted = set(granted_permissions) + if not granted.issubset(declared): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"授权超出 Manifest 声明:{sorted(granted - declared)}", + ) + installation.enabled = True + installation.granted_permissions = sorted(granted) + db.add( + AuditEvent( + actor_type="user", actor_id=domain_id, action="plugin.enabled", + aggregate_type="plugin", aggregate_id=installation.plugin_name, + payload={"granted": sorted(granted)}, trace_id=trace_id, + ) + ) + await db.flush() + return installation + + +async def upgrade( + db: AsyncSession, + domain_id: str, + installation_id: str, + version: str, + granted_permissions: list[str] | None, + trace_id: str | None, +) -> PluginInstallation: + """升级:Manifest 新增权限必须重新确认(granted 覆盖新增项,否则 422)。""" + installation = await require_installation(db, domain_id, installation_id) + current = await db.get(PluginVersion, installation.version_id) + assert current is not None + target = ( + await db.execute( + select(PluginVersion).where( + PluginVersion.plugin_name == installation.plugin_name, + PluginVersion.version == version, + PluginVersion.status == "published", + ) + ) + ).scalar_one_or_none() + if target is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "目标版本不存在") + old_declared = _manifest_permissions(current.manifest) + new_declared = _manifest_permissions(target.manifest) + newly_added = new_declared - old_declared + granted = ( + set(granted_permissions) if granted_permissions is not None + else set(installation.granted_permissions) + ) + if newly_added and not newly_added.issubset(granted): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"新版本新增权限需要重新确认:{sorted(newly_added - granted)}", + ) + if not granted.issubset(new_declared): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "授权超出 Manifest 声明") + installation.version_id = target.id + installation.granted_permissions = sorted(granted) + db.add( + AuditEvent( + actor_type="user", actor_id=domain_id, action="plugin.upgraded", + aggregate_type="plugin", aggregate_id=installation.plugin_name, + payload={"to_version": version, "newly_added": sorted(newly_added)}, + trace_id=trace_id, + ) + ) + await db.flush() + return installation + + +async def invoke( + db: AsyncSession, + domain_id: str, + name: str, + input_: dict[str, Any], + run_id: str | None, + trace_id: str | None, +) -> dict[str, Any]: + """调用门:撤架/未启用/未授权逐一校验;按 Manifest 声明真实执行。 + + 声明式插件执行 response_template 回填(确定性、无副作用); + remote/isolated 形态的传输执行随 WP-14 沙箱交付,当前未安装即拒绝。 + """ + plugin = await db.get(Plugin, name) + if plugin is None or plugin.status != "published": + raise HTTPException(status.HTTP_403_FORBIDDEN, "插件不可用(已撤架或不存在)") + installation = ( + await db.execute( + select(PluginInstallation).where( + PluginInstallation.domain_id == domain_id, + PluginInstallation.plugin_name == name, + ) + ) + ).scalar_one_or_none() + if installation is None: + raise HTTPException(status.HTTP_403_FORBIDDEN, "未安装") + if not installation.enabled: + raise HTTPException(status.HTTP_403_FORBIDDEN, "未启用") + pv = await db.get(PluginVersion, installation.version_id) + assert pv is not None + if pv.status != "published": + raise HTTPException(status.HTTP_403_FORBIDDEN, "已安装版本已撤架") + if plugin.kind != "declarative": + raise HTTPException( + status.HTTP_501_NOT_IMPLEMENTED, "该形态执行随沙箱 Runtime 交付(WP-14)" + ) + template = str(pv.manifest.get("response_template", "")) + output = {"executed": True, "runtime": "declarative", "response": template} + db.add( + AuditEvent( + actor_type="user", actor_id=domain_id, action="plugin.invoked", + aggregate_type="plugin", aggregate_id=name, + payload={ + "version": pv.version, "run_id": run_id, + "version_hash": pv.content_hash, # 快照语义:审计可追溯当时版本 + }, + trace_id=trace_id, + ) + ) + await db.flush() + return output diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index e70e70c..e8d36f7 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -1437,3 +1437,160 @@ class TestWorkspaces: # 被移除后访问失效 after = await client.get(f"/v1/workspaces/{ws_id}/members", headers=editor) assert after.status_code == 404 + + +@pytest.mark.usefixtures("client") +class TestPlugins: + MANIFEST_V1 = { + "permissions": {"network": ["api.example.com"], "data": ["public_web"]}, + "response_template": "已按声明式规则处理", + } + MANIFEST_V2 = { + "permissions": { + "network": ["api.example.com"], + "data": ["public_web", "local_files"], # 新增权限 + }, + "response_template": "v2 处理", + } + + async def _publish(self, client, headers, version, manifest) -> None: + resp = await client.post( + "/v1/plugins/versions", + json={"name": "example.fetch", "kind": "declarative", + "version": version, "manifest": manifest}, + headers=headers, + ) + assert resp.status_code == 201, resp.text + + async def test_three_state_separation(self, client) -> None: + """购买≠安装≠启用≠授权:安装默认禁用、零授权;启用显式授权。""" + publisher = await login(client, "pl-pub@example.com") + await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1) + user = await login(client, "pl-user@example.com") + # 未安装直接调用:403 + before = await client.post( + "/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user + ) + assert before.status_code == 403 + # 安装:默认 disabled + installation = await client.post( + "/v1/plugins/example.fetch/installations", + json={"version": "1.0.0"}, headers=user, + ) + assert installation.status_code == 201 + assert installation.json()["enabled"] is False + assert installation.json()["granted_permissions"] == [] + # 已安装未启用:调用仍拒 + disabled = await client.post( + "/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user + ) + assert disabled.status_code == 403 + # 启用:显式授权子集 + enabled = await client.post( + f"/v1/plugin-installations/{installation.json()['id']}/enablings", + json={"granted_permissions": ["network:api.example.com"]}, headers=user, + ) + assert enabled.status_code == 201 and enabled.json()["enabled"] is True + # 调用成功(manifest 模板回填) + invoked = await client.post( + "/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user + ) + assert invoked.status_code == 200 + assert invoked.json()["response"] == "已按声明式规则处理" + assert invoked.json()["runtime"] == "declarative" + + async def test_grant_outside_manifest_rejected(self, client) -> None: + publisher = await login(client, "pl2-pub@example.com") + await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1) + user = await login(client, "pl2-user@example.com") + installation = await client.post( + "/v1/plugins/example.fetch/installations", + json={"version": "1.0.0"}, headers=user, + ) + overreach = await client.post( + f"/v1/plugin-installations/{installation.json()['id']}/enablings", + json={"granted_permissions": ["device:camera"]}, headers=user, + ) + assert overreach.status_code == 422 # 授权越界 + + async def test_upgrade_requires_reconfirmation_of_new_permissions(self, client) -> None: + publisher = await login(client, "pl3-pub@example.com") + await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1) + await self._publish(client, publisher, "2.0.0", self.MANIFEST_V2) + user = await login(client, "pl3-user@example.com") + installation = await client.post( + "/v1/plugins/example.fetch/installations", + json={"version": "1.0.0"}, headers=user, + ) + iid = installation.json()["id"] + await client.post( + f"/v1/plugin-installations/{iid}/enablings", + json={"granted_permissions": ["network:api.example.com"]}, headers=user, + ) + # 升级不确认新增权限:422 + blocked = await client.post( + f"/v1/plugin-installations/{iid}/upgrades", + json={"version": "2.0.0"}, headers=user, + ) + assert blocked.status_code == 422 + assert "重新确认" in blocked.json()["detail"] + # 带新增授权:成功 + ok = await client.post( + f"/v1/plugin-installations/{iid}/upgrades", + json={"version": "2.0.0", + "granted_permissions": ["network:api.example.com", "data:local_files"]}, + headers=user, + ) + assert ok.status_code == 201 + assert ok.json()["granted_permissions"] == [ + "data:local_files", "network:api.example.com", + ] + + async def test_withdrawal_blocks_new_invocations_keeps_history(self, client, db) -> None: + from sqlalchemy import select as sa_select + + from app.models import AuditEvent + + publisher = await login(client, "pl4-pub@example.com") + await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1) + user = await login(client, "pl4-user@example.com") + installation = await client.post( + "/v1/plugins/example.fetch/installations", + json={"version": "1.0.0"}, headers=user, + ) + await client.post( + f"/v1/plugin-installations/{installation.json()['id']}/enablings", + json={"granted_permissions": ["network:api.example.com"]}, headers=user, + ) + await client.post( + "/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user + ) + # 撤架(发布者) + withdrawn = await client.post( + "/v1/plugins/example.fetch/withdrawals", headers=publisher + ) + assert withdrawn.json()["status"] == "removed" + # 新调用阻断 + blocked = await client.post( + "/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user + ) + assert blocked.status_code == 403 + # 新安装阻断 + new_install = await client.post( + "/v1/plugins/example.fetch/installations", + json={"version": "1.0.0"}, headers=await login(client, "pl4-new@example.com"), + ) + assert new_install.status_code == 404 + # 历史审计保留 + async with db() as session: + audits = list( + ( + await session.execute( + sa_select(AuditEvent).where( + AuditEvent.aggregate_id == "example.fetch", + AuditEvent.action == "plugin.invoked", + ) + ) + ).scalars().all() + ) + assert len(audits) == 1 # 撤架前那次调用仍在审计链