From f5724e49a93b930d256bc5938f841b6c959ab404 Mon Sep 17 00:00:00 2001 From: Phil Feibicke <187636763+phinaldoo@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:00:13 +0200 Subject: [PATCH] Add Agent Plugin support --- README.md | 8 + agent-plugins.md | 49 ++ .../versions/add_agent_plugins_20260808.py | 81 +++ backend/app/main.py | 3 + backend/app/mcp/models.py | 13 + backend/app/model_modules.py | 1 + backend/app/plugins/__init__.py | 1 + backend/app/plugins/models.py | 146 ++++++ backend/app/plugins/router.py | 151 ++++++ backend/app/plugins/schemas.py | 95 ++++ backend/app/plugins/utils.py | 487 ++++++++++++++++++ backend/app/skills/models.py | 15 + backend/app/users/models.py | 25 + .../test_agent_plugins_migration.py | 29 ++ backend/tests/plugins/test_plugin_archives.py | 236 +++++++++ frontend/css/chat/plugins.css | 119 +++++ frontend/i18n/ar/index.json | 44 +- frontend/i18n/de/index.json | 44 +- frontend/i18n/en/index.json | 44 +- frontend/i18n/es/index.json | 44 +- frontend/i18n/fr/index.json | 44 +- frontend/i18n/hi/index.json | 44 +- frontend/i18n/it/index.json | 44 +- frontend/i18n/ja/index.json | 44 +- frontend/i18n/pt/index.json | 44 +- frontend/i18n/ru/index.json | 44 +- frontend/i18n/zh/index.json | 44 +- frontend/index.html | 56 ++ frontend/js/chat/plugins.js | 304 +++++++++++ frontend/js/chat/pluginsWorkspace.test.js | 49 ++ frontend/js/chat/script.js | 5 + frontend/js/chat/shortcuts.js | 2 +- frontend/js/chat/workspace.js | 16 +- frontend/js/common/icons.js | 1 + 34 files changed, 2352 insertions(+), 24 deletions(-) create mode 100644 agent-plugins.md create mode 100644 backend/alembic_main/versions/add_agent_plugins_20260808.py create mode 100644 backend/app/plugins/__init__.py create mode 100644 backend/app/plugins/models.py create mode 100644 backend/app/plugins/router.py create mode 100644 backend/app/plugins/schemas.py create mode 100644 backend/app/plugins/utils.py create mode 100644 backend/tests/migrations/test_agent_plugins_migration.py create mode 100644 backend/tests/plugins/test_plugin_archives.py create mode 100644 frontend/css/chat/plugins.css create mode 100644 frontend/js/chat/plugins.js create mode 100644 frontend/js/chat/pluginsWorkspace.test.js diff --git a/README.md b/README.md index 4fc94ff..34f88dc 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,14 @@ Follow the installation guide in our documentation to choose the right path and > not work; users must approve the app manually and install later versions > themselves. +## Agent Plugins + +Install portable `.codex-plugin/plugin.json` bundles from **Workspace → +Plugins**. Omlorix imports bundled Agent Skills and remote MCP servers, provides +review-first lifecycle controls, preserves unsupported manifest metadata, and +keeps hooks and personal local-process servers disabled by design. See [Agent +Plugins](agent-plugins.md) for the supported contract and security model. + ## License Omlorix is source-available under the [PolyForm Free Trial License 1.0.0](LICENSE). diff --git a/agent-plugins.md b/agent-plugins.md new file mode 100644 index 0000000..15aa695 --- /dev/null +++ b/agent-plugins.md @@ -0,0 +1,49 @@ +# Agent Plugins + +Omlorix supports portable OpenAI Agent Plugin ZIP bundles through **Workspace → +Plugins**. A bundle must contain exactly one `.codex-plugin/plugin.json` +manifest. Omlorix currently installs the capabilities it can execute through its +provider-neutral runtime: + +- Agent Skills from declared skill folders, including their `SKILL.md`, + `scripts/`, `references/`, and `assets/` files. +- Remote streamable-HTTP and SSE MCP servers from a referenced `.mcp.json` or + an inline `mcpServers` map. +- MCP-hosted app UIs exposed by those servers through Omlorix's existing MCP Apps + sandbox. + +The workflow validates the complete archive, shows a component and warning +preview, then installs the plugin disabled. Enabling or disabling the plugin +controls every installed skill and MCP server as one aggregate. Export returns +the original validated bundle, and uninstall removes all components that still +belong to the plugin. + +## Compatibility boundaries + +Omlorix preserves the full manifest and source bundle so unknown and future +fields survive export. Two OpenAI-specific capabilities are intentionally not +executed: + +- Plugin hooks are untrusted executable automation. They remain portable + metadata and never run in Omlorix. +- Registered ChatGPT app IDs depend on OpenAI's hosted app registry. They remain + portable metadata; a plugin must expose its app UI through an installed MCP + server for Omlorix to render it. + +Personal plugins cannot install stdio MCP servers because that would allow an +uploaded archive to start a local backend process. Administrators can continue +to configure reviewed stdio servers through the existing admin MCP controls. + +## Security model + +Plugin parsing rejects absolute paths, traversal, backslashes, NUL bytes, +duplicate archive paths, symbolic links, excessive file/entry/expanded sizes, +and suspicious compression ratios. Manifest references are independently +confined to the bundle root. MCP headers are persisted through Omlorix's existing +encrypted secret columns and are omitted from plugin API responses. Lifecycle +changes are authenticated, owner-scoped, CSRF-protected by the shared request +dependency, and audit logged. + +The implemented format follows OpenAI's current [plugin bundle +documentation](https://developers.openai.com/plugins/build/plugins) and +[security guidance](https://developers.openai.com/plugins/guides/security-privacy). diff --git a/backend/alembic_main/versions/add_agent_plugins_20260808.py b/backend/alembic_main/versions/add_agent_plugins_20260808.py new file mode 100644 index 0000000..068d90d --- /dev/null +++ b/backend/alembic_main/versions/add_agent_plugins_20260808.py @@ -0,0 +1,81 @@ +"""Add portable agent plugin lifecycle tables. + +Revision ID: agent_plugins_20260808 +Revises: slide_storage_meta_20260806 +Create Date: 2026-08-08 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +from app.database import DATABASE_SCHEMA + + +revision: str = "agent_plugins_20260808" +down_revision: Union[str, Sequence[str], None] = "slide_storage_meta_20260806" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _app_schema() -> str | None: + """Return the application schema, or no schema for SQLite.""" + if op.get_bind().dialect.name == "sqlite": + return None + return str(op.get_context().version_table_schema or DATABASE_SCHEMA) + + +def upgrade() -> None: + """Create plugin aggregates and portable component links.""" + schema = _app_schema() + json_default = sa.text("'{}'::json") if op.get_bind().dialect.name == "postgresql" else sa.text("'{}'") + op.create_table( + "agent_plugins", + sa.Column("id", sa.String(), nullable=False), + sa.Column("owner_type", sa.String(), nullable=False, server_default="user"), + sa.Column("owner_user_id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("version", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("content_sha256", sa.String(), nullable=False), + sa.Column("manifest", sa.JSON(), nullable=False, server_default=json_default), + sa.Column("compatibility", sa.JSON(), nullable=False, server_default=json_default), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("owner_type = 'user'", name="ck_agent_plugins_owner_type"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("owner_user_id", "name", name="uq_agent_plugins_owner_name"), + schema=schema, + ) + op.create_index("ix_agent_plugins_owner_user_id", "agent_plugins", ["owner_user_id"], schema=schema) + op.create_index("ix_agent_plugins_enabled", "agent_plugins", ["enabled"], schema=schema) + op.create_table( + "agent_plugin_components", + sa.Column("id", sa.String(), nullable=False), + sa.Column("plugin_id", sa.String(), nullable=False), + sa.Column("component_type", sa.String(), nullable=False), + sa.Column("component_id", sa.String(), nullable=False), + sa.Column("component_key", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("component_type IN ('skill', 'mcp_server')", name="ck_agent_plugin_components_type"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("component_type", "component_id", name="uq_agent_plugin_components_lookup"), + schema=schema, + ) + op.create_index("ix_agent_plugin_components_plugin_id", "agent_plugin_components", ["plugin_id"], schema=schema) + op.create_index("ix_agent_plugin_components_lookup", "agent_plugin_components", ["component_type", "component_id"], schema=schema) + + +def downgrade() -> None: + """Remove portable plugin metadata while leaving standalone components.""" + schema = _app_schema() + op.drop_index("ix_agent_plugin_components_lookup", table_name="agent_plugin_components", schema=schema) + op.drop_index("ix_agent_plugin_components_plugin_id", table_name="agent_plugin_components", schema=schema) + op.drop_table("agent_plugin_components", schema=schema) + op.drop_index("ix_agent_plugins_enabled", table_name="agent_plugins", schema=schema) + op.drop_index("ix_agent_plugins_owner_user_id", table_name="agent_plugins", schema=schema) + op.drop_table("agent_plugins", schema=schema) diff --git a/backend/app/main.py b/backend/app/main.py index 2c8bd28..a41d6a4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -36,6 +36,7 @@ "chats", "logo", "profilepicture", + "plugins", "skills", "userFiles", ) @@ -95,6 +96,7 @@ ) from app.notes.router import notes_router from app.prompts.router import prompts_router +from app.plugins.router import plugins_router from app.llm.ollama.router import ollama_router from app.llm.lmstudio.router import lmstudio_router from app.llm.worker import start_llm_provider_worker, stop_llm_provider_worker @@ -623,6 +625,7 @@ def _load_prometheus_metrics_token() -> str: app.include_router(memories_router) app.include_router(notes_router) app.include_router(prompts_router) +app.include_router(plugins_router) app.include_router(llm_router) app.include_router(ollama_router) app.include_router(lmstudio_router) diff --git a/backend/app/mcp/models.py b/backend/app/mcp/models.py index 916b9de..15838af 100644 --- a/backend/app/mcp/models.py +++ b/backend/app/mcp/models.py @@ -344,6 +344,11 @@ def update_mcp_server(db, server_id: str, **updates) -> MCPServer: def delete_mcp_server(db, server_id: str) -> None: server = get_mcp_server(db, server_id) deleted_server_id = server.id + # Personal MCP settings can remove a plugin component independently. Keep + # the plugin aggregate consistent instead of retaining a dangling link. + from app.plugins.models import COMPONENT_MCP_SERVER, detach_plugin_component + + detach_plugin_component(db, COMPONENT_MCP_SERVER, server.id) # Redirect states deliberately have no database foreign key so migrations # work across the supported schemas. Remove them explicitly with the server. db.query(MCPOAuthState).filter(MCPOAuthState.server_id == server.id).delete( @@ -371,6 +376,14 @@ def list_mcp_servers(db, *, owner_type: str | None = None, owner_user_id: str | query = query.filter(MCPServer.enabled.is_(True)) if not include_managed: query = query.filter(MCPServer.managed_connection_id.is_(None)) + # A disabled plugin must remain disabled even if an older process or a + # direct MCP edit left its component row enabled. This fail-closed filter is + # applied to every discovery and execution path through this shared helper. + from app.plugins.models import COMPONENT_MCP_SERVER, disabled_plugin_component_ids + + disabled_ids = disabled_plugin_component_ids(db, COMPONENT_MCP_SERVER) + if disabled_ids: + query = query.filter(~MCPServer.id.in_(disabled_ids)) return query.order_by(MCPServer.name.asc(), MCPServer.created_at.asc()).all() diff --git a/backend/app/model_modules.py b/backend/app/model_modules.py index 02da1db..ae33bfb 100644 --- a/backend/app/model_modules.py +++ b/backend/app/model_modules.py @@ -17,6 +17,7 @@ "app.mcp.models", "app.memories.models", "app.notes.models", + "app.plugins.models", "app.projects.models", "app.prompts.models", "app.realtime.models", diff --git a/backend/app/plugins/__init__.py b/backend/app/plugins/__init__.py new file mode 100644 index 0000000..a12f592 --- /dev/null +++ b/backend/app/plugins/__init__.py @@ -0,0 +1 @@ +"""Portable OpenAI Agent Plugin support for Omlorix.""" diff --git a/backend/app/plugins/models.py b/backend/app/plugins/models.py new file mode 100644 index 0000000..4907913 --- /dev/null +++ b/backend/app/plugins/models.py @@ -0,0 +1,146 @@ +"""Database models and persistence helpers for portable agent plugins.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import uuid + +from fastapi import HTTPException +from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, Index, JSON, String, Text, UniqueConstraint +from sqlalchemy.exc import OperationalError + +from app.database import Base + + +OWNER_USER = "user" +COMPONENT_SKILL = "skill" +COMPONENT_MCP_SERVER = "mcp_server" + + +class AgentPlugin(Base): + """One user-owned plugin bundle and its compatibility metadata.""" + + __tablename__ = "agent_plugins" + __table_args__ = ( + CheckConstraint("owner_type = 'user'", name="ck_agent_plugins_owner_type"), + UniqueConstraint("owner_user_id", "name", name="uq_agent_plugins_owner_name"), + Index("ix_agent_plugins_owner_user_id", "owner_user_id"), + Index("ix_agent_plugins_enabled", "enabled"), + ) + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + owner_type = Column(String, nullable=False, default=OWNER_USER) + owner_user_id = Column(String, nullable=False) + name = Column(String, nullable=False) + version = Column(String, nullable=False) + description = Column(Text, nullable=True) + enabled = Column(Boolean, nullable=False, default=False) + content_sha256 = Column(String, nullable=False) + manifest = Column(JSON, nullable=False, default=dict) + compatibility = Column(JSON, nullable=False, default=dict) + created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class AgentPluginComponent(Base): + """Links plugin lifecycle operations to an installed Omlorix capability.""" + + __tablename__ = "agent_plugin_components" + __table_args__ = ( + CheckConstraint( + "component_type IN ('skill', 'mcp_server')", + name="ck_agent_plugin_components_type", + ), + Index("ix_agent_plugin_components_plugin_id", "plugin_id"), + Index("ix_agent_plugin_components_lookup", "component_type", "component_id"), + UniqueConstraint("component_type", "component_id", name="uq_agent_plugin_components_lookup"), + ) + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + plugin_id = Column(String, nullable=False) + component_type = Column(String, nullable=False) + component_id = Column(String, nullable=False) + component_key = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + + +def get_user_plugin(db, user_id: str, plugin_id: str) -> AgentPlugin: + """Return a plugin only when the authenticated user owns it.""" + plugin = ( + db.query(AgentPlugin) + .filter( + AgentPlugin.id == str(plugin_id or "").strip(), + AgentPlugin.owner_user_id == str(user_id or "").strip(), + ) + .first() + ) + if not plugin: + raise HTTPException(status_code=404, detail="Plugin not found.") + return plugin + + +def list_user_plugins(db, user_id: str) -> list[AgentPlugin]: + """List the user's installed plugins, newest first.""" + return ( + db.query(AgentPlugin) + .filter(AgentPlugin.owner_user_id == str(user_id or "").strip()) + .order_by(AgentPlugin.created_at.desc(), AgentPlugin.id.desc()) + .all() + ) + + +def plugin_component_is_enabled(db, component_type: str, component_id: str) -> bool: + """Return false only when a component belongs to a disabled plugin.""" + try: + row = ( + db.query(AgentPlugin.enabled) + .join(AgentPluginComponent, AgentPluginComponent.plugin_id == AgentPlugin.id) + .filter( + AgentPluginComponent.component_type == component_type, + AgentPluginComponent.component_id == str(component_id or "").strip(), + ) + .first() + ) + except OperationalError as exc: + # Some focused SQLite unit tests deliberately create only the legacy + # feature table. Preserve those partial-schema fixtures while never + # failing open for PostgreSQL or for any other database error. + db.rollback() + if db.get_bind().dialect.name == "sqlite" and "no such table" in str(exc).lower(): + return True + raise + return True if row is None else bool(row[0]) + + +def disabled_plugin_component_ids(db, component_type: str) -> set[str]: + """Return component IDs hidden by disabled plugin parents.""" + try: + rows = ( + db.query(AgentPluginComponent.component_id) + .join(AgentPlugin, AgentPlugin.id == AgentPluginComponent.plugin_id) + .filter( + AgentPluginComponent.component_type == component_type, + AgentPlugin.enabled.is_(False), + ) + .all() + ) + except OperationalError as exc: + db.rollback() + if db.get_bind().dialect.name == "sqlite" and "no such table" in str(exc).lower(): + return set() + raise + return {str(row[0]) for row in rows} + + +def detach_plugin_component(db, component_type: str, component_id: str) -> None: + """Remove a lifecycle link when its standalone component is deleted.""" + try: + db.query(AgentPluginComponent).filter( + AgentPluginComponent.component_type == component_type, + AgentPluginComponent.component_id == str(component_id or "").strip(), + ).delete(synchronize_session=False) + except OperationalError as exc: + db.rollback() + if db.get_bind().dialect.name == "sqlite" and "no such table" in str(exc).lower(): + return + raise diff --git a/backend/app/plugins/router.py b/backend/app/plugins/router.py new file mode 100644 index 0000000..d062032 --- /dev/null +++ b/backend/app/plugins/router.py @@ -0,0 +1,151 @@ +"""Authenticated lifecycle API for user-owned agent plugins.""" + +from __future__ import annotations + +import re + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from app.dependencies import get_db, get_db_log, verified_user +from app.logging.models import create_audit_log, get_audit_request_ip +from app.plugins.schemas import PluginEnabledRequest, PluginPreviewResponse, PluginResponse +from app.plugins.utils import ( + PLUGIN_MAX_ARCHIVE_BYTES, + delete_user_plugin, + get_plugin_archive, + install_user_plugin, + list_serialized_user_plugins, + preview_plugin_archive, + serialize_plugin, + set_plugin_enabled, +) + + +plugins_router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"]) + + +async def _read_upload(file: UploadFile) -> bytes: + """Read an upload incrementally and reject oversized requests early.""" + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > PLUGIN_MAX_ARCHIVE_BYTES: + raise HTTPException(status_code=413, detail="Plugin archive exceeds the upload size limit.") + chunks.append(chunk) + return b"".join(chunks) + + +@plugins_router.get("", response_model=list[PluginResponse]) +def list_plugins(db: Session = Depends(get_db), user=Depends(verified_user)): + """List plugins owned by the current user.""" + return list_serialized_user_plugins(db, user.id) + + +@plugins_router.post("/preview", response_model=PluginPreviewResponse) +async def preview_plugin(file: UploadFile = File(...), user=Depends(verified_user)): + """Validate and summarize a bundle without changing persistent state.""" + del user + try: + return preview_plugin_archive(await _read_upload(file)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@plugins_router.post("/install", response_model=PluginResponse, status_code=status.HTTP_201_CREATED) +async def install_plugin( + request: Request, + file: UploadFile = File(...), + db: Session = Depends(get_db), + db_log: Session = Depends(get_db_log), + user=Depends(verified_user), +): + """Install a validated bundle in an initially disabled state.""" + try: + plugin = install_user_plugin(db, user.id, await _read_upload(file)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + result = serialize_plugin(db, plugin) + create_audit_log( + db_log=db_log, + user_id=user.id, + action="agent_plugin_installed", + details={"plugin_id": plugin.id, "name": plugin.name, "version": plugin.version, "component_count": len(result["components"])}, + ip_address=get_audit_request_ip(request, db), + category="plugins", + ) + return result + + +@plugins_router.patch("/{plugin_id}/enabled", response_model=PluginResponse) +def update_plugin_enabled( + plugin_id: str, + payload: PluginEnabledRequest, + request: Request, + db: Session = Depends(get_db), + db_log: Session = Depends(get_db_log), + user=Depends(verified_user), +): + """Enable or disable all capabilities owned by a plugin.""" + plugin = set_plugin_enabled(db, user.id, plugin_id, payload.enabled) + create_audit_log( + db_log=db_log, + user_id=user.id, + action="agent_plugin_enabled" if payload.enabled else "agent_plugin_disabled", + details={"plugin_id": plugin.id, "name": plugin.name}, + ip_address=get_audit_request_ip(request, db), + category="plugins", + ) + return serialize_plugin(db, plugin) + + +@plugins_router.get("/{plugin_id}/export") +def export_plugin( + plugin_id: str, + request: Request, + db: Session = Depends(get_db), + db_log: Session = Depends(get_db_log), + user=Depends(verified_user), +): + """Export the original validated bundle without exposing separate stored secrets.""" + plugin, archive = get_plugin_archive(db, user.id, plugin_id) + safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "-", plugin.name).strip("-") or "plugin" + create_audit_log( + db_log=db_log, + user_id=user.id, + action="agent_plugin_exported", + details={"plugin_id": plugin.id, "name": plugin.name}, + ip_address=get_audit_request_ip(request, db), + category="plugins", + ) + return Response( + content=archive, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{safe_name}-{plugin.version}.zip"'}, + ) + + +@plugins_router.delete("/{plugin_id}", status_code=status.HTTP_204_NO_CONTENT) +def uninstall_plugin( + plugin_id: str, + request: Request, + db: Session = Depends(get_db), + db_log: Session = Depends(get_db_log), + user=Depends(verified_user), +): + """Remove a plugin and every capability it installed.""" + delete_user_plugin(db, user.id, plugin_id) + create_audit_log( + db_log=db_log, + user_id=user.id, + action="agent_plugin_uninstalled", + details={"plugin_id": plugin_id}, + ip_address=get_audit_request_ip(request, db), + category="plugins", + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/plugins/schemas.py b/backend/app/plugins/schemas.py new file mode 100644 index 0000000..9dbf435 --- /dev/null +++ b/backend/app/plugins/schemas.py @@ -0,0 +1,95 @@ +"""Pydantic contracts for plugin manifests and public API responses.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class PluginAuthor(BaseModel): + """Author metadata from ``.codex-plugin/plugin.json``.""" + + name: str = Field(min_length=1, max_length=200) + email: str | None = Field(default=None, max_length=320) + url: str | None = Field(default=None, max_length=2048) + model_config = ConfigDict(extra="allow", str_strip_whitespace=True) + + +class PluginManifest(BaseModel): + """Forward-compatible subset of the OpenAI plugin manifest.""" + + name: str = Field(min_length=1, max_length=120) + version: str = Field(min_length=1, max_length=64) + description: str = Field(min_length=1, max_length=4000) + author: PluginAuthor | None = None + homepage: str | None = Field(default=None, max_length=2048) + repository: str | None = Field(default=None, max_length=2048) + license: str | None = Field(default=None, max_length=200) + keywords: list[str] = Field(default_factory=list, max_length=100) + skills: list[str] = Field(default_factory=list, max_length=100) + mcpServers: str | list[str] | dict[str, Any] | None = None + apps: str | list[str] | dict[str, Any] | None = None + hooks: str | dict[str, Any] | None = None + model_config = ConfigDict(extra="allow", str_strip_whitespace=True) + + @field_validator("skills", mode="before") + @classmethod + def normalize_skills(cls, value: Any) -> list[str]: + """Accept one path or the standard array form without broad coercion.""" + if value is None: + return [] + if isinstance(value, str): + return [value] + if not isinstance(value, list): + raise ValueError("skills must be a path or an array of paths") + return [str(item) for item in value] + + +class PluginComponentResponse(BaseModel): + """A safe component summary without MCP credentials or skill contents.""" + + id: str + type: str + key: str + name: str + enabled: bool + +class PluginResponse(BaseModel): + """Plugin detail returned by lifecycle endpoints.""" + + id: str + name: str + version: str + description: str = "" + enabled: bool + content_sha256: str + author_name: str = "" + homepage: str = "" + repository: str = "" + components: list[PluginComponentResponse] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + created_at: str | None = None + updated_at: str | None = None + + +class PluginPreviewResponse(BaseModel): + """Validated archive summary shown before installation.""" + + name: str + version: str + description: str + author_name: str = "" + content_sha256: str + skill_names: list[str] = Field(default_factory=list) + mcp_server_names: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + requires_local_process_review: bool = False + has_hooks: bool = False + has_registered_apps: bool = False + + +class PluginEnabledRequest(BaseModel): + """Explicit lifecycle state transition requested by a user.""" + + enabled: bool diff --git a/backend/app/plugins/utils.py b/backend/app/plugins/utils.py new file mode 100644 index 0000000..eebe1eb --- /dev/null +++ b/backend/app/plugins/utils.py @@ -0,0 +1,487 @@ +"""Secure parsing, installation, export, and lifecycle helpers for plugins.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import io +import json +from pathlib import Path, PurePosixPath +import shutil +import stat +import zipfile + +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy.exc import IntegrityError + +from app.mcp.models import ( + OWNER_USER, + TRANSPORT_SSE, + TRANSPORT_STREAMABLE_HTTP, + create_mcp_server, + delete_mcp_server, + get_mcp_server, +) +from app.mcp.schemas import CreateMCPServerRequest +from app.paths import DATA_DIR +from app.plugins.models import ( + AgentPlugin, + AgentPluginComponent, + COMPONENT_MCP_SERVER, + COMPONENT_SKILL, + get_user_plugin, + list_user_plugins, +) +from app.plugins.schemas import PluginManifest +from app.skills.models import _skill_directory, delete_skill +from app.skills.utils import _write_archive_skill_assets, import_skill_from_markdown + + +PLUGINS_ROOT = DATA_DIR / "plugins" +PLUGIN_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 +PLUGIN_MAX_EXPANDED_BYTES = 200 * 1024 * 1024 +PLUGIN_MAX_FILE_BYTES = 25 * 1024 * 1024 +PLUGIN_MAX_ENTRIES = 2_000 +PLUGIN_MAX_COMPRESSION_RATIO = 200 +MANIFEST_PATH = PurePosixPath(".codex-plugin/plugin.json") + + +def _normalize_archive_name(name: str) -> PurePosixPath: + """Normalize an archive member and reject paths unsafe on any platform.""" + raw = str(name or "") + if not raw or "\x00" in raw or "\\" in raw: + raise ValueError("Plugin archive contains an invalid path.") + path = PurePosixPath(raw) + if path.is_absolute() or ".." in path.parts or any(part in {"", "."} for part in path.parts): + raise ValueError("Plugin archive paths must stay inside the bundle.") + if path.parts and ":" in path.parts[0]: + raise ValueError("Plugin archive contains an absolute Windows path.") + return path + + +def _validate_archive(archive: zipfile.ZipFile) -> dict[str, zipfile.ZipInfo]: + """Apply zip-slip, symlink, duplicate, zip-bomb, and size protections.""" + infos = archive.infolist() + if not infos or len(infos) > PLUGIN_MAX_ENTRIES: + raise ValueError(f"Plugin archives may contain at most {PLUGIN_MAX_ENTRIES} entries.") + total = 0 + by_name: dict[str, zipfile.ZipInfo] = {} + for info in infos: + path = _normalize_archive_name(info.filename) + normalized = path.as_posix().rstrip("/") + if normalized in by_name: + raise ValueError(f"Plugin archive contains duplicate path '{normalized}'.") + by_name[normalized] = info + unix_mode = (info.external_attr >> 16) & 0xFFFF + if stat.S_ISLNK(unix_mode): + raise ValueError("Plugin archives cannot contain symbolic links.") + if info.is_dir(): + continue + if info.file_size > PLUGIN_MAX_FILE_BYTES: + raise ValueError(f"Plugin file '{normalized}' exceeds the per-file size limit.") + total += info.file_size + if total > PLUGIN_MAX_EXPANDED_BYTES: + raise ValueError("Expanded plugin archive exceeds the size limit.") + if info.compress_size == 0 and info.file_size > 0: + raise ValueError("Plugin archive contains an invalid compressed entry.") + if info.compress_size and info.file_size / info.compress_size > PLUGIN_MAX_COMPRESSION_RATIO: + raise ValueError("Plugin archive contains a suspicious compression ratio.") + return by_name + + +def _locate_manifest(by_name: dict[str, zipfile.ZipInfo]) -> tuple[PurePosixPath, zipfile.ZipInfo]: + """Find exactly one plugin manifest, allowing one enclosing upload folder.""" + matches = [ + (PurePosixPath(name), info) + for name, info in by_name.items() + if PurePosixPath(name).parts[-2:] == MANIFEST_PATH.parts + ] + if len(matches) != 1: + raise ValueError("Plugin archive must contain exactly one .codex-plugin/plugin.json manifest.") + path, info = matches[0] + if info.file_size > 256_000: + raise ValueError("Plugin manifest exceeds the size limit.") + return path, info + + +def _safe_manifest_path(root: PurePosixPath, raw_path: str) -> PurePosixPath: + """Resolve a manifest-relative path without allowing bundle escapes.""" + value = str(raw_path or "").strip().replace("\\", "/") + while value.startswith("./"): + value = value[2:] + relative = PurePosixPath(value) + if not value or relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"Manifest path '{raw_path}' is unsafe.") + return root / relative + + +def _load_json(archive: zipfile.ZipFile, info: zipfile.ZipInfo, label: str) -> dict: + """Read a bounded UTF-8 JSON object with a helpful validation error.""" + try: + value = json.loads(archive.read(info).decode("utf-8-sig")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"{label} is not valid UTF-8 JSON.") from exc + if not isinstance(value, dict): + raise ValueError(f"{label} must contain a JSON object.") + return value + + +def _manifest_reference_paths(value) -> list[str]: + """Extract JSON file references from string/list manifest fields.""" + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [str(item) for item in value if isinstance(item, str)] + return [] + + +def _mcp_config_from_manifest(archive, by_name, root, manifest: PluginManifest) -> dict[str, dict]: + """Load direct or wrapped MCP server maps from all referenced files.""" + merged: dict[str, dict] = {} + value = manifest.mcpServers + if isinstance(value, dict): + source = value.get("mcpServers") if isinstance(value.get("mcpServers"), dict) else value + merged.update({str(key): item for key, item in source.items() if isinstance(item, dict)}) + for raw_path in _manifest_reference_paths(value): + path = _safe_manifest_path(root, raw_path) + info = by_name.get(path.as_posix()) + if not info: + raise ValueError(f"Referenced MCP configuration '{raw_path}' was not found.") + payload = _load_json(archive, info, "MCP configuration") + source = payload.get("mcpServers") if isinstance(payload.get("mcpServers"), dict) else payload + merged.update({str(key): item for key, item in source.items() if isinstance(item, dict)}) + return merged + + +def _skill_documents(archive, by_name, root, manifest: PluginManifest): + """Resolve declared skill folders or discover conventional skill folders.""" + documents: list[tuple[str, str, str]] = [] + declared = manifest.skills + if declared: + candidates = [] + for raw_path in declared: + folder = _safe_manifest_path(root, raw_path) + direct_document = folder / "SKILL.md" + if direct_document.as_posix() in by_name: + candidates.append((folder.name, direct_document)) + continue + + # The current manifest format also permits the conventional + # ``./skills`` container rather than enumerating each child. Only + # immediate child skill folders are discovered, keeping unrelated + # nested Markdown from becoming an executable capability. + child_documents = [ + (path.parent.name, path) + for name in by_name + for path in [PurePosixPath(name)] + if path.parent.parent == folder and path.name.lower() == "skill.md" + ] + if not child_documents: + raise ValueError(f"Declared skill path '{raw_path}' does not contain any SKILL.md files.") + candidates.extend(child_documents) + else: + candidates = [ + (path.parent.name, path) + for name in by_name + for path in [PurePosixPath(name)] + if len(path.parts) >= len(root.parts) + 3 + and path.parent.parent == root / "skills" + and path.name.lower() == "skill.md" + ] + seen: set[str] = set() + for key, path in candidates: + if path.as_posix() in seen: + continue + seen.add(path.as_posix()) + info = by_name.get(path.as_posix()) + if not info: + raise ValueError(f"Declared skill '{key}' does not contain SKILL.md.") + try: + markdown = archive.read(info).decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise ValueError(f"Skill '{key}' is not valid UTF-8.") from exc + documents.append((key, path.parent.as_posix(), markdown)) + return documents + + +def inspect_plugin_archive(payload: bytes) -> dict: + """Fully validate a plugin archive and return its staged installation plan.""" + if not payload or len(payload) > PLUGIN_MAX_ARCHIVE_BYTES: + raise ValueError("Plugin archive is empty or exceeds the upload size limit.") + try: + archive = zipfile.ZipFile(io.BytesIO(payload)) + except zipfile.BadZipFile as exc: + raise ValueError("Uploaded file is not a valid ZIP archive.") from exc + with archive: + by_name = _validate_archive(archive) + manifest_path, manifest_info = _locate_manifest(by_name) + try: + manifest = PluginManifest.model_validate(_load_json(archive, manifest_info, "Plugin manifest")) + except ValidationError as exc: + raise ValueError(f"Plugin manifest is invalid: {exc.errors()[0].get('msg', 'validation failed')}.") from exc + root = manifest_path.parent.parent + skills = _skill_documents(archive, by_name, root, manifest) + mcp_servers = _mcp_config_from_manifest(archive, by_name, root, manifest) + warnings: list[str] = [] + has_stdio = any(str(item.get("command") or "").strip() for item in mcp_servers.values()) + has_hooks = manifest.hooks is not None + has_apps = manifest.apps is not None + if has_stdio: + warnings.append("local_process_not_supported") + if has_hooks: + warnings.append("hooks_not_executed") + if has_apps: + warnings.append("registered_apps_require_mcp") + if not skills and not mcp_servers: + warnings.append("no_installable_components") + return { + "manifest": manifest, + "manifest_raw": _load_json(archive, manifest_info, "Plugin manifest"), + "root": root, + "skills": skills, + "mcp_servers": mcp_servers, + "warnings": warnings, + "has_stdio": has_stdio, + "has_hooks": has_hooks, + "has_apps": has_apps, + "content_sha256": hashlib.sha256(payload).hexdigest(), + } + + +def preview_plugin_archive(payload: bytes) -> dict: + """Return the safe, user-facing subset of an inspected archive.""" + plan = inspect_plugin_archive(payload) + manifest = plan["manifest"] + return { + "name": manifest.name, + "version": manifest.version, + "description": manifest.description, + "author_name": manifest.author.name if manifest.author else "", + "content_sha256": plan["content_sha256"], + "skill_names": [item[0] for item in plan["skills"]], + "mcp_server_names": sorted(plan["mcp_servers"]), + "warnings": plan["warnings"], + "requires_local_process_review": plan["has_stdio"], + "has_hooks": plan["has_hooks"], + "has_registered_apps": plan["has_apps"], + } + + +def _mcp_payload(name: str, config: dict) -> CreateMCPServerRequest: + """Translate Codex MCP JSON variants into Omlorix's provider-neutral model.""" + if str(config.get("command") or "").strip(): + raise ValueError(f"MCP server '{name}' launches a local process and cannot be installed personally.") + url = str(config.get("url") or config.get("serverUrl") or "").strip() + raw_type = str(config.get("type") or config.get("transport") or "").strip().lower() + transport = TRANSPORT_SSE if raw_type == "sse" else TRANSPORT_STREAMABLE_HTTP + return CreateMCPServerRequest( + owner_type=OWNER_USER, + name=name, + description=str(config.get("description") or "").strip() or None, + namespace=str(config.get("namespace") or name).strip(), + transport=transport, + enabled=False, + url=url, + args=[], + headers=config.get("headers") or {}, + env={}, + allowed_tools=config.get("allowedTools") or config.get("allowed_tools") or [], + timeout_seconds=config.get("timeoutSeconds") or config.get("timeout_seconds") or 30, + ) + + +def _plugin_storage(plugin_id: str) -> Path: + """Return the dedicated source archive directory for one plugin.""" + return PLUGINS_ROOT / str(plugin_id) + + +def install_user_plugin(db, user_id: str, payload: bytes) -> AgentPlugin: + """Install one validated bundle, compensating every committed sub-operation on failure.""" + plan = inspect_plugin_archive(payload) + # Plugin import must not become a back door around the same group controls + # enforced by the standalone Skills and MCP management endpoints. + if plan["skills"]: + from app.groups.init import ensure_data_control_permission, get_user_group_setting_value + + if not get_user_group_setting_value(user_id, "skills", "enabled_skills", db): + raise HTTPException(status_code=403, detail="Skills feature disabled for your group") + ensure_data_control_permission( + user_id, + "allow_skills", + db, + detail="Skills are disabled by your group's data controls.", + ) + if plan["mcp_servers"]: + from app.mcp.utils import require_group_mcp_enabled + + require_group_mcp_enabled(user_id, db) + if plan["has_stdio"]: + raise ValueError("Personal plugins cannot install local-process MCP servers.") + duplicate = ( + db.query(AgentPlugin) + .filter(AgentPlugin.owner_user_id == user_id, AgentPlugin.name == plan["manifest"].name) + .first() + ) + if duplicate: + raise ValueError("A plugin with this name is already installed. Uninstall it before installing another version.") + + manifest = plan["manifest"] + compatibility = { + "warnings": plan["warnings"], + "has_hooks": plan["has_hooks"], + "has_registered_apps": plan["has_apps"], + } + plugin = AgentPlugin( + owner_user_id=user_id, + name=manifest.name, + version=manifest.version, + description=manifest.description, + enabled=False, + content_sha256=plan["content_sha256"], + manifest=plan["manifest_raw"], + compatibility=compatibility, + ) + db.add(plugin) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise ValueError( + "A plugin with this name is already installed. Uninstall it before installing another version." + ) from exc + db.refresh(plugin) + # Keep the scalar separately because a rollback followed by compensation + # can expire or delete the ORM instance before filesystem cleanup runs. + plugin_id = str(plugin.id) + created: list[tuple[str, str]] = [] + try: + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + for key, folder_prefix, markdown in plan["skills"]: + skill = import_skill_from_markdown(db, user_id, markdown) + _write_archive_skill_assets(archive, folder_prefix, _skill_directory(user_id, skill.id)) + created.append((COMPONENT_SKILL, skill.id)) + db.add(AgentPluginComponent(plugin_id=plugin_id, component_type=COMPONENT_SKILL, component_id=skill.id, component_key=key)) + db.commit() + for key, config in plan["mcp_servers"].items(): + parsed = _mcp_payload(key, config) + server = create_mcp_server(db, owner_type=OWNER_USER, owner_user_id=user_id, **parsed.model_dump(exclude={"owner_type"})) + created.append((COMPONENT_MCP_SERVER, server.id)) + db.add(AgentPluginComponent(plugin_id=plugin_id, component_type=COMPONENT_MCP_SERVER, component_id=server.id, component_key=key)) + db.commit() + storage = _plugin_storage(plugin_id) + storage.mkdir(parents=True, exist_ok=False) + (storage / "bundle.zip").write_bytes(payload) + return plugin + except Exception: + # A failed component commit leaves SQLAlchemy's transaction unusable. + # Roll it back before attempting compensating deletes, otherwise the + # first cleanup would fail and leave an orphaned skill or MCP server. + db.rollback() + for component_type, component_id in reversed(created): + try: + if component_type == COMPONENT_SKILL: + delete_skill(db, user_id, component_id) + else: + delete_mcp_server(db, component_id) + except Exception: + db.rollback() + db.query(AgentPluginComponent).filter(AgentPluginComponent.plugin_id == plugin_id).delete(synchronize_session=False) + db.query(AgentPlugin).filter(AgentPlugin.id == plugin_id).delete(synchronize_session=False) + db.commit() + shutil.rmtree(_plugin_storage(plugin_id), ignore_errors=True) + raise + + +def set_plugin_enabled(db, user_id: str, plugin_id: str, enabled: bool) -> AgentPlugin: + """Atomically expose or hide a plugin and synchronize its MCP server rows.""" + plugin = get_user_plugin(db, user_id, plugin_id) + plugin.enabled = bool(enabled) + plugin.updated_at = datetime.now(timezone.utc) + component_ids = [ + row.component_id + for row in db.query(AgentPluginComponent).filter( + AgentPluginComponent.plugin_id == plugin.id, + AgentPluginComponent.component_type == COMPONENT_MCP_SERVER, + ) + ] + if component_ids: + from app.mcp.models import MCPServer + db.query(MCPServer).filter( + MCPServer.id.in_(component_ids), + MCPServer.owner_type == OWNER_USER, + MCPServer.owner_user_id == user_id, + ).update( + {MCPServer.enabled: bool(enabled)}, synchronize_session=False + ) + db.commit() + db.refresh(plugin) + return plugin + + +def delete_user_plugin(db, user_id: str, plugin_id: str) -> None: + """Uninstall every owned component before removing bundle metadata.""" + plugin = get_user_plugin(db, user_id, plugin_id) + components = db.query(AgentPluginComponent).filter(AgentPluginComponent.plugin_id == plugin.id).all() + for component in components: + if component.component_type == COMPONENT_SKILL: + delete_skill(db, user_id, component.component_id) + elif component.component_type == COMPONENT_MCP_SERVER: + server = get_mcp_server(db, component.component_id) + if server.owner_user_id != user_id: + raise HTTPException(status_code=403, detail="Plugin component ownership mismatch.") + delete_mcp_server(db, component.component_id) + db.query(AgentPluginComponent).filter(AgentPluginComponent.plugin_id == plugin.id).delete(synchronize_session=False) + db.delete(plugin) + db.commit() + shutil.rmtree(_plugin_storage(plugin.id), ignore_errors=True) + + +def serialize_plugin(db, plugin: AgentPlugin) -> dict: + """Serialize a plugin with safe component summaries.""" + from app.mcp.models import MCPServer + from app.skills.models import Skills + + rows = db.query(AgentPluginComponent).filter(AgentPluginComponent.plugin_id == plugin.id).all() + components = [] + for row in rows: + if row.component_type == COMPONENT_SKILL: + item = db.query(Skills).filter(Skills.id == row.component_id).first() + if item: + components.append({"id": item.id, "type": row.component_type, "key": row.component_key, "name": item.name, "enabled": bool(plugin.enabled)}) + else: + item = db.query(MCPServer).filter(MCPServer.id == row.component_id).first() + if item: + components.append({"id": item.id, "type": row.component_type, "key": row.component_key, "name": item.name, "enabled": bool(item.enabled and plugin.enabled)}) + manifest = plugin.manifest if isinstance(plugin.manifest, dict) else {} + author = manifest.get("author") if isinstance(manifest.get("author"), dict) else {} + compatibility = plugin.compatibility if isinstance(plugin.compatibility, dict) else {} + return { + "id": plugin.id, + "name": plugin.name, + "version": plugin.version, + "description": plugin.description or "", + "enabled": bool(plugin.enabled), + "content_sha256": plugin.content_sha256, + "author_name": str(author.get("name") or ""), + "homepage": str(manifest.get("homepage") or ""), + "repository": str(manifest.get("repository") or ""), + "components": components, + "warnings": list(compatibility.get("warnings") or []), + "created_at": plugin.created_at.isoformat() if plugin.created_at else None, + "updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None, + } + + +def list_serialized_user_plugins(db, user_id: str) -> list[dict]: + """List serialized plugins for API responses and account export.""" + return [serialize_plugin(db, item) for item in list_user_plugins(db, user_id)] + + +def get_plugin_archive(db, user_id: str, plugin_id: str) -> tuple[AgentPlugin, bytes]: + """Read the original validated bundle for lossless round-trip export.""" + plugin = get_user_plugin(db, user_id, plugin_id) + path = _plugin_storage(plugin.id) / "bundle.zip" + if not path.is_file(): + raise HTTPException(status_code=410, detail="Plugin source archive is unavailable.") + return plugin, path.read_bytes() diff --git a/backend/app/skills/models.py b/backend/app/skills/models.py index a911c4a..2ba41c0 100644 --- a/backend/app/skills/models.py +++ b/backend/app/skills/models.py @@ -289,6 +289,13 @@ def delete_skill(db: Session, user_id: str, skill_id: str): Also removes all subscriptions to this skill. """ skill = _get_skill(db, user_id, skill_id) + + # A user may delete a plugin-installed skill from the ordinary Skills UI. + # Detach it from the aggregate first so later plugin uninstall remains + # idempotent and never fails on an already-removed component. + from app.plugins.models import COMPONENT_SKILL, detach_plugin_component + + detach_plugin_component(db, COMPONENT_SKILL, skill_id) # Remove all subscriptions to this skill db.query(SharedSkillSubscription).filter( @@ -949,6 +956,14 @@ def _resolve_accessible_skill_for_user( if not skill_id: return None + # Plugin-owned skills follow the aggregate lifecycle. Keeping this check at + # the shared resolver covers prompt context and skill-file attachment paths + # without relying on a frontend toggle as a security boundary. + from app.plugins.models import COMPONENT_SKILL, plugin_component_is_enabled + + if not plugin_component_is_enabled(db, COMPONENT_SKILL, skill_id): + return None + from app.skills.queries import skill_access access, _ = skill_access(user_id) skill = db.query(Skills).filter(Skills.id == skill_id, access).first() diff --git a/backend/app/users/models.py b/backend/app/users/models.py index f080e0c..d1aebc2 100644 --- a/backend/app/users/models.py +++ b/backend/app/users/models.py @@ -848,6 +848,8 @@ def hard_delete_user( from app.mcp.models import MCPOAuthState, MCPServer from app.memories.models import Memory from app.notes.models import NoteHistory, Notes, SharedNoteSubscription + from app.plugins.models import AgentPlugin, AgentPluginComponent + from app.plugins.utils import _plugin_storage from app.projects.models import ( Project, ProjectMember, @@ -975,6 +977,29 @@ def hard_delete_user( .delete(synchronize_session=False) ) + # Remove aggregate plugin metadata before its ordinary skill/MCP rows. + # Components are deleted by the existing account cleanup below, while + # source archives are queued for post-commit filesystem cleanup. + user_plugin_ids = [ + plugin_id + for (plugin_id,) in db.query(AgentPlugin.id) + .filter(AgentPlugin.owner_user_id == user_id) + .all() + ] + if user_plugin_ids: + db.query(AgentPluginComponent).filter( + AgentPluginComponent.plugin_id.in_(user_plugin_ids) + ).delete(synchronize_session=False) + db.query(AgentPlugin).filter(AgentPlugin.id.in_(user_plugin_ids)).delete( + synchronize_session=False + ) + for plugin_id in user_plugin_ids: + post_commit_cleanup_actions.append( + lambda plugin_id=plugin_id: shutil.rmtree( + _plugin_storage(plugin_id), ignore_errors=True + ) + ) + # Delete user-owned skills and both inbound/outbound subscriptions. user_skill_ids = [ skill_id diff --git a/backend/tests/migrations/test_agent_plugins_migration.py b/backend/tests/migrations/test_agent_plugins_migration.py new file mode 100644 index 0000000..43262da --- /dev/null +++ b/backend/tests/migrations/test_agent_plugins_migration.py @@ -0,0 +1,29 @@ +"""Migration coverage for portable agent plugin persistence.""" + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from alembic_main.versions import add_agent_plugins_20260808 as migration + + +def test_agent_plugin_migration_round_trip(monkeypatch): + """Both aggregate tables can be created and removed on SQLite.""" + engine = sa.create_engine("sqlite:///:memory:") + with engine.begin() as connection: + operations = Operations(MigrationContext.configure(connection)) + monkeypatch.setattr(migration, "op", operations) + + migration.upgrade() + names = set(sa.inspect(connection).get_table_names()) + assert {"agent_plugins", "agent_plugin_components"} <= names + + plugin_columns = { + column["name"] for column in sa.inspect(connection).get_columns("agent_plugins") + } + assert {"manifest", "compatibility", "content_sha256", "enabled"} <= plugin_columns + + migration.downgrade() + names = set(sa.inspect(connection).get_table_names()) + assert "agent_plugins" not in names + assert "agent_plugin_components" not in names diff --git a/backend/tests/plugins/test_plugin_archives.py b/backend/tests/plugins/test_plugin_archives.py new file mode 100644 index 0000000..1f925d9 --- /dev/null +++ b/backend/tests/plugins/test_plugin_archives.py @@ -0,0 +1,236 @@ +"""Focused security and compatibility tests for portable plugin archives.""" + +from __future__ import annotations + +import io +import json +import zipfile + +import pytest +import sqlalchemy as sa +from cryptography.fernet import Fernet +from sqlalchemy.orm import sessionmaker + +from app.database import Base +from app.mcp.models import MCPOAuthState, MCPServer +from app.plugins.models import AgentPlugin, AgentPluginComponent +from app.plugins.utils import ( + delete_user_plugin, + get_plugin_archive, + inspect_plugin_archive, + install_user_plugin, + preview_plugin_archive, + set_plugin_enabled, +) +from app.skills.models import SharedSkillSubscription, Skills, get_skill_context_for_user + + +def _archive(files: dict[str, str]) -> bytes: + """Build a small in-memory test archive without touching application data.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in files.items(): + archive.writestr(name, content) + return buffer.getvalue() + + +def _manifest(**updates) -> str: + """Return a minimal current OpenAI plugin manifest.""" + payload = { + "name": "research-kit", + "version": "1.2.0", + "description": "Research helpers", + "author": {"name": "Omlorix Tests"}, + "skills": ["./skills/research-kit"], + "mcpServers": "./.mcp.json", + } + payload.update(updates) + return json.dumps(payload) + + +def test_preview_accepts_enclosing_folder_and_wrapped_mcp_map(): + """Common GitHub ZIP wrapping and wrapped MCP JSON remain portable.""" + payload = _archive( + { + "research-kit/.codex-plugin/plugin.json": _manifest(hooks={"PreToolUse": []}), + "research-kit/skills/research-kit/SKILL.md": ( + "---\nname: research-kit\ndescription: Research carefully\n---\n\nUse primary sources.\n" + ), + "research-kit/.mcp.json": json.dumps( + {"mcpServers": {"search": {"type": "http", "url": "https://example.com/mcp"}}} + ), + } + ) + + preview = preview_plugin_archive(payload) + + assert preview["name"] == "research-kit" + assert preview["skill_names"] == ["research-kit"] + assert preview["mcp_server_names"] == ["search"] + assert preview["has_hooks"] is True + assert "hooks_not_executed" in preview["warnings"] + + +def test_manifest_can_declare_the_conventional_skills_container(): + """A single ./skills path discovers each immediate child skill folder.""" + payload = _archive( + { + ".codex-plugin/plugin.json": _manifest(skills="./skills", mcpServers=None), + "skills/alpha/SKILL.md": "---\nname: alpha\ndescription: Alpha skill\n---\n", + "skills/beta/SKILL.md": "---\nname: beta\ndescription: Beta skill\n---\n", + } + ) + + preview = preview_plugin_archive(payload) + + assert sorted(preview["skill_names"]) == ["alpha", "beta"] + + +def test_archive_rejects_path_traversal_before_manifest_processing(): + """A valid manifest cannot make a ZIP-slip member acceptable.""" + payload = _archive( + { + ".codex-plugin/plugin.json": _manifest(skills=[], mcpServers=None), + "../outside.txt": "nope", + } + ) + + with pytest.raises(ValueError, match="stay inside"): + inspect_plugin_archive(payload) + + +def test_personal_stdio_server_is_flagged_for_review(): + """Local commands are detected during preview, before installation writes.""" + payload = _archive( + { + ".codex-plugin/plugin.json": _manifest(skills=[]), + ".mcp.json": json.dumps( + {"mcpServers": {"local": {"command": "node", "args": ["server.js"]}}} + ), + } + ) + + preview = preview_plugin_archive(payload) + + assert preview["requires_local_process_review"] is True + assert "local_process_not_supported" in preview["warnings"] + + +def test_manifest_reference_cannot_escape_bundle_root(): + """Manifest paths receive the same traversal protection as ZIP members.""" + payload = _archive( + { + ".codex-plugin/plugin.json": _manifest(skills=["../secret"]), + ".mcp.json": json.dumps({"mcpServers": {}}), + } + ) + + with pytest.raises(ValueError, match="unsafe"): + inspect_plugin_archive(payload) + + +def test_skill_plugin_lifecycle_controls_runtime_and_round_trip(tmp_path, monkeypatch): + """Install, enable, export, and uninstall act on one aggregate.""" + import app.plugins.utils as plugin_utils + import app.skills.models as skill_models + import app.skills.utils as skill_utils + import app.utils.encryption as encryption + + monkeypatch.setattr(plugin_utils, "PLUGINS_ROOT", tmp_path / "plugins") + monkeypatch.setattr(skill_models, "SKILLS_ROOT", tmp_path / "skills") + monkeypatch.setattr(skill_utils, "SKILLS_ROOT", tmp_path / "skills") + monkeypatch.setattr(encryption, "_ENCRYPTION_KEY", Fernet.generate_key()) + monkeypatch.setattr(encryption, "_CIPHER_SUITE", None) + # This focused lifecycle test has no user/group tables. Policy enforcement + # is exercised by the established group helpers at the authenticated route + # boundary, so stub their two shared lookups here. + monkeypatch.setattr("app.groups.init.get_user_group_setting_value", lambda *args, **kwargs: True) + monkeypatch.setattr("app.groups.init.ensure_data_control_permission", lambda *args, **kwargs: None) + monkeypatch.setattr("app.mcp.utils.require_group_mcp_enabled", lambda *args, **kwargs: None) + engine = sa.create_engine("sqlite:///:memory:") + Base.metadata.create_all( + engine, + tables=[ + Skills.__table__, + SharedSkillSubscription.__table__, + AgentPlugin.__table__, + AgentPluginComponent.__table__, + MCPServer.__table__, + MCPOAuthState.__table__, + ], + ) + db = sessionmaker(bind=engine)() + payload = _archive( + { + ".codex-plugin/plugin.json": _manifest(), + ".mcp.json": json.dumps( + {"mcpServers": {"search": {"type": "http", "url": "https://example.com/mcp"}}} + ), + "skills/research-kit/SKILL.md": ( + "---\nname: research-kit\ndescription: Research carefully\n---\n\nUse primary sources.\n" + ), + "skills/research-kit/references/checklist.txt": "Confirm dates and authors.", + } + ) + + plugin = install_user_plugin(db, "user-1", payload) + components = db.query(AgentPluginComponent).all() + skill_component = next(item for item in components if item.component_type == "skill") + mcp_component = next(item for item in components if item.component_type == "mcp_server") + assert plugin.enabled is False + assert get_skill_context_for_user(db, "user-1", skill_component.component_id) is None + assert db.query(MCPServer).filter(MCPServer.id == mcp_component.component_id).one().enabled is False + + set_plugin_enabled(db, "user-1", plugin.id, True) + context = get_skill_context_for_user(db, "user-1", skill_component.component_id) + assert "Use primary sources" in context + assert "Confirm dates and authors" in context + assert db.query(MCPServer).filter(MCPServer.id == mcp_component.component_id).one().enabled is True + assert get_plugin_archive(db, "user-1", plugin.id)[1] == payload + + delete_user_plugin(db, "user-1", plugin.id) + assert db.query(AgentPlugin).count() == 0 + assert db.query(AgentPluginComponent).count() == 0 + assert db.query(Skills).count() == 0 + assert db.query(MCPServer).count() == 0 + assert not (tmp_path / "plugins" / plugin.id).exists() + + +def test_failed_component_install_compensates_prior_skill(tmp_path, monkeypatch): + """A later invalid MCP definition cannot leave a half-installed skill.""" + import app.plugins.utils as plugin_utils + import app.skills.models as skill_models + import app.skills.utils as skill_utils + + monkeypatch.setattr(plugin_utils, "PLUGINS_ROOT", tmp_path / "plugins") + monkeypatch.setattr(skill_models, "SKILLS_ROOT", tmp_path / "skills") + monkeypatch.setattr(skill_utils, "SKILLS_ROOT", tmp_path / "skills") + monkeypatch.setattr("app.groups.init.get_user_group_setting_value", lambda *args, **kwargs: True) + monkeypatch.setattr("app.groups.init.ensure_data_control_permission", lambda *args, **kwargs: None) + monkeypatch.setattr("app.mcp.utils.require_group_mcp_enabled", lambda *args, **kwargs: None) + engine = sa.create_engine("sqlite:///:memory:") + Base.metadata.create_all( + engine, + tables=[ + Skills.__table__, SharedSkillSubscription.__table__, + AgentPlugin.__table__, AgentPluginComponent.__table__, + ], + ) + db = sessionmaker(bind=engine)() + payload = _archive( + { + ".codex-plugin/plugin.json": _manifest(), + ".mcp.json": json.dumps({"mcpServers": {"broken": {"type": "http"}}}), + "skills/research-kit/SKILL.md": ( + "---\nname: research-kit\ndescription: Research carefully\n---\n\nUse primary sources.\n" + ), + } + ) + + with pytest.raises(ValueError): + install_user_plugin(db, "user-1", payload) + + assert db.query(AgentPlugin).count() == 0 + assert db.query(AgentPluginComponent).count() == 0 + assert db.query(Skills).count() == 0 + assert not (tmp_path / "skills" / "user-1").exists() diff --git a/frontend/css/chat/plugins.css b/frontend/css/chat/plugins.css new file mode 100644 index 0000000..4fc9577 --- /dev/null +++ b/frontend/css/chat/plugins.css @@ -0,0 +1,119 @@ +.plugins-workspace { + max-width: 1120px; + margin: 0 auto; +} + +.plugins-header, +.plugins-header-actions, +.plugins-card-heading, +.plugins-card-actions, +.plugins-card-identity, +.plugins-review-heading, +.plugins-review-actions, +.plugins-security-note { + display: flex; + align-items: center; +} + +.plugins-card-identity { gap: 10px; min-width: 0; } + +.plugins-header, +.plugins-card-heading, +.plugins-review-heading { + justify-content: space-between; + gap: 16px; +} + +.plugins-security-note { + gap: 10px; + padding: 12px 14px; + margin: 18px 0; + color: var(--text-color-secondary); + background: var(--bg-normal); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} + +.plugins-security-note p { margin: 0; } +.plugins-security-note-icon svg, +.plugins-empty-icon svg, +.plugins-card-icon svg { width: 22px; height: 22px; } + +.plugins-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 14px; +} + +.plugins-card { + min-width: 0; + padding: 18px; + background: var(--background-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} + +.plugins-card-title-wrap { min-width: 0; } +.plugins-card-title { margin: 0; color: var(--text-color); font-size: 16px; } +.plugins-card-version { color: var(--text-color-tertiary); font-size: 12px; } +.plugins-card-description { color: var(--text-color-secondary); min-height: 42px; } +.plugins-card-components { display: flex; flex-wrap: wrap; gap: 6px; padding: 0; list-style: none; } +.plugins-component-chip { padding: 4px 8px; color: var(--text-color-secondary); background: var(--bg-normal); border-radius: 999px; font-size: 12px; } +.plugins-card-actions { flex-wrap: wrap; gap: 8px; margin-top: 16px; } + +.plugins-state { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-color-secondary); + font-size: 13px; +} + +.plugins-state::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--text-color-tertiary); } +.plugins-card[data-enabled="true"] .plugins-state::before { background: var(--primary-color); } + +.plugins-empty { padding: 64px 20px; text-align: center; color: var(--text-color-secondary); } +.plugins-empty h2 { color: var(--text-color); } +.plugins-empty-icon { margin-bottom: 14px; } +.plugins-status { min-height: 20px; color: var(--text-color-secondary); } + +.plugins-review-overlay { + position: fixed; + inset: 0; + z-index: 1600; + display: grid; + place-items: center; + padding: 20px; + background: rgb(var(--background-color-rgb) / 0.72); +} +.plugins-review-overlay[hidden] { display: none; } +.plugins-review-dialog { + width: min(620px, 100%); + max-height: min(760px, 90vh); + overflow-y: auto; + padding: 22px; + color: var(--text-color); + background: var(--background-color); + border: 1px solid var(--modal-border-strong); + border-radius: var(--border-radius); +} +.plugins-review-eyebrow { margin: 0 0 4px; color: var(--text-color-tertiary); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; } +.plugins-review-heading h2 { margin: 0; } +.plugins-icon-button { border: 0; padding: 8px 12px; color: var(--text-color); background: transparent; font-size: 24px; border-radius: var(--border-radius); } +.plugins-review-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.plugins-review-meta > div { padding: 10px; background: var(--bg-normal); border-radius: var(--border-radius); } +.plugins-review-meta dt { color: var(--text-color-tertiary); font-size: 12px; } +.plugins-review-meta dd { margin: 4px 0 0; overflow-wrap: anywhere; } +.plugins-review-warnings { color: var(--error-color); } +.plugins-review-disabled-note { color: var(--text-color-secondary); } +.plugins-review-actions { justify-content: flex-end; gap: 8px; } + +@media (hover: hover) and (pointer: fine) { + .plugins-icon-button:hover { background: var(--bg-hover); } +} + +@media (max-width: 600px) { + .plugins-header { align-items: flex-start; flex-direction: column; } + .plugins-review-meta { grid-template-columns: 1fr; } + .plugins-grid { grid-template-columns: 1fr; } +} diff --git a/frontend/i18n/ar/index.json b/frontend/i18n/ar/index.json index 408e3d6..d364ee4 100644 --- a/frontend/i18n/ar/index.json +++ b/frontend/i18n/ar/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "أوقفت OpenAI هذه المحادثة لإجراء مراجعة أمنية. راجع الإجراءات التي نُفذت بالفعل مع المشغّل المسؤول. لا يمكن إعادة محاولة سير العمل هذا.", "chat_openai_tools_require_responses": "يتطلب هذا النموذج موفّر OpenAI Responses لاستخدام الأدوات. غيّر الموفّر قبل متابعة هذه المحادثة.", "llm.openai.tools_require_responses": "استخدم موفّر OpenAI Responses لتفعيل الأدوات لهذا النموذج.", - "chat_context_budget_exceeded": "يتجاوز هذا الطلب حد السياق للنموذج. أزل المرفقات أو اختصر الطلب أو اختر نموذجًا يدعم سياقًا أكبر.", + "chat_context_budget_exceeded": "يتجاوز هذا الطلب حد السياق للنموذج. أزل المرفقات أو اختصر الطلب أو اختر نموذجًا يدعم سياقًا أكبر.", "us_personal_info_email_verification_sent": "أُرسلت روابط التحقق. يبقى بريدك الحالي نشطًا حتى التحقق من العنوان الجديد.", "us_personal_info_email_change_unavailable": "تعذّر بدء تغيير البريد الإلكتروني. تحقّق من العنوان أو اطلب من مسؤول التحقق من إعدادات إرسال البريد.", "email_change_processing_title": "جارٍ تحديث عنوان البريد الإلكتروني", @@ -4797,5 +4797,45 @@ "chat_workspace_back": "العودة إلى المحادثة", "chat_workspace_close": "إخفاء لوحة التفاصيل", "chat_workspace_hide": "إخفاء", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "الإضافات", + "workspace_plugins_title": "إضافات الوكلاء", + "workspace_plugins_subtitle": "ثبّت حزم إضافات محمولة تتضمن مهارات وخوادم MCP.", + "workspace_plugins_install": "تثبيت إضافة", + "workspace_plugins_security_note": "تُراجع الإضافات قبل التثبيت. لا يشغّل Omlorix الخطافات مطلقًا، ولا يمكن للإضافات الشخصية بدء عمليات محلية.", + "workspace_plugins_empty_title": "لا توجد إضافات مثبتة", + "workspace_plugins_empty_desc": "ثبّت حزمة .zip تتضمن بيان .codex-plugin/plugin.json.", + "workspace_plugins_review_eyebrow": "مراجعة التثبيت", + "workspace_plugins_version": "الإصدار", + "workspace_plugins_author": "المؤلف", + "workspace_plugins_skills": "المهارات", + "workspace_plugins_servers": "خوادم MCP", + "workspace_plugins_installs_disabled": "ستُثبّت الإضافة معطّلة. فعّلها بعد مراجعة مكوناتها.", + "workspace_plugins_confirm_install": "التثبيت معطّلة", + "workspace_plugins_enabled": "مفعّلة", + "workspace_plugins_disabled": "معطّلة", + "workspace_plugins_enable": "تفعيل", + "workspace_plugins_disable": "تعطيل", + "workspace_plugins_export": "تصدير", + "workspace_plugins_homepage": "الصفحة الرئيسية", + "workspace_plugins_uninstall": "إلغاء التثبيت", + "workspace_plugins_uninstall_title": "إلغاء تثبيت الإضافة؟", + "workspace_plugins_uninstall_desc": "ستُحذف الإضافة وكل المهارات وخوادم MCP التي ثبّتها.", + "workspace_plugins_loading": "جارٍ تحميل الإضافات…", + "workspace_plugins_reviewing": "جارٍ مراجعة الإضافة…", + "workspace_plugins_updating": "جارٍ تحديث الإضافة…", + "workspace_plugins_uninstalling": "جارٍ إلغاء تثبيت الإضافة…", + "workspace_plugins_installed_success": "ثُبّتت الإضافة معطّلة.", + "workspace_plugins_enabled_success": "فُعّلت الإضافة.", + "workspace_plugins_disabled_success": "عُطّلت الإضافة.", + "workspace_plugins_uninstalled_success": "أُلغي تثبيت الإضافة.", + "workspace_plugins_no_description": "لا يوجد وصف.", + "workspace_plugins_skill": "مهارة", + "workspace_plugins_server": "خادم MCP", + "workspace_plugins_unknown": "غير معروف", + "workspace_plugins_request_error": "فشل طلب الإضافة.", + "workspace_plugins_warning_local_process": "لا يمكن تثبيت خوادم MCP التي تشغّل عمليات محلية ضمن الإضافات الشخصية.", + "workspace_plugins_warning_hooks": "تُحفظ الخطافات لضمان قابلية النقل، لكن Omlorix لا يشغّلها.", + "workspace_plugins_warning_apps": "تُحفظ معرّفات تطبيقات OpenAI المسجلة، لكنها تتطلب واجهة تطبيق يستضيفها MCP في Omlorix.", + "workspace_plugins_warning_empty": "لا تعلن هذه الحزمة عن مهارة أو خادم MCP قابل للتثبيت." } diff --git a/frontend/i18n/de/index.json b/frontend/i18n/de/index.json index 3d671e2..fc854d7 100644 --- a/frontend/i18n/de/index.json +++ b/frontend/i18n/de/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAI hat diese Unterhaltung zur Sicherheitsprüfung angehalten. Prüfe die bereits ausgeführten Aktionen gemeinsam mit der verantwortlichen Person. Dieser Ablauf kann nicht erneut gestartet werden.", "chat_openai_tools_require_responses": "Dieses Modell benötigt für Tools den Anbieter OpenAI Responses. Wechsle den Anbieter, bevor du diese Unterhaltung fortsetzt.", "llm.openai.tools_require_responses": "Verwende den Anbieter OpenAI Responses, um Tools für dieses Modell zu aktivieren.", - "chat_context_budget_exceeded": "Diese Anfrage überschreitet das Kontextlimit des Modells. Entferne Anhänge, kürze die Anfrage oder wähle ein Modell mit größerem Kontext.", + "chat_context_budget_exceeded": "Diese Anfrage überschreitet das Kontextlimit des Modells. Entferne Anhänge, kürze die Anfrage oder wähle ein Modell mit größerem Kontext.", "us_personal_info_email_verification_sent": "Die Bestätigungslinks wurden gesendet. Deine aktuelle E-Mail-Adresse bleibt aktiv, bis die neue bestätigt wurde.", "us_personal_info_email_change_unavailable": "Die E-Mail-Änderung konnte nicht gestartet werden. Prüfe die Adresse oder bitte einen Administrator, die E-Mail-Zustellung zu überprüfen.", "email_change_processing_title": "E-Mail-Adresse wird aktualisiert", @@ -4777,5 +4777,45 @@ "chat_workspace_back": "Zurück zum Chat", "chat_workspace_close": "Detailbereich ausblenden", "chat_workspace_hide": "Ausblenden", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "Plugins", + "workspace_plugins_title": "Agenten-Plugins", + "workspace_plugins_subtitle": "Portable Plugin-Pakete mit Skills und MCP-Servern installieren.", + "workspace_plugins_install": "Plugin installieren", + "workspace_plugins_security_note": "Plugins werden vor der Installation geprüft. Hooks werden in Omlorix nie ausgeführt, und persönliche Plugins dürfen keine lokalen Prozesse starten.", + "workspace_plugins_empty_title": "Keine Plugins installiert", + "workspace_plugins_empty_desc": "Installiere ein ZIP-Paket mit einem .codex-plugin/plugin.json-Manifest.", + "workspace_plugins_review_eyebrow": "Installationsprüfung", + "workspace_plugins_version": "Version", + "workspace_plugins_author": "Autor", + "workspace_plugins_skills": "Skills", + "workspace_plugins_servers": "MCP-Server", + "workspace_plugins_installs_disabled": "Das Plugin wird deaktiviert installiert. Aktiviere es nach Prüfung seiner Komponenten.", + "workspace_plugins_confirm_install": "Deaktiviert installieren", + "workspace_plugins_enabled": "Aktiviert", + "workspace_plugins_disabled": "Deaktiviert", + "workspace_plugins_enable": "Aktivieren", + "workspace_plugins_disable": "Deaktivieren", + "workspace_plugins_export": "Exportieren", + "workspace_plugins_homepage": "Homepage", + "workspace_plugins_uninstall": "Deinstallieren", + "workspace_plugins_uninstall_title": "Plugin deinstallieren?", + "workspace_plugins_uninstall_desc": "Das Plugin und alle dadurch installierten Skills und MCP-Server werden entfernt.", + "workspace_plugins_loading": "Plugins werden geladen…", + "workspace_plugins_reviewing": "Plugin wird geprüft…", + "workspace_plugins_updating": "Plugin wird aktualisiert…", + "workspace_plugins_uninstalling": "Plugin wird deinstalliert…", + "workspace_plugins_installed_success": "Plugin wurde deaktiviert installiert.", + "workspace_plugins_enabled_success": "Plugin aktiviert.", + "workspace_plugins_disabled_success": "Plugin deaktiviert.", + "workspace_plugins_uninstalled_success": "Plugin deinstalliert.", + "workspace_plugins_no_description": "Keine Beschreibung vorhanden.", + "workspace_plugins_skill": "Skill", + "workspace_plugins_server": "MCP-Server", + "workspace_plugins_unknown": "Unbekannt", + "workspace_plugins_request_error": "Plugin-Anfrage fehlgeschlagen.", + "workspace_plugins_warning_local_process": "MCP-Server mit lokalen Prozessen können nicht in persönlichen Plugins installiert werden.", + "workspace_plugins_warning_hooks": "Hooks bleiben für die Portabilität erhalten, werden von Omlorix aber nicht ausgeführt.", + "workspace_plugins_warning_apps": "Registrierte OpenAI-App-IDs bleiben erhalten, benötigen in Omlorix jedoch eine über MCP bereitgestellte App-Oberfläche.", + "workspace_plugins_warning_empty": "Dieses Paket enthält keinen installierbaren Skill und keinen MCP-Server." } diff --git a/frontend/i18n/en/index.json b/frontend/i18n/en/index.json index 77da886..8e92252 100644 --- a/frontend/i18n/en/index.json +++ b/frontend/i18n/en/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAI stopped this conversation for safety review. Review the actions already taken with the responsible operator. This workflow cannot be retried.", "chat_openai_tools_require_responses": "This model requires the OpenAI Responses provider for tools. Change the provider before continuing this conversation.", "llm.openai.tools_require_responses": "Use the OpenAI Responses provider to enable tools for this model.", - "chat_context_budget_exceeded": "This request exceeds the model's context limit. Remove attachments, reduce the request, or choose a model with a larger context.", + "chat_context_budget_exceeded": "This request exceeds the model's context limit. Remove attachments, reduce the request, or choose a model with a larger context.", "us_personal_info_email_verification_sent": "Verification links were sent. Your current email remains active until the new address is verified.", "us_personal_info_email_change_unavailable": "The email change could not be started. Check the address or ask an administrator to verify email delivery settings.", "email_change_processing_title": "Updating email address", @@ -4777,5 +4777,45 @@ "chat_workspace_back": "Back to chat", "chat_workspace_close": "Hide details panel", "chat_workspace_hide": "Hide", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "Plugins", + "workspace_plugins_title": "Agent Plugins", + "workspace_plugins_subtitle": "Install portable plugin bundles containing skills and MCP servers.", + "workspace_plugins_install": "Install plugin", + "workspace_plugins_security_note": "Plugins are reviewed before installation. Hooks never run in Omlorix, and personal plugins cannot start local processes.", + "workspace_plugins_empty_title": "No plugins installed", + "workspace_plugins_empty_desc": "Install a .zip bundle with a .codex-plugin/plugin.json manifest.", + "workspace_plugins_review_eyebrow": "Installation review", + "workspace_plugins_version": "Version", + "workspace_plugins_author": "Author", + "workspace_plugins_skills": "Skills", + "workspace_plugins_servers": "MCP servers", + "workspace_plugins_installs_disabled": "The plugin will be installed disabled. Enable it after reviewing its components.", + "workspace_plugins_confirm_install": "Install disabled", + "workspace_plugins_enabled": "Enabled", + "workspace_plugins_disabled": "Disabled", + "workspace_plugins_enable": "Enable", + "workspace_plugins_disable": "Disable", + "workspace_plugins_export": "Export", + "workspace_plugins_homepage": "Homepage", + "workspace_plugins_uninstall": "Uninstall", + "workspace_plugins_uninstall_title": "Uninstall plugin?", + "workspace_plugins_uninstall_desc": "The plugin and all skills and MCP servers it installed will be removed.", + "workspace_plugins_loading": "Loading plugins…", + "workspace_plugins_reviewing": "Reviewing plugin…", + "workspace_plugins_updating": "Updating plugin…", + "workspace_plugins_uninstalling": "Uninstalling plugin…", + "workspace_plugins_installed_success": "Plugin installed disabled.", + "workspace_plugins_enabled_success": "Plugin enabled.", + "workspace_plugins_disabled_success": "Plugin disabled.", + "workspace_plugins_uninstalled_success": "Plugin uninstalled.", + "workspace_plugins_no_description": "No description provided.", + "workspace_plugins_skill": "Skill", + "workspace_plugins_server": "MCP server", + "workspace_plugins_unknown": "Unknown", + "workspace_plugins_request_error": "Plugin request failed.", + "workspace_plugins_warning_local_process": "Local-process MCP servers cannot be installed in personal plugins.", + "workspace_plugins_warning_hooks": "Hooks are preserved for portability but are not executed by Omlorix.", + "workspace_plugins_warning_apps": "Registered OpenAI app IDs are preserved but require an MCP-hosted app UI in Omlorix.", + "workspace_plugins_warning_empty": "This bundle does not declare an installable skill or MCP server." } diff --git a/frontend/i18n/es/index.json b/frontend/i18n/es/index.json index 1a8991a..c2c69d6 100644 --- a/frontend/i18n/es/index.json +++ b/frontend/i18n/es/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAI ha detenido esta conversación para una revisión de seguridad. Revisa las acciones ya realizadas con la persona responsable. Este flujo de trabajo no se puede reintentar.", "chat_openai_tools_require_responses": "Este modelo requiere el proveedor OpenAI Responses para usar herramientas. Cambia de proveedor antes de continuar esta conversación.", "llm.openai.tools_require_responses": "Usa el proveedor OpenAI Responses para habilitar herramientas en este modelo.", - "chat_context_budget_exceeded": "Esta solicitud supera el límite de contexto del modelo. Elimina archivos adjuntos, acorta la solicitud o elige un modelo con un contexto más amplio.", + "chat_context_budget_exceeded": "Esta solicitud supera el límite de contexto del modelo. Elimina archivos adjuntos, acorta la solicitud o elige un modelo con un contexto más amplio.", "us_personal_info_email_verification_sent": "Se enviaron los enlaces de verificación. Tu correo actual seguirá activo hasta que se verifique el nuevo.", "us_personal_info_email_change_unavailable": "No se pudo iniciar el cambio de correo. Comprueba la dirección o pide a un administrador que revise la configuración de envío.", "email_change_processing_title": "Actualizando dirección de correo", @@ -4782,5 +4782,45 @@ "chat_workspace_back": "Volver al chat", "chat_workspace_close": "Ocultar panel de detalles", "chat_workspace_hide": "Ocultar", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "Complementos", + "workspace_plugins_title": "Complementos de agente", + "workspace_plugins_subtitle": "Instala paquetes portátiles con habilidades y servidores MCP.", + "workspace_plugins_install": "Instalar complemento", + "workspace_plugins_security_note": "Los complementos se revisan antes de instalarlos. Omlorix nunca ejecuta hooks y los complementos personales no pueden iniciar procesos locales.", + "workspace_plugins_empty_title": "No hay complementos instalados", + "workspace_plugins_empty_desc": "Instala un archivo .zip con un manifiesto .codex-plugin/plugin.json.", + "workspace_plugins_review_eyebrow": "Revisión de instalación", + "workspace_plugins_version": "Versión", + "workspace_plugins_author": "Autor", + "workspace_plugins_skills": "Habilidades", + "workspace_plugins_servers": "Servidores MCP", + "workspace_plugins_installs_disabled": "El complemento se instalará desactivado. Actívalo tras revisar sus componentes.", + "workspace_plugins_confirm_install": "Instalar desactivado", + "workspace_plugins_enabled": "Activado", + "workspace_plugins_disabled": "Desactivado", + "workspace_plugins_enable": "Activar", + "workspace_plugins_disable": "Desactivar", + "workspace_plugins_export": "Exportar", + "workspace_plugins_homepage": "Página principal", + "workspace_plugins_uninstall": "Desinstalar", + "workspace_plugins_uninstall_title": "¿Desinstalar complemento?", + "workspace_plugins_uninstall_desc": "Se eliminarán el complemento y todas las habilidades y servidores MCP que instaló.", + "workspace_plugins_loading": "Cargando complementos…", + "workspace_plugins_reviewing": "Revisando complemento…", + "workspace_plugins_updating": "Actualizando complemento…", + "workspace_plugins_uninstalling": "Desinstalando complemento…", + "workspace_plugins_installed_success": "Complemento instalado desactivado.", + "workspace_plugins_enabled_success": "Complemento activado.", + "workspace_plugins_disabled_success": "Complemento desactivado.", + "workspace_plugins_uninstalled_success": "Complemento desinstalado.", + "workspace_plugins_no_description": "Sin descripción.", + "workspace_plugins_skill": "Habilidad", + "workspace_plugins_server": "Servidor MCP", + "workspace_plugins_unknown": "Desconocido", + "workspace_plugins_request_error": "Falló la solicitud del complemento.", + "workspace_plugins_warning_local_process": "Los servidores MCP con procesos locales no se pueden instalar en complementos personales.", + "workspace_plugins_warning_hooks": "Los hooks se conservan para la portabilidad, pero Omlorix no los ejecuta.", + "workspace_plugins_warning_apps": "Los ID de aplicaciones de OpenAI se conservan, pero requieren una interfaz alojada por MCP en Omlorix.", + "workspace_plugins_warning_empty": "Este paquete no declara ninguna habilidad ni servidor MCP instalable." } diff --git a/frontend/i18n/fr/index.json b/frontend/i18n/fr/index.json index a25a51c..19ad52f 100644 --- a/frontend/i18n/fr/index.json +++ b/frontend/i18n/fr/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAI a arrêté cette conversation pour un examen de sécurité. Examinez les actions déjà effectuées avec la personne responsable. Ce processus ne peut pas être relancé.", "chat_openai_tools_require_responses": "Ce modèle nécessite le fournisseur OpenAI Responses pour utiliser des outils. Changez de fournisseur avant de poursuivre cette conversation.", "llm.openai.tools_require_responses": "Utilisez le fournisseur OpenAI Responses pour activer les outils de ce modèle.", - "chat_context_budget_exceeded": "Cette demande dépasse la limite de contexte du modèle. Retirez des pièces jointes, raccourcissez la demande ou choisissez un modèle avec un contexte plus large.", + "chat_context_budget_exceeded": "Cette demande dépasse la limite de contexte du modèle. Retirez des pièces jointes, raccourcissez la demande ou choisissez un modèle avec un contexte plus large.", "us_personal_info_email_verification_sent": "Les liens de vérification ont été envoyés. Votre adresse actuelle reste active jusqu’à la vérification de la nouvelle.", "us_personal_info_email_change_unavailable": "La modification de l’adresse e-mail n’a pas pu démarrer. Vérifiez l’adresse ou demandez à un administrateur de contrôler la configuration d’envoi.", "email_change_processing_title": "Mise à jour de l’adresse e-mail", @@ -4782,5 +4782,45 @@ "chat_workspace_back": "Retour à la conversation", "chat_workspace_close": "Masquer le panneau de détails", "chat_workspace_hide": "Masquer", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "Extensions", + "workspace_plugins_title": "Extensions d’agent", + "workspace_plugins_subtitle": "Installez des paquets portables contenant des compétences et des serveurs MCP.", + "workspace_plugins_install": "Installer une extension", + "workspace_plugins_security_note": "Les extensions sont examinées avant installation. Omlorix n’exécute jamais les hooks et les extensions personnelles ne peuvent pas lancer de processus locaux.", + "workspace_plugins_empty_title": "Aucune extension installée", + "workspace_plugins_empty_desc": "Installez une archive .zip avec un manifeste .codex-plugin/plugin.json.", + "workspace_plugins_review_eyebrow": "Examen de l’installation", + "workspace_plugins_version": "Version", + "workspace_plugins_author": "Auteur", + "workspace_plugins_skills": "Compétences", + "workspace_plugins_servers": "Serveurs MCP", + "workspace_plugins_installs_disabled": "L’extension sera installée désactivée. Activez-la après avoir examiné ses composants.", + "workspace_plugins_confirm_install": "Installer désactivée", + "workspace_plugins_enabled": "Activée", + "workspace_plugins_disabled": "Désactivée", + "workspace_plugins_enable": "Activer", + "workspace_plugins_disable": "Désactiver", + "workspace_plugins_export": "Exporter", + "workspace_plugins_homepage": "Site web", + "workspace_plugins_uninstall": "Désinstaller", + "workspace_plugins_uninstall_title": "Désinstaller l’extension ?", + "workspace_plugins_uninstall_desc": "L’extension et toutes les compétences et tous les serveurs MCP installés seront supprimés.", + "workspace_plugins_loading": "Chargement des extensions…", + "workspace_plugins_reviewing": "Examen de l’extension…", + "workspace_plugins_updating": "Mise à jour de l’extension…", + "workspace_plugins_uninstalling": "Désinstallation de l’extension…", + "workspace_plugins_installed_success": "Extension installée désactivée.", + "workspace_plugins_enabled_success": "Extension activée.", + "workspace_plugins_disabled_success": "Extension désactivée.", + "workspace_plugins_uninstalled_success": "Extension désinstallée.", + "workspace_plugins_no_description": "Aucune description.", + "workspace_plugins_skill": "Compétence", + "workspace_plugins_server": "Serveur MCP", + "workspace_plugins_unknown": "Inconnu", + "workspace_plugins_request_error": "Échec de la requête d’extension.", + "workspace_plugins_warning_local_process": "Les serveurs MCP à processus local ne peuvent pas être installés dans des extensions personnelles.", + "workspace_plugins_warning_hooks": "Les hooks sont conservés pour la portabilité, mais Omlorix ne les exécute pas.", + "workspace_plugins_warning_apps": "Les identifiants d’app OpenAI sont conservés, mais exigent une interface hébergée par MCP dans Omlorix.", + "workspace_plugins_warning_empty": "Ce paquet ne déclare aucune compétence ni aucun serveur MCP installable." } diff --git a/frontend/i18n/hi/index.json b/frontend/i18n/hi/index.json index 5a342a1..1d69741 100644 --- a/frontend/i18n/hi/index.json +++ b/frontend/i18n/hi/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAI ने सुरक्षा समीक्षा के लिए यह बातचीत रोक दी है। ज़िम्मेदार ऑपरेटर के साथ पहले से की गई कार्रवाइयों की समीक्षा करें। इस कार्यप्रवाह को दोबारा नहीं चलाया जा सकता।", "chat_openai_tools_require_responses": "इस मॉडल में टूल इस्तेमाल करने के लिए OpenAI Responses प्रदाता ज़रूरी है। यह बातचीत जारी रखने से पहले प्रदाता बदलें।", "llm.openai.tools_require_responses": "इस मॉडल के लिए टूल सक्षम करने के लिए OpenAI Responses प्रदाता इस्तेमाल करें।", - "chat_context_budget_exceeded": "यह अनुरोध मॉडल की संदर्भ सीमा से अधिक है। अटैचमेंट हटाएँ, अनुरोध छोटा करें या अधिक संदर्भ क्षमता वाला मॉडल चुनें।", + "chat_context_budget_exceeded": "यह अनुरोध मॉडल की संदर्भ सीमा से अधिक है। अटैचमेंट हटाएँ, अनुरोध छोटा करें या अधिक संदर्भ क्षमता वाला मॉडल चुनें।", "us_personal_info_email_verification_sent": "सत्यापन लिंक भेज दिए गए हैं। नया पता सत्यापित होने तक आपका मौजूदा ईमेल सक्रिय रहेगा।", "us_personal_info_email_change_unavailable": "ईमेल बदलने की प्रक्रिया शुरू नहीं हो सकी। पता जाँचें या किसी व्यवस्थापक से ईमेल डिलीवरी सेटिंग सत्यापित करने को कहें।", "email_change_processing_title": "ईमेल पता अपडेट हो रहा है", @@ -4777,5 +4777,45 @@ "chat_workspace_back": "चैट पर वापस जाएँ", "chat_workspace_close": "विवरण पैनल छिपाएँ", "chat_workspace_hide": "छिपाएँ", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "प्लगइन", + "workspace_plugins_title": "एजेंट प्लगइन", + "workspace_plugins_subtitle": "कौशल और MCP सर्वर वाले पोर्टेबल प्लगइन बंडल इंस्टॉल करें।", + "workspace_plugins_install": "प्लगइन इंस्टॉल करें", + "workspace_plugins_security_note": "इंस्टॉल करने से पहले प्लगइन की समीक्षा की जाती है। Omlorix हुक नहीं चलाता और निजी प्लगइन स्थानीय प्रोसेस शुरू नहीं कर सकते।", + "workspace_plugins_empty_title": "कोई प्लगइन इंस्टॉल नहीं है", + "workspace_plugins_empty_desc": ".codex-plugin/plugin.json मैनिफेस्ट वाला .zip बंडल इंस्टॉल करें।", + "workspace_plugins_review_eyebrow": "इंस्टॉलेशन समीक्षा", + "workspace_plugins_version": "संस्करण", + "workspace_plugins_author": "लेखक", + "workspace_plugins_skills": "कौशल", + "workspace_plugins_servers": "MCP सर्वर", + "workspace_plugins_installs_disabled": "प्लगइन निष्क्रिय अवस्था में इंस्टॉल होगा। घटकों की समीक्षा के बाद इसे सक्रिय करें।", + "workspace_plugins_confirm_install": "निष्क्रिय इंस्टॉल करें", + "workspace_plugins_enabled": "सक्रिय", + "workspace_plugins_disabled": "निष्क्रिय", + "workspace_plugins_enable": "सक्रिय करें", + "workspace_plugins_disable": "निष्क्रिय करें", + "workspace_plugins_export": "निर्यात", + "workspace_plugins_homepage": "मुखपृष्ठ", + "workspace_plugins_uninstall": "अनइंस्टॉल", + "workspace_plugins_uninstall_title": "प्लगइन अनइंस्टॉल करें?", + "workspace_plugins_uninstall_desc": "प्लगइन और उसके इंस्टॉल किए सभी कौशल और MCP सर्वर हटा दिए जाएंगे।", + "workspace_plugins_loading": "प्लगइन लोड हो रहे हैं…", + "workspace_plugins_reviewing": "प्लगइन की समीक्षा हो रही है…", + "workspace_plugins_updating": "प्लगइन अपडेट हो रहा है…", + "workspace_plugins_uninstalling": "प्लगइन अनइंस्टॉल हो रहा है…", + "workspace_plugins_installed_success": "प्लगइन निष्क्रिय इंस्टॉल हुआ।", + "workspace_plugins_enabled_success": "प्लगइन सक्रिय हुआ।", + "workspace_plugins_disabled_success": "प्लगइन निष्क्रिय हुआ।", + "workspace_plugins_uninstalled_success": "प्लगइन अनइंस्टॉल हुआ।", + "workspace_plugins_no_description": "कोई विवरण नहीं।", + "workspace_plugins_skill": "कौशल", + "workspace_plugins_server": "MCP सर्वर", + "workspace_plugins_unknown": "अज्ञात", + "workspace_plugins_request_error": "प्लगइन अनुरोध विफल हुआ।", + "workspace_plugins_warning_local_process": "स्थानीय प्रोसेस वाले MCP सर्वर निजी प्लगइन में इंस्टॉल नहीं किए जा सकते।", + "workspace_plugins_warning_hooks": "पोर्टेबिलिटी के लिए हुक सुरक्षित रखे जाते हैं, लेकिन Omlorix उन्हें नहीं चलाता।", + "workspace_plugins_warning_apps": "पंजीकृत OpenAI ऐप ID सुरक्षित रहते हैं, पर Omlorix में MCP-होस्टेड ऐप UI चाहिए।", + "workspace_plugins_warning_empty": "इस बंडल में कोई इंस्टॉल योग्य कौशल या MCP सर्वर घोषित नहीं है।" } diff --git a/frontend/i18n/it/index.json b/frontend/i18n/it/index.json index de6277a..d5f32bb 100644 --- a/frontend/i18n/it/index.json +++ b/frontend/i18n/it/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAI ha interrotto questa conversazione per una verifica di sicurezza. Esamina le azioni già eseguite con la persona responsabile. Questo flusso di lavoro non può essere riprovato.", "chat_openai_tools_require_responses": "Questo modello richiede il provider OpenAI Responses per usare gli strumenti. Cambia provider prima di continuare questa conversazione.", "llm.openai.tools_require_responses": "Usa il provider OpenAI Responses per abilitare gli strumenti per questo modello.", - "chat_context_budget_exceeded": "Questa richiesta supera il limite di contesto del modello. Rimuovi gli allegati, riduci la richiesta o scegli un modello con un contesto più ampio.", + "chat_context_budget_exceeded": "Questa richiesta supera il limite di contesto del modello. Rimuovi gli allegati, riduci la richiesta o scegli un modello con un contesto più ampio.", "us_personal_info_email_verification_sent": "I link di verifica sono stati inviati. L’indirizzo attuale resta attivo finché il nuovo non viene verificato.", "us_personal_info_email_change_unavailable": "Non è stato possibile avviare la modifica dell’email. Controlla l’indirizzo o chiedi a un amministratore di verificare le impostazioni di invio.", "email_change_processing_title": "Aggiornamento indirizzo email", @@ -4782,5 +4782,45 @@ "chat_workspace_back": "Torna alla chat", "chat_workspace_close": "Nascondi il pannello dei dettagli", "chat_workspace_hide": "Nascondi", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "Plugin", + "workspace_plugins_title": "Plugin agente", + "workspace_plugins_subtitle": "Installa pacchetti portatili con abilità e server MCP.", + "workspace_plugins_install": "Installa plugin", + "workspace_plugins_security_note": "I plugin vengono esaminati prima dell’installazione. Omlorix non esegue mai gli hook e i plugin personali non possono avviare processi locali.", + "workspace_plugins_empty_title": "Nessun plugin installato", + "workspace_plugins_empty_desc": "Installa un archivio .zip con un manifesto .codex-plugin/plugin.json.", + "workspace_plugins_review_eyebrow": "Verifica installazione", + "workspace_plugins_version": "Versione", + "workspace_plugins_author": "Autore", + "workspace_plugins_skills": "Abilità", + "workspace_plugins_servers": "Server MCP", + "workspace_plugins_installs_disabled": "Il plugin sarà installato disattivato. Attivalo dopo averne verificato i componenti.", + "workspace_plugins_confirm_install": "Installa disattivato", + "workspace_plugins_enabled": "Attivato", + "workspace_plugins_disabled": "Disattivato", + "workspace_plugins_enable": "Attiva", + "workspace_plugins_disable": "Disattiva", + "workspace_plugins_export": "Esporta", + "workspace_plugins_homepage": "Sito web", + "workspace_plugins_uninstall": "Disinstalla", + "workspace_plugins_uninstall_title": "Disinstallare il plugin?", + "workspace_plugins_uninstall_desc": "Il plugin e tutte le abilità e i server MCP installati verranno rimossi.", + "workspace_plugins_loading": "Caricamento plugin…", + "workspace_plugins_reviewing": "Verifica plugin…", + "workspace_plugins_updating": "Aggiornamento plugin…", + "workspace_plugins_uninstalling": "Disinstallazione plugin…", + "workspace_plugins_installed_success": "Plugin installato disattivato.", + "workspace_plugins_enabled_success": "Plugin attivato.", + "workspace_plugins_disabled_success": "Plugin disattivato.", + "workspace_plugins_uninstalled_success": "Plugin disinstallato.", + "workspace_plugins_no_description": "Nessuna descrizione.", + "workspace_plugins_skill": "Abilità", + "workspace_plugins_server": "Server MCP", + "workspace_plugins_unknown": "Sconosciuto", + "workspace_plugins_request_error": "Richiesta del plugin non riuscita.", + "workspace_plugins_warning_local_process": "I server MCP con processi locali non possono essere installati nei plugin personali.", + "workspace_plugins_warning_hooks": "Gli hook vengono conservati per la portabilità, ma Omlorix non li esegue.", + "workspace_plugins_warning_apps": "Gli ID delle app OpenAI vengono conservati, ma richiedono un’interfaccia ospitata da MCP in Omlorix.", + "workspace_plugins_warning_empty": "Questo pacchetto non dichiara abilità o server MCP installabili." } diff --git a/frontend/i18n/ja/index.json b/frontend/i18n/ja/index.json index 3ec77d2..2afb7e8 100644 --- a/frontend/i18n/ja/index.json +++ b/frontend/i18n/ja/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAIは安全性の確認のため、この会話を停止しました。責任者とともに、すでに実行された操作を確認してください。このワークフローは再試行できません。", "chat_openai_tools_require_responses": "このモデルでツールを使うには、OpenAI Responsesプロバイダーが必要です。この会話を続ける前にプロバイダーを変更してください。", "llm.openai.tools_require_responses": "このモデルのツールを有効にするには、OpenAI Responsesプロバイダーを使用してください。", - "chat_context_budget_exceeded": "このリクエストはモデルのコンテキスト上限を超えています。添付ファイルを削除するか、リクエストを短くするか、より大きなコンテキストに対応したモデルを選んでください。", + "chat_context_budget_exceeded": "このリクエストはモデルのコンテキスト上限を超えています。添付ファイルを削除するか、リクエストを短くするか、より大きなコンテキストに対応したモデルを選んでください。", "us_personal_info_email_verification_sent": "確認リンクを送信しました。新しいアドレスが確認されるまで、現在のメールアドレスが有効です。", "us_personal_info_email_change_unavailable": "メールアドレスの変更を開始できませんでした。アドレスを確認するか、管理者にメール配信設定の確認を依頼してください。", "email_change_processing_title": "メールアドレスを更新しています", @@ -4777,5 +4777,45 @@ "chat_workspace_back": "チャットに戻る", "chat_workspace_close": "詳細パネルを非表示", "chat_workspace_hide": "非表示", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "プラグイン", + "workspace_plugins_title": "エージェントプラグイン", + "workspace_plugins_subtitle": "スキルと MCP サーバーを含む移植可能なプラグインバンドルをインストールします。", + "workspace_plugins_install": "プラグインをインストール", + "workspace_plugins_security_note": "プラグインはインストール前に確認されます。Omlorix はフックを実行せず、個人用プラグインはローカルプロセスを起動できません。", + "workspace_plugins_empty_title": "プラグインはありません", + "workspace_plugins_empty_desc": ".codex-plugin/plugin.json マニフェストを含む .zip バンドルをインストールしてください。", + "workspace_plugins_review_eyebrow": "インストール確認", + "workspace_plugins_version": "バージョン", + "workspace_plugins_author": "作成者", + "workspace_plugins_skills": "スキル", + "workspace_plugins_servers": "MCP サーバー", + "workspace_plugins_installs_disabled": "プラグインは無効な状態でインストールされます。コンポーネントを確認してから有効にしてください。", + "workspace_plugins_confirm_install": "無効でインストール", + "workspace_plugins_enabled": "有効", + "workspace_plugins_disabled": "無効", + "workspace_plugins_enable": "有効にする", + "workspace_plugins_disable": "無効にする", + "workspace_plugins_export": "エクスポート", + "workspace_plugins_homepage": "ホームページ", + "workspace_plugins_uninstall": "アンインストール", + "workspace_plugins_uninstall_title": "プラグインをアンインストールしますか?", + "workspace_plugins_uninstall_desc": "プラグインと、それがインストールしたすべてのスキルおよび MCP サーバーが削除されます。", + "workspace_plugins_loading": "プラグインを読み込み中…", + "workspace_plugins_reviewing": "プラグインを確認中…", + "workspace_plugins_updating": "プラグインを更新中…", + "workspace_plugins_uninstalling": "プラグインをアンインストール中…", + "workspace_plugins_installed_success": "プラグインを無効な状態でインストールしました。", + "workspace_plugins_enabled_success": "プラグインを有効にしました。", + "workspace_plugins_disabled_success": "プラグインを無効にしました。", + "workspace_plugins_uninstalled_success": "プラグインをアンインストールしました。", + "workspace_plugins_no_description": "説明はありません。", + "workspace_plugins_skill": "スキル", + "workspace_plugins_server": "MCP サーバー", + "workspace_plugins_unknown": "不明", + "workspace_plugins_request_error": "プラグインの要求に失敗しました。", + "workspace_plugins_warning_local_process": "ローカルプロセス型 MCP サーバーは個人用プラグインにインストールできません。", + "workspace_plugins_warning_hooks": "フックは移植性のため保持されますが、Omlorix では実行されません。", + "workspace_plugins_warning_apps": "登録済み OpenAI アプリ ID は保持されますが、Omlorix では MCP が提供するアプリ UI が必要です。", + "workspace_plugins_warning_empty": "このバンドルにはインストール可能なスキルまたは MCP サーバーがありません。" } diff --git a/frontend/i18n/pt/index.json b/frontend/i18n/pt/index.json index e0d319b..2dcea04 100644 --- a/frontend/i18n/pt/index.json +++ b/frontend/i18n/pt/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "A OpenAI interrompeu esta conversa para uma revisão de segurança. Reveja as ações já realizadas com a pessoa responsável. Este fluxo de trabalho não pode ser repetido.", "chat_openai_tools_require_responses": "Este modelo requer o provedor OpenAI Responses para usar ferramentas. Altere o provedor antes de continuar esta conversa.", "llm.openai.tools_require_responses": "Use o provedor OpenAI Responses para ativar ferramentas neste modelo.", - "chat_context_budget_exceeded": "Esta solicitação excede o limite de contexto do modelo. Remova anexos, reduza a solicitação ou escolha um modelo com um contexto maior.", + "chat_context_budget_exceeded": "Esta solicitação excede o limite de contexto do modelo. Remova anexos, reduza a solicitação ou escolha um modelo com um contexto maior.", "us_personal_info_email_verification_sent": "As ligações de verificação foram enviadas. O email atual permanece ativo até o novo ser verificado.", "us_personal_info_email_change_unavailable": "Não foi possível iniciar a alteração do email. Verifique o endereço ou peça a um administrador para confirmar as definições de envio.", "email_change_processing_title": "A atualizar o endereço de email", @@ -4782,5 +4782,45 @@ "chat_workspace_back": "Voltar à conversa", "chat_workspace_close": "Ocultar painel de detalhes", "chat_workspace_hide": "Ocultar", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "Plugins", + "workspace_plugins_title": "Plugins de agente", + "workspace_plugins_subtitle": "Instale pacotes portáteis com habilidades e servidores MCP.", + "workspace_plugins_install": "Instalar plugin", + "workspace_plugins_security_note": "Os plugins são revistos antes da instalação. O Omlorix nunca executa hooks, e plugins pessoais não podem iniciar processos locais.", + "workspace_plugins_empty_title": "Nenhum plugin instalado", + "workspace_plugins_empty_desc": "Instale um pacote .zip com um manifesto .codex-plugin/plugin.json.", + "workspace_plugins_review_eyebrow": "Revisão da instalação", + "workspace_plugins_version": "Versão", + "workspace_plugins_author": "Autor", + "workspace_plugins_skills": "Habilidades", + "workspace_plugins_servers": "Servidores MCP", + "workspace_plugins_installs_disabled": "O plugin será instalado desativado. Ative-o após rever os componentes.", + "workspace_plugins_confirm_install": "Instalar desativado", + "workspace_plugins_enabled": "Ativado", + "workspace_plugins_disabled": "Desativado", + "workspace_plugins_enable": "Ativar", + "workspace_plugins_disable": "Desativar", + "workspace_plugins_export": "Exportar", + "workspace_plugins_homepage": "Página inicial", + "workspace_plugins_uninstall": "Desinstalar", + "workspace_plugins_uninstall_title": "Desinstalar plugin?", + "workspace_plugins_uninstall_desc": "O plugin e todas as habilidades e servidores MCP instalados por ele serão removidos.", + "workspace_plugins_loading": "A carregar plugins…", + "workspace_plugins_reviewing": "A rever plugin…", + "workspace_plugins_updating": "A atualizar plugin…", + "workspace_plugins_uninstalling": "A desinstalar plugin…", + "workspace_plugins_installed_success": "Plugin instalado desativado.", + "workspace_plugins_enabled_success": "Plugin ativado.", + "workspace_plugins_disabled_success": "Plugin desativado.", + "workspace_plugins_uninstalled_success": "Plugin desinstalado.", + "workspace_plugins_no_description": "Sem descrição.", + "workspace_plugins_skill": "Habilidade", + "workspace_plugins_server": "Servidor MCP", + "workspace_plugins_unknown": "Desconhecido", + "workspace_plugins_request_error": "Falha no pedido do plugin.", + "workspace_plugins_warning_local_process": "Servidores MCP com processos locais não podem ser instalados em plugins pessoais.", + "workspace_plugins_warning_hooks": "Os hooks são preservados para portabilidade, mas não são executados pelo Omlorix.", + "workspace_plugins_warning_apps": "Os IDs de apps OpenAI são preservados, mas exigem uma interface alojada por MCP no Omlorix.", + "workspace_plugins_warning_empty": "Este pacote não declara uma habilidade ou servidor MCP instalável." } diff --git a/frontend/i18n/ru/index.json b/frontend/i18n/ru/index.json index c650a10..1550d6e 100644 --- a/frontend/i18n/ru/index.json +++ b/frontend/i18n/ru/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAI остановила этот разговор для проверки безопасности. Проверьте уже выполненные действия вместе с ответственным оператором. Повторный запуск этого процесса невозможен.", "chat_openai_tools_require_responses": "Для работы с инструментами этой модели нужен провайдер OpenAI Responses. Смените провайдера, прежде чем продолжить разговор.", "llm.openai.tools_require_responses": "Используйте провайдер OpenAI Responses, чтобы включить инструменты для этой модели.", - "chat_context_budget_exceeded": "Запрос превышает лимит контекста модели. Удалите вложения, сократите запрос или выберите модель с большим контекстом.", + "chat_context_budget_exceeded": "Запрос превышает лимит контекста модели. Удалите вложения, сократите запрос или выберите модель с большим контекстом.", "us_personal_info_email_verification_sent": "Ссылки для подтверждения отправлены. Текущий адрес останется активным до подтверждения нового.", "us_personal_info_email_change_unavailable": "Не удалось начать смену адреса. Проверьте адрес или попросите администратора проверить настройки доставки почты.", "email_change_processing_title": "Обновление адреса электронной почты", @@ -4792,5 +4792,45 @@ "chat_workspace_back": "Вернуться в чат", "chat_workspace_close": "Скрыть панель подробностей", "chat_workspace_hide": "Скрыть", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "Плагины", + "workspace_plugins_title": "Плагины агентов", + "workspace_plugins_subtitle": "Устанавливайте переносимые пакеты с навыками и серверами MCP.", + "workspace_plugins_install": "Установить плагин", + "workspace_plugins_security_note": "Плагины проверяются до установки. Omlorix не запускает хуки, а личные плагины не могут запускать локальные процессы.", + "workspace_plugins_empty_title": "Нет установленных плагинов", + "workspace_plugins_empty_desc": "Установите ZIP-пакет с манифестом .codex-plugin/plugin.json.", + "workspace_plugins_review_eyebrow": "Проверка установки", + "workspace_plugins_version": "Версия", + "workspace_plugins_author": "Автор", + "workspace_plugins_skills": "Навыки", + "workspace_plugins_servers": "Серверы MCP", + "workspace_plugins_installs_disabled": "Плагин будет установлен отключённым. Включите его после проверки компонентов.", + "workspace_plugins_confirm_install": "Установить отключённым", + "workspace_plugins_enabled": "Включён", + "workspace_plugins_disabled": "Отключён", + "workspace_plugins_enable": "Включить", + "workspace_plugins_disable": "Отключить", + "workspace_plugins_export": "Экспорт", + "workspace_plugins_homepage": "Домашняя страница", + "workspace_plugins_uninstall": "Удалить", + "workspace_plugins_uninstall_title": "Удалить плагин?", + "workspace_plugins_uninstall_desc": "Плагин и все установленные им навыки и серверы MCP будут удалены.", + "workspace_plugins_loading": "Загрузка плагинов…", + "workspace_plugins_reviewing": "Проверка плагина…", + "workspace_plugins_updating": "Обновление плагина…", + "workspace_plugins_uninstalling": "Удаление плагина…", + "workspace_plugins_installed_success": "Плагин установлен отключённым.", + "workspace_plugins_enabled_success": "Плагин включён.", + "workspace_plugins_disabled_success": "Плагин отключён.", + "workspace_plugins_uninstalled_success": "Плагин удалён.", + "workspace_plugins_no_description": "Описание отсутствует.", + "workspace_plugins_skill": "Навык", + "workspace_plugins_server": "Сервер MCP", + "workspace_plugins_unknown": "Неизвестно", + "workspace_plugins_request_error": "Запрос плагина завершился ошибкой.", + "workspace_plugins_warning_local_process": "Серверы MCP с локальными процессами нельзя устанавливать в личных плагинах.", + "workspace_plugins_warning_hooks": "Хуки сохраняются для переносимости, но Omlorix их не выполняет.", + "workspace_plugins_warning_apps": "Зарегистрированные ID приложений OpenAI сохраняются, но в Omlorix им нужен интерфейс, размещённый через MCP.", + "workspace_plugins_warning_empty": "В этом пакете нет устанавливаемого навыка или сервера MCP." } diff --git a/frontend/i18n/zh/index.json b/frontend/i18n/zh/index.json index a384599..1ed569a 100644 --- a/frontend/i18n/zh/index.json +++ b/frontend/i18n/zh/index.json @@ -7,7 +7,7 @@ "chat_openai_safety_stop": "OpenAI 已停止此对话以进行安全审查。请与负责的操作人员一起审查已执行的操作。此工作流程无法重试。", "chat_openai_tools_require_responses": "此模型需要使用 OpenAI Responses 提供商才能调用工具。请在继续此对话前更换提供商。", "llm.openai.tools_require_responses": "请使用 OpenAI Responses 提供商为此模型启用工具。", - "chat_context_budget_exceeded": "此请求超出了模型的上下文限制。请移除附件、缩短请求,或选择支持更大上下文的模型。", + "chat_context_budget_exceeded": "此请求超出了模型的上下文限制。请移除附件、缩短请求,或选择支持更大上下文的模型。", "us_personal_info_email_verification_sent": "验证链接已发送。在新地址通过验证前,当前电子邮件地址仍然有效。", "us_personal_info_email_change_unavailable": "无法开始更改电子邮件地址。请检查地址,或请管理员核对邮件发送设置。", "email_change_processing_title": "正在更新电子邮件地址", @@ -4777,5 +4777,45 @@ "chat_workspace_back": "返回聊天", "chat_workspace_close": "隐藏详情面板", "chat_workspace_hide": "隐藏", - "subagent_tab_label": "{name} · {number}" + "subagent_tab_label": "{name} · {number}", + "workspace_tab_plugins": "插件", + "workspace_plugins_title": "智能体插件", + "workspace_plugins_subtitle": "安装包含技能和 MCP 服务器的可移植插件包。", + "workspace_plugins_install": "安装插件", + "workspace_plugins_security_note": "插件会在安装前经过检查。Omlorix 绝不运行钩子,个人插件也无法启动本地进程。", + "workspace_plugins_empty_title": "尚未安装插件", + "workspace_plugins_empty_desc": "安装包含 .codex-plugin/plugin.json 清单的 .zip 包。", + "workspace_plugins_review_eyebrow": "安装检查", + "workspace_plugins_version": "版本", + "workspace_plugins_author": "作者", + "workspace_plugins_skills": "技能", + "workspace_plugins_servers": "MCP 服务器", + "workspace_plugins_installs_disabled": "插件将以停用状态安装。请检查其组件后再启用。", + "workspace_plugins_confirm_install": "停用安装", + "workspace_plugins_enabled": "已启用", + "workspace_plugins_disabled": "已停用", + "workspace_plugins_enable": "启用", + "workspace_plugins_disable": "停用", + "workspace_plugins_export": "导出", + "workspace_plugins_homepage": "主页", + "workspace_plugins_uninstall": "卸载", + "workspace_plugins_uninstall_title": "卸载插件?", + "workspace_plugins_uninstall_desc": "该插件及其安装的所有技能和 MCP 服务器都将被删除。", + "workspace_plugins_loading": "正在加载插件…", + "workspace_plugins_reviewing": "正在检查插件…", + "workspace_plugins_updating": "正在更新插件…", + "workspace_plugins_uninstalling": "正在卸载插件…", + "workspace_plugins_installed_success": "插件已停用安装。", + "workspace_plugins_enabled_success": "插件已启用。", + "workspace_plugins_disabled_success": "插件已停用。", + "workspace_plugins_uninstalled_success": "插件已卸载。", + "workspace_plugins_no_description": "未提供说明。", + "workspace_plugins_skill": "技能", + "workspace_plugins_server": "MCP 服务器", + "workspace_plugins_unknown": "未知", + "workspace_plugins_request_error": "插件请求失败。", + "workspace_plugins_warning_local_process": "个人插件不能安装本地进程型 MCP 服务器。", + "workspace_plugins_warning_hooks": "钩子会为可移植性保留,但 Omlorix 不会执行它们。", + "workspace_plugins_warning_apps": "已注册的 OpenAI 应用 ID 会保留,但在 Omlorix 中需要由 MCP 托管的应用界面。", + "workspace_plugins_warning_empty": "此插件包没有声明可安装的技能或 MCP 服务器。" } diff --git a/frontend/index.html b/frontend/index.html index 69f5efc..ef32be7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -79,6 +79,7 @@ + @@ -224,6 +225,7 @@ + @@ -635,6 +637,9 @@