From 99a92e9eb19913e246022d9e26f0605a8ac8de4b Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sun, 6 Sep 2026 00:52:59 +0800 Subject: [PATCH] feat(extensions): add ZIP installation and unify action dialogs --- README.md | 16 +++ backend/app/extensions/archive.py | 99 +++++++++++++++++++ backend/app/routes.py | 25 ++++- backend/tests/test_extension_archive.py | 87 ++++++++++++++++ .../components/common/ActionDialog.spec.ts | 53 ++++++++++ .../src/components/common/ActionDialog.vue | 32 ++++++ .../src/components/common/AppDialog.spec.ts | 16 ++- frontend/src/components/common/AppDialog.vue | 12 +++ .../src/components/common/CommandPalette.vue | 20 ++-- .../common/ExtensionInstallDialog.spec.ts | 28 ++++++ .../common/ExtensionInstallDialog.vue | 47 +++++++-- frontend/src/composables/useActionDialog.ts | 28 ++++++ .../features/editor/VisualMarkdownEditor.vue | 30 ++++-- .../src/features/mcp/McpServersView.spec.ts | 14 ++- frontend/src/features/mcp/McpServersView.vue | 10 +- frontend/src/features/media/MediaView.vue | 8 +- .../src/features/plugins/PluginMcpPanel.vue | 11 ++- .../plugins/PluginSettingsPanel.spec.ts | 6 +- .../features/plugins/PluginSettingsPanel.vue | 7 +- frontend/src/features/plugins/PluginsView.vue | 8 +- .../src/features/settings/SettingsView.vue | 6 +- frontend/src/features/skills/SkillsView.vue | 6 +- frontend/src/features/tasks/TasksView.vue | 6 +- .../src/features/workspace/FileTreePanel.vue | 8 +- frontend/src/services/apiClient.ts | 3 + frontend/src/services/pluginService.ts | 7 +- frontend/src/services/skillService.ts | 7 +- frontend/src/stores/plugin.ts | 2 +- frontend/src/stores/skill.ts | 2 +- 29 files changed, 554 insertions(+), 50 deletions(-) create mode 100644 backend/app/extensions/archive.py create mode 100644 backend/tests/test_extension_archive.py create mode 100644 frontend/src/components/common/ActionDialog.spec.ts create mode 100644 frontend/src/components/common/ActionDialog.vue create mode 100644 frontend/src/composables/useActionDialog.ts diff --git a/README.md b/README.md index fbb686f..9ab3202 100644 --- a/README.md +++ b/README.md @@ -187,3 +187,19 @@ css_entry: styles/theme.css 模型设置页将提供商、本地模型、用量统计分成独立卡片。用量趋势支持近 7 天、30 天、90 天及自定义时间,沿用提供商/模型/来源筛选;按本机 UTC 偏移分组(长区间自动合并到最多 90 组)。可切换输入、输出、总 Token 和请求次数,本地为芯片实色图例,提供商为连接斜纹图例。仅汇总已报告值,并提供覆盖数与可展开的数据表,缺失不补零。 纸间时光更新至 1.5.0,通用卡片、执行事件、引用、模型路由及弹窗统一使用纸张、虚线、胶带和叠纸阴影。已安装旧版本时,在主题社区点击“更新”应用新版样式。 + + +## Skill / Plugin ZIP 安装(临时规范) + +安装弹窗支持 ZIP 文件和 AI Core 主机上的本地目录。ZIP 根目录须包含 `skill.yaml` 或 `plugin.yaml`;也支持整个包放在唯一的顶层文件夹中。每个 ZIP 安装一个扩展,清单字段沿用现有 Skill / Plugin 契约。 + +```text +my-skill.zip my-plugin.zip +└─ my-skill/ ├─ plugin.yaml + ├─ skill.yaml ├─ 后端入口及资源文件 + └─ prompt.md(可选) └─ 其他包内资源 +``` + +ZIP 最大 10 MiB,解压总大小最大 50 MiB,最多 2048 个条目;支持 stored/deflate。拒绝加密条目、符号链接、特殊文件、越界路径以及重复或大小写冲突路径。选择文件后点击安装才上传;后端解压并沿用现有清单、依赖及权限校验,不自动授予权限或启动 Plugin 进程。 + +解压文件保存在 AI Core 数据目录的 `extension-packages/` 下,安装失败会清理本次目录。此功能不改变扩展运行时现有的安装记录持久化机制;目前重启后仍需重新注册包。扩展 ZIP 暂不支持 URL 下载;主题 ZIP 使用其独立的导入规则。 diff --git a/backend/app/extensions/archive.py b/backend/app/extensions/archive.py new file mode 100644 index 0000000..a1c8582 --- /dev/null +++ b/backend/app/extensions/archive.py @@ -0,0 +1,99 @@ +"""Bounded ZIP extraction for packages uploaded to the AI Core host.""" +from __future__ import annotations + +import io +import re +import shutil +import stat +import tempfile +import zipfile +import zlib +from pathlib import Path +from collections.abc import Callable +from typing import TypeVar + +from app.errors import ApiError +from app.extensions.errors import ExtensionError + +MAX_ZIP_BYTES = 10 * 1024 * 1024 +MAX_EXPANDED_BYTES = 50 * 1024 * 1024 +MAX_ENTRIES = 2048 +T = TypeVar('T') + + +def invalid(message: str) -> ApiError: + return ApiError(422, 'EXTENSION_ZIP_INVALID', message) + + +def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path], T]) -> T: + if len(data) > MAX_ZIP_BYTES: + raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 文件不能超过 10 MiB。') + if kind not in ('skill', 'plugin'): + raise ValueError('Unknown extension kind') + storage.mkdir(parents=True, exist_ok=True) + # Retain successful extraction: Plugin commands and resources use this directory. + destination = Path(tempfile.mkdtemp(prefix=f'{kind}-', dir=storage)) + try: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + entries = archive.infolist() + if not entries or len(entries) > MAX_ENTRIES: + raise invalid('ZIP 为空或文件条目超过 2048 个。') + seen: set[str] = set() + spellings: dict[str, str] = {} + total = 0 + for entry in entries: + name = entry.filename.rstrip('/') + parts = name.split('/') + if (entry.orig_filename != entry.filename or '\\' in name + or any(not p or p in ('.', '..') or any(c in p for c in ':*?<>|"') or p.endswith((' ', '.')) + or any(ord(c) < 32 for c in p) + or re.match(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)', p, re.I) + for p in parts)): + raise invalid('ZIP 包含不安全的文件路径。') + mode = stat.S_IFMT(entry.external_attr >> 16) + if mode not in (0, stat.S_IFREG, stat.S_IFDIR) or entry.flag_bits & 1: + raise invalid('ZIP 不支持链接、特殊文件或加密条目。') + if entry.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise invalid('ZIP 仅支持 stored/deflate 压缩。') + key = name.casefold() + if key in seen: + raise invalid('ZIP 包含重复或大小写冲突的路径。') + seen.add(key) + for index in range(1, len(parts) + 1): + prefix = '/'.join(parts[:index]) + if spellings.setdefault(prefix.casefold(), prefix) != prefix: + raise invalid('ZIP 包含大小写冲突的目录。') + total += entry.file_size + if total > MAX_EXPANDED_BYTES: + raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 解压后不能超过 50 MiB。') + target = destination.joinpath(*parts) + if not target.resolve().is_relative_to(destination.resolve()): + raise invalid('ZIP 路径超出包目录。') + written = 0 + for entry in entries: + target = destination.joinpath(*entry.filename.rstrip('/').split('/')) + if entry.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(entry) as source, target.open('xb') as output: + while chunk := source.read(64 * 1024): + written += len(chunk) + if written > MAX_EXPANDED_BYTES: + raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 解压后不能超过 50 MiB。') + output.write(chunk) + manifest = f'{kind}.yaml' + root = destination + if not (root / manifest).is_file(): + children = list(root.iterdir()) + if len(children) != 1 or not children[0].is_dir() or not (children[0] / manifest).is_file(): + raise invalid(f'ZIP 根目录或唯一顶层文件夹中须包含 {manifest}。') + root = children[0] + return install(root) + except BaseException as error: + shutil.rmtree(destination) + if isinstance(error, ExtensionError): + raise + if isinstance(error, (zipfile.BadZipFile, OSError, RuntimeError, NotImplementedError, zlib.error, EOFError, UnicodeError)): + raise invalid('ZIP 损坏、路径冲突或无法解压。') from error + raise diff --git a/backend/app/routes.py b/backend/app/routes.py index a35e598..ae9f80e 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -5,11 +5,13 @@ from contextlib import aclosing from datetime import datetime, timezone from uuid import uuid4 -from fastapi import APIRouter, Header, Query +from fastapi import APIRouter, Header, Query, Request from fastapi.responses import StreamingResponse from app.agent import AgentCapacityError, AgentRunNotFoundError from app.container import container +from app.config import get_settings +from app.extensions.archive import MAX_ZIP_BYTES, install_zip from app.services.persona_settings import PersonaSettings, load_persona, save_persona from app.contracts import ( AgentRun, @@ -659,6 +661,27 @@ async def install_skill(request: ExtensionInstallRequest) -> Skill: return extension_call(lambda: container.skills.install(request.package_path)) +async def read_extension_zip(request: Request) -> bytes: + data = bytearray() + async for chunk in request.stream(): + if len(data) + len(chunk) > MAX_ZIP_BYTES: + raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 文件不能超过 10 MiB。') + data.extend(chunk) + return bytes(data) + + +@router.post('/skills/install-zip', response_model=Skill, status_code=202, tags=['Skills']) +async def install_skill_zip(request: Request) -> Skill: + data = await read_extension_zip(request) + return extension_call(lambda: install_zip(data, 'skill', get_settings().data_dir / 'extension-packages', container.skills.install)) + + +@router.post('/plugins/install-zip', response_model=Plugin, status_code=202, tags=['Plugins']) +async def install_plugin_zip(request: Request) -> Plugin: + data = await read_extension_zip(request) + return extension_call(lambda: install_zip(data, 'plugin', get_settings().data_dir / 'extension-packages', container.plugins.install)) + + @router.post( "/skills/{skill_id}/enable", response_model=Skill, diff --git a/backend/tests/test_extension_archive.py b/backend/tests/test_extension_archive.py new file mode 100644 index 0000000..5ce894a --- /dev/null +++ b/backend/tests/test_extension_archive.py @@ -0,0 +1,87 @@ +import asyncio +import io +import stat +import zipfile + +import pytest +from starlette.requests import Request + +from app.errors import ApiError +from app.extensions import ExtensionError +from app.extensions.archive import install_zip +from app.extensions import archive as module + + +def zipped(files): + output = io.BytesIO() + with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as archive: + for name, value in files: + if isinstance(name, str) and '\\' in name: + entry = zipfile.ZipInfo() + entry.filename = name # Keep malicious separators on Windows too. + name = entry + archive.writestr(name, value) + return output.getvalue() + + +@pytest.mark.parametrize('kind', ['skill', 'plugin']) +@pytest.mark.parametrize('prefix', ['', 'package/']) +def test_install_keeps_package_resources(tmp_path, kind, prefix): + data = zipped([(prefix + kind + '.yaml', 'name: test'), (prefix + 'assets/说明.txt', 'hello')]) + root = install_zip(data, kind, tmp_path, lambda root: root) + assert (root / 'assets/说明.txt').read_text() == 'hello' + + +@pytest.mark.parametrize('path', ['../outside', '/outside', 'C:/outside', 'a\\b', 'NUL.txt', 'a/../b', 'a./x']) +def test_unsafe_paths_rejected_and_cleaned(tmp_path, path): + with pytest.raises(ApiError): + install_zip(zipped([('skill.yaml', 'name: x'), (path, 'x')]), 'skill', tmp_path, lambda _: pytest.fail('must not install')) + assert list(tmp_path.iterdir()) == [] + + +def test_links_duplicates_and_size_limits(tmp_path, monkeypatch): + link = zipfile.ZipInfo('link') + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + cases = [zipped([(link, '../outside')]), zipped([('skill.yaml', 'x'), ('SKILL.yaml', 'x')]), b'not a zip'] + for data in cases: + with pytest.raises(ApiError): + install_zip(data, 'skill', tmp_path, lambda _: pytest.fail('must not install')) + assert list(tmp_path.iterdir()) == [] + monkeypatch.setattr(module, 'MAX_EXPANDED_BYTES', 3) + with pytest.raises(ApiError, match='50 MiB'): + install_zip(zipped([('skill.yaml', 'xxxxx')]), 'skill', tmp_path, lambda _: None) + assert list(tmp_path.iterdir()) == [] + + +def test_manifest_validation_failure_preserved_and_cleaned(tmp_path): + def reject(_): + raise ExtensionError('BAD_MANIFEST', 'invalid manifest') + with pytest.raises(ExtensionError, match='invalid manifest'): + install_zip(zipped([('plugin.yaml', 'x')]), 'plugin', tmp_path, reject) + assert list(tmp_path.iterdir()) == [] + with pytest.raises(ApiError, match='plugin.yaml'): + install_zip(zipped([('skill.yaml', 'x')]), 'plugin', tmp_path, reject) + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize('kind', ['skill', 'plugin']) +def test_upload_route_uses_real_manifest_validation(tmp_path, monkeypatch, kind): + from app import routes + from app.container import build_container + runtime = build_container() + monkeypatch.setattr(routes, 'container', runtime) + data = zipped([(kind + '.yaml', f'id: zip-example\nname: ZIP example\nversion: 1.0.0\ndescription: test\n')]) + sent = False + async def receive(): + nonlocal sent + assert not sent + sent = True + return {'type': 'http.request', 'body': data, 'more_body': False} + request = Request({'type': 'http', 'method': 'POST', 'headers': []}, receive) + try: + result = asyncio.run(getattr(routes, f'install_{kind}_zip')(request)) + assert getattr(result.manifest, kind + '_id') == 'zip-example' + assert not result.enabled + finally: + runtime.plugins.shutdown() diff --git a/frontend/src/components/common/ActionDialog.spec.ts b/frontend/src/components/common/ActionDialog.spec.ts new file mode 100644 index 0000000..504f894 --- /dev/null +++ b/frontend/src/components/common/ActionDialog.spec.ts @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom +import { defineComponent } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, expect, it, vi } from 'vitest' +import ActionDialog from './ActionDialog.vue' +import { useActionDialog } from '@/composables/useActionDialog' + +let wrapper: ReturnType +afterEach(() => wrapper?.unmount()) +function setup() { + let api!: ReturnType + wrapper = mount(defineComponent({ + components: { ActionDialog }, + setup() { api = useActionDialog(); return api }, + template: '', + }), { attachTo: document.body }) + return api +} +it('requires explicit confirmation and treats Escape as cancellation', async () => { + const api = setup() + const action = vi.fn() + const result = api.askConfirm('删除所有配置?').then(ok => { if (ok) action() }) + await flushPromises() + expect(document.activeElement?.textContent).toBe('取消') + await wrapper.get('dialog').trigger('cancel') + await result + expect(action).not.toHaveBeenCalled() + const confirmed = api.askConfirm('继续?') + await flushPromises() + await wrapper.get('form').trigger('submit') + expect(await confirmed).toBe(true) +}) +it('preserves the default input and distinguishes empty submission from cancel', async () => { + const api = setup() + const input = api.askPrompt('新名称', '旧名称') + await flushPromises() + expect((wrapper.get('input').element as HTMLInputElement).value).toBe('旧名称') + await wrapper.get('input').setValue('') + await wrapper.get('form').trigger('submit') + expect(await input).toBe('') + const cancelled = api.askPrompt('名称') + await flushPromises() + await wrapper.get('button[type="button"]').trigger('click') + expect(await cancelled).toBeNull() +}) +it('cancels duplicate requests and pending operations when their view unmounts', async () => { + const api = setup() + const first = api.askConfirm('继续?') + expect(await api.askConfirm('重复')).toBe(false) + wrapper.unmount() + expect(await first).toBe(false) + expect(await api.askPrompt('已离开')).toBeNull() +}) diff --git a/frontend/src/components/common/ActionDialog.vue b/frontend/src/components/common/ActionDialog.vue new file mode 100644 index 0000000..988ede0 --- /dev/null +++ b/frontend/src/components/common/ActionDialog.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/frontend/src/components/common/AppDialog.spec.ts b/frontend/src/components/common/AppDialog.spec.ts index 3f64e60..d065c86 100644 --- a/frontend/src/components/common/AppDialog.spec.ts +++ b/frontend/src/components/common/AppDialog.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { afterEach, expect, it } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import { mount, type VueWrapper } from '@vue/test-utils' import AppDialog from './AppDialog.vue' const mounted: VueWrapper[] = [] @@ -34,3 +34,17 @@ it('does not dismiss permission or busy dialogs through Escape or backdrop', asy await w.get('dialog').trigger('click') expect(w.emitted('close')).toBeUndefined() }) + +it('cycles Tab between the first and last visible controls', async () => { + const w = mount(AppDialog, {props:{label:'键盘'}, slots:{default:''},attachTo:document.body}); mounted.push(w) + const input = w.get('input').element + const button = w.get('button').element + const rects = [new DOMRect(0, 0, 50, 30)] as unknown as DOMRectList + const spies = [input, button].map(element => vi.spyOn(element, 'getClientRects').mockReturnValue(rects)) + input.focus() + await w.get('dialog').trigger('keydown', {key:'Tab', shiftKey:true}) + expect(document.activeElement).toBe(button) + await w.get('dialog').trigger('keydown', {key:'Tab'}) + expect(document.activeElement).toBe(input) + spies.forEach(spy => spy.mockRestore()) +}) diff --git a/frontend/src/components/common/AppDialog.vue b/frontend/src/components/common/AppDialog.vue index eb15de7..cff7675 100644 --- a/frontend/src/components/common/AppDialog.vue +++ b/frontend/src/components/common/AppDialog.vue @@ -9,6 +9,18 @@ let previousFocus: HTMLElement | null = null function dismiss() { if (props.dismissible) emit('close') } function keydown(event: KeyboardEvent) { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss() } + if (event.key === 'Tab' && dialog.value) { + const items = Array.from(dialog.value.querySelectorAll('button:not(:disabled), input:not(:disabled):not([type="hidden"]), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex]')) + .filter(element => element.tabIndex >= 0 && element.getClientRects().length > 0) + const first = items[0] + const last = items.at(-1) + if (!first) { event.preventDefault(); dialog.value.focus(); return } + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.value)) { + event.preventDefault(); last?.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); first.focus() + } + } } onMounted(() => { previousFocus = document.activeElement as HTMLElement | null diff --git a/frontend/src/components/common/CommandPalette.vue b/frontend/src/components/common/CommandPalette.vue index 820964e..559c8e1 100644 --- a/frontend/src/components/common/CommandPalette.vue +++ b/frontend/src/components/common/CommandPalette.vue @@ -1,4 +1,8 @@ diff --git a/frontend/src/composables/useActionDialog.ts b/frontend/src/composables/useActionDialog.ts new file mode 100644 index 0000000..507a37e --- /dev/null +++ b/frontend/src/composables/useActionDialog.ts @@ -0,0 +1,28 @@ +import { nextTick, onBeforeUnmount, shallowRef } from 'vue' + +export interface ActionDialogRequest { message: string; mode: 'confirm' | 'prompt'; initialValue: string } + +/** Requests belong to the invoking view; leaving it cancels pending work. */ +export function useActionDialog() { + const actionDialog = shallowRef(null) + let pending: ((value: string | null) => void) | undefined + let disposed = false + async function resolveAction(value: string | null) { + const resolve = pending + pending = undefined + actionDialog.value = null + await nextTick() // Restore focus and release the modal before the caller continues. + resolve?.(disposed ? null : value) + } + function request(mode: ActionDialogRequest['mode'], message: string, initialValue = '') { + if (disposed || pending) return Promise.resolve(null) + actionDialog.value = { mode, message, initialValue } + return new Promise(resolve => { pending = resolve }) + } + onBeforeUnmount(() => { disposed = true; pending?.(null); pending = undefined; actionDialog.value = null }) + return { + actionDialog, resolveAction, + askConfirm: async (message: string) => (await request('confirm', message)) !== null, + askPrompt: (message: string, initialValue = '') => request('prompt', message, initialValue), + } +} diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index 717425b..863069b 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -1,4 +1,7 @@