feat(extension): 实现 Plugin Command 与 Settings Contribution #10

Merged
Kronecker merged 7 commits from feat/plugin-command-settings into main 2026-09-02 20:05:43 +08:00
18 changed files with 282 additions and 21 deletions
Showing only changes of commit c3ef9dfa44 - Show all commits
+1 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 103 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 107 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。 构建产物位于 `frontend/dist`,该目录不提交到 Git。
+1 -1
View File
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
uv run pytest uv run pytest
``` ```
当前基线为 103 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。 当前基线为 107 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
+8
View File
@@ -73,6 +73,7 @@ class PluginCommandSpec(BaseModel):
} }
) )
permission: str | None = None permission: str | None = None
secrets: list[str] = Field(default_factory=list)
handler: Literal["echo", "uppercase_selection"] handler: Literal["echo", "uppercase_selection"]
timeout_seconds: int = Field(default=30, ge=1, le=120) timeout_seconds: int = Field(default=30, ge=1, le=120)
@@ -81,6 +82,7 @@ CommandExecutor = Callable[
[dict[str, Any], dict[str, Any]], [dict[str, Any], dict[str, Any]],
PluginCommandEffect | Awaitable[PluginCommandEffect], PluginCommandEffect | Awaitable[PluginCommandEffect],
] ]
PluginSecretResolver = Callable[[str], str | None]
@dataclass(slots=True) @dataclass(slots=True)
@@ -656,6 +658,12 @@ def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None:
"PLUGIN_COMMAND_INVALID", "PLUGIN_COMMAND_INVALID",
"Plugin command when/context entries must be unique.", "Plugin command when/context entries must be unique.",
) )
if len(spec.secrets) != len(set(spec.secrets)):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command Secret entries must be unique.",
details={"command_id": spec.command_id},
)
unknown_when = sorted(set(spec.when) - _WHEN_TOKENS) unknown_when = sorted(set(spec.when) - _WHEN_TOKENS)
if unknown_when: if unknown_when:
raise ExtensionError( raise ExtensionError(
+77 -2
View File
@@ -24,6 +24,7 @@ from app.contracts import (
PluginManifest, PluginManifest,
PluginHostStatus, PluginHostStatus,
PluginSecretStatus, PluginSecretStatus,
PluginSettingType,
PluginSettingsSchema, PluginSettingsSchema,
PluginStatus, PluginStatus,
RetrievalConfig, RetrievalConfig,
@@ -35,6 +36,7 @@ from app.contracts import (
from app.extensions.contributions import ( from app.extensions.contributions import (
CommandRegistry, CommandRegistry,
PluginCommandSpec, PluginCommandSpec,
PluginSecretResolver,
PluginSettingsDefinition, PluginSettingsDefinition,
PluginSettingsStore, PluginSettingsStore,
validate_command_spec, validate_command_spec,
@@ -245,6 +247,7 @@ class DeclarativePluginHost:
arguments: dict[str, Any], arguments: dict[str, Any],
context: dict[str, Any], context: dict[str, Any],
settings: dict[str, Any], settings: dict[str, Any],
resolve_secret: PluginSecretResolver,
) -> PluginCommandEffect: ) -> PluginCommandEffect:
"""执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。""" """执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。"""
@@ -381,6 +384,32 @@ class PluginRuntime:
) )
if settings_definition is not None: if settings_definition is not None:
validate_settings_definition(manifest.plugin_id, settings_definition) validate_settings_definition(manifest.plugin_id, settings_definition)
secret_fields = (
{
field.key
for field in settings_definition.fields
if field.type == PluginSettingType.secret
}
if settings_definition is not None
else set()
)
for spec in command_specs:
unknown_secrets = sorted(set(spec.secrets) - secret_fields)
if unknown_secrets:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command references undeclared Secret settings.",
details={
"command_id": spec.command_id,
"secrets": unknown_secrets,
},
)
if spec.secrets and "secrets.use" not in manifest.permissions:
raise ExtensionError(
"PLUGIN_PERMISSION_UNDECLARED",
"Commands using Secret settings require the secrets.use permission.",
details={"command_id": spec.command_id},
)
record = _PluginRecord( record = _PluginRecord(
plugin=Plugin( plugin=Plugin(
@@ -502,6 +531,16 @@ class PluginRuntime:
_spec: PluginCommandSpec = spec, _spec: PluginCommandSpec = spec,
_record: _PluginRecord = record, _record: _PluginRecord = record,
) -> PluginCommandEffect: ) -> PluginCommandEffect:
if (
not _record.plugin.enabled
or _record.plugin.status != PluginStatus.ready
):
raise ExtensionError(
"PLUGIN_COMMAND_NOT_FOUND",
"Plugin command is not available while its Plugin is inactive.",
status_code=404,
details={"command_id": _spec.command_id},
)
settings = ( settings = (
self.settings.get( self.settings.get(
_record.plugin.manifest.plugin_id, _record.plugin.manifest.plugin_id,
@@ -510,8 +549,38 @@ class PluginRuntime:
if _record.settings_definition is not None if _record.settings_definition is not None
else {} else {}
) )
def resolve_secret(key: str) -> str | None:
if key not in _spec.secrets:
raise ExtensionError(
"PLUGIN_SECRET_ACCESS_DENIED",
"Command cannot access an undeclared Plugin Secret.",
status_code=403,
details={
"command_id": _spec.command_id,
"key": key,
},
)
if "secrets.use" not in _record.plugin.granted_permissions:
raise ExtensionError(
"PLUGIN_SECRET_ACCESS_DENIED",
"Plugin no longer has permission to access Secret settings.",
status_code=403,
details={"command_id": _spec.command_id, "key": key},
)
if _record.settings_definition is None:
return None
return self.settings.resolve_secret(
_record.plugin.manifest.plugin_id,
_record.settings_definition,
key,
)
return await self.host.execute_command( return await self.host.execute_command(
_spec.handler, arguments, context, settings _spec.handler,
arguments,
context,
settings,
resolve_secret,
) )
self.commands.register(plugin_id, spec, command_executor) self.commands.register(plugin_id, spec, command_executor)
@@ -782,10 +851,16 @@ class PluginRuntime:
if not path.exists(): if not path.exists():
return [] return []
raw = _read_yaml(path) raw = _read_yaml(path)
items = raw.get("commands", [])
if not isinstance(items, list):
raise ExtensionError(
"EXTENSION_MANIFEST_INVALID",
"Invalid plugin command manifest: commands must be an array.",
)
try: try:
return [ return [
PluginCommandSpec.model_validate(item) PluginCommandSpec.model_validate(item)
for item in raw.get("commands", []) for item in items
] ]
except ValidationError as exc: except ValidationError as exc:
raise _manifest_error("plugin command", exc) from exc raise _manifest_error("plugin command", exc) from exc
+21
View File
@@ -13,6 +13,7 @@ from app.config import get_settings
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") _CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_PLUGIN_CREDENTIAL_PREFIX = "plugin."
class CredentialStoreError(RuntimeError): class CredentialStoreError(RuntimeError):
@@ -23,6 +24,15 @@ class CredentialResolver(Protocol):
def resolve(self, credential_id: str | None) -> str | None: ... def resolve(self, credential_id: str | None) -> str | None: ...
def validate_provider_credential_id(credential_id: str | None) -> None:
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
if credential_id and credential_id.casefold().startswith(
_PLUGIN_CREDENTIAL_PREFIX
):
raise CredentialStoreError("Credential namespace is reserved for Plugin settings.")
class EnvironmentCredentialResolver: class EnvironmentCredentialResolver:
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。""" """解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
@@ -170,3 +180,14 @@ class ChainedCredentialResolver:
if value: if value:
return value return value
return None return None
class ProviderCredentialResolver:
"""Provider 专用防御层,避免配置绕过 HTTP 校验读取 Plugin Secret。"""
def __init__(self, delegate: CredentialResolver) -> None:
self._delegate = delegate
def resolve(self, credential_id: str | None) -> str | None:
validate_provider_credential_id(credential_id)
return self._delegate.resolve(credential_id)
+4 -2
View File
@@ -1,6 +1,6 @@
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
from app.providers.base import ModelProvider from app.providers.base import ModelProvider
from app.providers.credentials import CredentialResolver from app.providers.credentials import CredentialResolver, ProviderCredentialResolver
from app.providers.ollama import OllamaProvider from app.providers.ollama import OllamaProvider
from app.providers.openai_compatible import OpenAICompatibleProvider from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -11,7 +11,9 @@ class UnsupportedProviderError(ValueError):
class ProviderFactory: class ProviderFactory:
def __init__(self, credentials: CredentialResolver) -> None: def __init__(self, credentials: CredentialResolver) -> None:
self.credentials = credentials # ProviderFactory 是所有可配置 Provider 的创建边界,在此统一禁止
# Provider 借用 Plugin Secret 引用,避免调用方漏包安全 Resolver。
self.credentials = ProviderCredentialResolver(credentials)
def build(self, config: ProviderConfig) -> ModelProvider: def build(self, config: ProviderConfig) -> ModelProvider:
if config.provider_type in { if config.provider_type in {
+18 -1
View File
@@ -75,7 +75,10 @@ from app.extensions import ExtensionError
from app.providers.registry import ProviderNotFoundError from app.providers.registry import ProviderNotFoundError
from app.providers.factory import UnsupportedProviderError from app.providers.factory import UnsupportedProviderError
from app.providers.base import ProviderError from app.providers.base import ProviderError
from app.providers.credentials import CredentialStoreError from app.providers.credentials import (
CredentialStoreError,
validate_provider_credential_id,
)
from app.retrieval.engine import engine from app.retrieval.engine import engine
from app.services import ( from app.services import (
index_service, index_service,
@@ -92,6 +95,13 @@ def utc_now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def validate_public_credential_id(credential_id: str | None) -> None:
try:
validate_provider_credential_id(credential_id)
except CredentialStoreError as exc:
raise ApiError(422, "CREDENTIAL_NAMESPACE_RESERVED", str(exc)) from exc
def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str: def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str:
id_line = f"id: {event_id}\n" if event_id is not None else "" id_line = f"id: {event_id}\n" if event_id is not None else ""
return f"{id_line}event: {event}\ndata: {payload}\n\n" return f"{id_line}event: {event}\ndata: {payload}\n\n"
@@ -654,6 +664,7 @@ async def delete_plugin_setting_secret(
tags=["Providers"], tags=["Providers"],
) )
async def get_credential_status(credential_id: str) -> CredentialStatus: async def get_credential_status(credential_id: str) -> CredentialStatus:
validate_public_credential_id(credential_id)
try: try:
configured = container.credentials.has(credential_id) configured = container.credentials.has(credential_id)
except CredentialStoreError as exc: except CredentialStoreError as exc:
@@ -669,6 +680,7 @@ async def get_credential_status(credential_id: str) -> CredentialStatus:
async def put_credential( async def put_credential(
credential_id: str, request: CredentialWriteRequest credential_id: str, request: CredentialWriteRequest
) -> CredentialStatus: ) -> CredentialStatus:
validate_public_credential_id(credential_id)
try: try:
container.credentials.put(credential_id, request.api_key.get_secret_value()) container.credentials.put(credential_id, request.api_key.get_secret_value())
except CredentialStoreError as exc: except CredentialStoreError as exc:
@@ -682,6 +694,7 @@ async def put_credential(
tags=["Providers"], tags=["Providers"],
) )
async def delete_credential(credential_id: str) -> CredentialStatus: async def delete_credential(credential_id: str) -> CredentialStatus:
validate_public_credential_id(credential_id)
try: try:
container.credentials.delete(credential_id) container.credentials.delete(credential_id)
except CredentialStoreError as exc: except CredentialStoreError as exc:
@@ -718,6 +731,7 @@ async def get_provider(provider_id: str) -> ProviderConfig:
tags=["Providers"], tags=["Providers"],
) )
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig: async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
validate_public_credential_id(request.credential_id)
config = ProviderConfig( config = ProviderConfig(
provider_id=f"provider_{uuid4().hex}", provider_id=f"provider_{uuid4().hex}",
provider_type=request.provider_type, provider_type=request.provider_type,
@@ -761,6 +775,8 @@ async def update_provider(
"name and enabled cannot be null when explicitly provided.", "name and enabled cannot be null when explicitly provided.",
) )
updates = {name: getattr(request, name) for name in fields} updates = {name: getattr(request, name) for name in fields}
if "credential_id" in fields:
validate_public_credential_id(request.credential_id)
config = ProviderConfig.model_validate( config = ProviderConfig.model_validate(
{**current.model_dump(mode="python"), **updates} {**current.model_dump(mode="python"), **updates}
) )
@@ -820,6 +836,7 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse: async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
registered = configurable_provider_or_404(request.provider_id) registered = configurable_provider_or_404(request.provider_id)
if request.credential_context_id: if request.credential_context_id:
validate_public_credential_id(request.credential_context_id)
temporary_config = registered.config.model_copy( temporary_config = registered.config.model_copy(
update={"credential_id": request.credential_context_id, "enabled": True} update={"credential_id": request.credential_context_id, "enabled": True}
) )
+31 -1
View File
@@ -1,16 +1,20 @@
import asyncio import asyncio
import httpx import httpx
import pytest
from app.config import get_settings from app.config import get_settings
from app.contracts import CredentialWriteRequest from app.contracts import CredentialWriteRequest
from app.errors import ApiError
from app.providers.credentials import ( from app.providers.credentials import (
ChainedCredentialResolver, ChainedCredentialResolver,
CredentialStoreError,
EncryptedCredentialStore, EncryptedCredentialStore,
EnvironmentCredentialResolver, EnvironmentCredentialResolver,
) )
from app.providers.factory import ProviderFactory
from app.providers.openai_compatible import OpenAICompatibleProvider from app.providers.openai_compatible import OpenAICompatibleProvider
from app.routes import get_credential_status, put_credential from app.routes import delete_credential, get_credential_status, put_credential
def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None: def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None:
@@ -72,3 +76,29 @@ def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch
resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver()) resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
assert resolver.resolve("deepseek") == "saved-key" assert resolver.resolve("deepseek") == "saved-key"
def test_public_credential_api_rejects_plugin_namespace() -> None:
operations = [
get_credential_status("plugin.text-tools.api_key"),
put_credential(
"plugin.text-tools.api_key",
CredentialWriteRequest(api_key="must-not-write"),
),
delete_credential("plugin.text-tools.api_key"),
]
for operation in operations:
with pytest.raises(ApiError) as exc:
asyncio.run(operation)
assert exc.value.code == "CREDENTIAL_NAMESPACE_RESERVED"
assert EncryptedCredentialStore().resolve("plugin.text-tools.api_key") is None
def test_provider_resolver_cannot_read_plugin_secret() -> None:
store = EncryptedCredentialStore()
store.put("plugin.text-tools.api_key", "private-plugin-secret")
resolver = ProviderFactory(store).credentials
with pytest.raises(CredentialStoreError, match="reserved for Plugin settings"):
resolver.resolve("plugin.text-tools.api_key")
+102 -1
View File
@@ -106,7 +106,9 @@ def test_command_only_receives_declared_context() -> None:
def __init__(self) -> None: def __init__(self) -> None:
self.context = None self.context = None
async def execute_command(self, handler, arguments, context, settings): async def execute_command(
self, handler, arguments, context, settings, resolve_secret
):
self.context = context self.context = context
return PluginCommandEffect(type="none") return PluginCommandEffect(type="none")
@@ -128,6 +130,81 @@ def test_command_only_receives_declared_context() -> None:
assert host.context == {"selection": "visible"} assert host.context == {"selection": "visible"}
def test_command_resolves_only_declared_plugin_secrets(tmp_path: Path) -> None:
class SecretHost(DeclarativePluginHost):
def __init__(self) -> None:
self.secret = None
self.denied_code = None
async def execute_command(
self, handler, arguments, context, settings, resolve_secret
):
self.secret = resolve_secret("api_key")
try:
resolve_secret("undeclared")
except ExtensionError as exc:
self.denied_code = exc.code
return PluginCommandEffect(type="none")
host = SecretHost()
runtime = PluginRuntime(ToolRegistry(), host=host)
package = tmp_path / "secret-command"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: secret-command
name: Secret Command
version: 1.0.0
permissions: [secrets.use]
contributes:
commands: [secret-command.run]
settings_sections: [secret-command.general]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text(
"""
commands:
- command_id: secret-command.run
title: Secret Command
locations: [command_palette]
secrets: [api_key]
handler: echo
""".strip(),
encoding="utf-8",
)
(package / "settings.yaml").write_text(
"""
section_id: secret-command.general
schema_version: 1
fields:
- key: api_key
label: API Key
type: secret
""".strip(),
encoding="utf-8",
)
runtime.install(package)
runtime.set_permissions("secret-command", ["secrets.use"])
runtime.enable("secret-command")
runtime.put_setting_secret("secret-command", "api_key", "runtime-only-secret")
run(
runtime.execute_command(
"secret-command.run",
{},
PluginCommandContext(selection="visible"),
)
)
assert host.secret == "runtime-only-secret"
assert host.denied_code == "PLUGIN_SECRET_ACCESS_DENIED"
assert "runtime-only-secret" not in repr(runtime.commands.audit_events())
def test_settings_schema_contains_defaults_and_hides_secret() -> None: def test_settings_schema_contains_defaults_and_hides_secret() -> None:
container = build_container() container = build_container()
@@ -262,6 +339,30 @@ fields:
assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID" assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
def test_null_command_list_returns_stable_manifest_error(tmp_path: Path) -> None:
package = tmp_path / "null-commands"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: null-commands
name: Null Commands
version: 1.0.0
contributes:
commands: []
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text("commands:\n", encoding="utf-8")
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "EXTENSION_MANIFEST_INVALID"
def test_settings_missing_and_secret_field_errors_are_stable() -> None: def test_settings_missing_and_secret_field_errors_are_stable() -> None:
container = build_container() container = build_container()
@@ -2329,7 +2329,7 @@ Markdown Workspace
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
截至 2026-09-02,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host 以及 Plugin Command/Settings 后端 Contract 也已完成。当前验证基线为后端 103 项测试、前端 29 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 截至 2026-09-02,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host 以及 Plugin Command/Settings 后端 Contract 也已完成。当前验证基线为后端 107 项测试、前端 29 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
第二阶段在既有 Contract 上接入: 第二阶段在既有 Contract 上接入:
@@ -176,7 +176,7 @@ RunCancelled
## 当前实现状态 ## 当前实现状态
更新至 2026-09-02:后端 103 项回归测试通过;第二阶段 Plugin Command 与 Plugin Settings/Secret 接口已实现,详细 DTO 和边界见《第二阶段接口契约-开发版》第 7 节。 更新至 2026-09-02:后端 107 项回归测试通过;第二阶段 Plugin Command 与 Plugin Settings/Secret 接口已实现,详细 DTO 和边界见《第二阶段接口契约-开发版》第 7 节。
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Agent Run/Event 已持久化到 SQLiteSSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。 - Agent Run/Event 已持久化到 SQLiteSSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
@@ -544,6 +544,8 @@ error
当前宿主只注册已启用且已满足权限授权的 Plugin Command。执行前按 JSON Schema 校验参数、按 `when` 校验上下文,再根据 Command 声明裁剪 Context;单次执行默认超时 30 秒,effect 的 JSON 编码结果不得超过 64 KiB。宿主保留最多 500 条轻量审计事件,仅记录 Command、Plugin、状态、耗时和错误码,不记录 arguments、Context、effect 或 Secret。 当前宿主只注册已启用且已满足权限授权的 Plugin Command。执行前按 JSON Schema 校验参数、按 `when` 校验上下文,再根据 Command 声明裁剪 Context;单次执行默认超时 30 秒,effect 的 JSON 编码结果不得超过 64 KiB。宿主保留最多 500 条轻量审计事件,仅记录 Command、Plugin、状态、耗时和错误码,不记录 arguments、Context、effect 或 Secret。
需要 Secret 的 Command 必须在 `commands.yaml` 的内部 `secrets` 数组中声明对应 Setting Key,并在 Plugin Manifest 声明 `secrets.use` 权限。安装时宿主校验该字段确实属于当前 Plugin Settings Schema 的 `secret` 类型;只有权限已授予并启用后,运行时才向受控 handler 提供按需 Resolver,未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED``secrets` 不属于前端 `PluginCommand` DTOSecret 明文也不会并入普通 Settings 字典。
### 7.5 Settings Schema ### 7.5 Settings Schema
`GET /api/plugins/{plugin_id}/settings` `GET /api/plugins/{plugin_id}/settings`
@@ -627,6 +629,8 @@ Secret 明文不进入普通 Settings、日志、Trace、Benchmark Dataset 或
非敏感值按 `plugin_id` 写入 `APP_DATA_DIR/plugins/settings.json`。该文件只保存普通值、Schema 版本和确定性的 Secret ReferenceSecret 本身由宿主凭据存储加密保存。卸载 Plugin 时同时清理它的 Settings 命名空间和 Secret Reference。当前开发阶段使用 Fernet 文件凭据存储,第三阶段接入桌面 Host 后应迁移到 Stronghold 或系统 Keychain。 非敏感值按 `plugin_id` 写入 `APP_DATA_DIR/plugins/settings.json`。该文件只保存普通值、Schema 版本和确定性的 Secret ReferenceSecret 本身由宿主凭据存储加密保存。卸载 Plugin 时同时清理它的 Settings 命名空间和 Secret Reference。当前开发阶段使用 Fernet 文件凭据存储,第三阶段接入桌面 Host 后应迁移到 Stronghold 或系统 Keychain。
`plugin.*` 为宿主保留凭据命名空间。`/api/credentials/{credential_id}`、Provider 持久配置、Provider 临时测试凭据和 Provider Resolver 均拒绝该前缀,防止通过 Provider 链路覆盖、删除或向外部 Base URL 发送 Plugin Secret。
### 7.7 Plugin/MCP 错误码 ### 7.7 Plugin/MCP 错误码
```text ```text
@@ -652,9 +656,11 @@ PLUGIN_SETTINGS_SCHEMA_INVALID
PLUGIN_SETTINGS_VERSION_CONFLICT PLUGIN_SETTINGS_VERSION_CONFLICT
PLUGIN_SETTINGS_FIELD_INVALID PLUGIN_SETTINGS_FIELD_INVALID
PLUGIN_SECRET_FIELD_NOT_FOUND PLUGIN_SECRET_FIELD_NOT_FOUND
PLUGIN_SECRET_ACCESS_DENIED
PLUGIN_SECRET_VALUE_INVALID PLUGIN_SECRET_VALUE_INVALID
PLUGIN_SECRET_STORE_ERROR PLUGIN_SECRET_STORE_ERROR
PLUGIN_STORAGE_ERROR PLUGIN_STORAGE_ERROR
CREDENTIAL_NAMESPACE_RESERVED
``` ```
--- ---
@@ -2,7 +2,7 @@
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。 > 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
> 更新日期:2026-09-02。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host 以及 Plugin Command/Settings 已落地,后端当前回归基线为 103 项测试通过。 > 更新日期:2026-09-02。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host 以及 Plugin Command/Settings 已落地,后端当前回归基线为 107 项测试通过。
## 当前实现 ## 当前实现
@@ -3,7 +3,7 @@
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的 > 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。 > 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
> 更新日期:2026-09-02。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 103 项测试通过。 > 更新日期:2026-09-02。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 107 项测试通过。
## 当前实现 ## 当前实现
@@ -1,6 +1,6 @@
# Plugin Command 与 Settings 开发说明 # Plugin Command 与 Settings 开发说明
> 更新日期:2026-09-02。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界和前端 Service Contract。当前回归基线为后端 103 项测试、前端 29 项测试,TypeScript 类型检查和生产构建通过。 > 更新日期:2026-09-02。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界和前端 Service Contract。当前回归基线为后端 107 项测试、前端 29 项测试,TypeScript 类型检查和生产构建通过。
## 1. 阶段目标 ## 1. 阶段目标
@@ -22,6 +22,7 @@ Plugin 在 `plugin.yaml` 的 `contributes.commands` 与 `contributes.settings_se
- `locations``command_palette``context_menu``toolbar` - `locations``command_palette``context_menu``toolbar`
- `when` 与允许传入执行器的 Context 字段; - `when` 与允许传入执行器的 Context 字段;
- 参数 JSON Schema、可选权限、受控 handler 和超时。 - 参数 JSON Schema、可选权限、受控 handler 和超时。
- 可选 `secrets` 字段:只声明当前 Command 允许按需读取的 Secret Setting Key,不暴露给前端 DTO。
`settings.yaml` 采用递增 `schema_version`,首批字段类型固定为 `string``number``boolean``select``secret`。宿主会校验默认值、必填项、数值边界、Select 选项,以及 Secret 不得携带默认明文。 `settings.yaml` 采用递增 `schema_version`,首批字段类型固定为 `string``number``boolean``select``secret`。宿主会校验默认值、必填项、数值边界、Select 选项,以及 Secret 不得携带默认明文。
@@ -43,7 +44,7 @@ Plugin 在 `plugin.yaml` 的 `contributes.commands` 与 `contributes.settings_se
首批 effect 为 `none``notification``navigate``refresh``job`。前端不得把 effect 当作任意代码执行。 首批 effect 为 `none``notification``navigate``refresh``job`。前端不得把 effect 当作任意代码执行。
Command 审计使用 500 条有界内存队列,仅保留 `command_id``plugin_id`、成功/失败状态、耗时、错误码和时间。arguments、正文选区、文件路径、effect 与 Secret 均不进入审计事件。 Command 执行器通过受控 Resolver 按需读取 `commands.yaml` 已声明且确实属于当前 Plugin Schema 的 Secret;使用 Secret 的 Plugin 还必须声明并获授 `secrets.use` 权限,读取未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`。Secret 不会并入普通 Settings 字典。Command 审计使用 500 条有界内存队列,仅保留 `command_id``plugin_id`、成功/失败状态、耗时、错误码和时间。arguments、正文选区、文件路径、effect 与 Secret 均不进入审计事件。
当前声明式宿主提供安全白名单 handler,后续如允许 MCP Server 承担 Command 逻辑,应增加独立的 MCP Command Target Contract,不能把插件给出的模块路径或 Shell 字符串直接执行。 当前声明式宿主提供安全白名单 handler,后续如允许 MCP Server 承担 Command 逻辑,应增加独立的 MCP Command Target Contract,不能把插件给出的模块路径或 Shell 字符串直接执行。
@@ -61,7 +62,7 @@ APP_DATA_DIR/plugins/settings.json
- 非敏感字段值; - 非敏感字段值;
- Secret 的确定性引用,例如 `plugin.text-tools.api_key` - Secret 的确定性引用,例如 `plugin.text-tools.api_key`
Secret 写入必须调用专用端点。后端通过 `SecretStr` 接收明文,再交给现有 `EncryptedCredentialStore`;普通 Settings API 只返回 `{ configured: true|false }`,不会返回 Secret 值。卸载 Plugin 时同时删除普通设置命名空间和对应加密凭据。 Secret 写入必须调用专用端点。后端通过 `SecretStr` 接收明文,再交给现有 `EncryptedCredentialStore`;普通 Settings API 只返回 `{ configured: true|false }`,不会返回 Secret 值。`plugin.*` 是保留命名空间,通用凭据 API、Provider 配置、Provider 临时测试凭据和 Provider Resolver 均不得访问,防止覆盖、删除或外发 Plugin Secret。卸载 Plugin 时同时删除普通设置命名空间和对应加密凭据。
开发阶段凭据文件由本机 Fernet Key 加密。桌面端落地后,应由 Tauri Host 将同一引用语义迁移到 Stronghold 或系统 KeychainHTTP Contract 无需因此改变。 开发阶段凭据文件由本机 Fernet Key 加密。桌面端落地后,应由 Tauri Host 将同一引用语义迁移到 Stronghold 或系统 KeychainHTTP Contract 无需因此改变。
@@ -102,6 +103,6 @@ pnpm type-check
pnpm build pnpm build
``` ```
阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、无效贡献文件、OpenAPI 路径和前端 Service 请求格式。 阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径和前端 Service 请求格式。
生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。 生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。
@@ -187,12 +187,12 @@ pnpm build
```text ```text
pnpm build passed pnpm build passed
pnpm test 29 passed pnpm test 29 passed
uv run pytest 103 passed uv run pytest 107 passed
preview smoke HTTP 200 preview smoke HTTP 200
git diff --check passed git diff --check passed
``` ```
当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、Plugin Command/Settings Service、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 103 项测试结果,也不涉及产品代码。 当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、Plugin Command/Settings Service、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 107 项测试结果,也不涉及产品代码。
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
@@ -104,4 +104,4 @@ pnpm build
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。 自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
当前完整回归基线:后端 103 项测试、前端 29 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 当前完整回归基线:后端 107 项测试、前端 29 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。`plugin.*` 为 Plugin Secret 保留命名空间,Provider 配置、临时测试凭据和通用凭据 API 均拒绝该前缀。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
@@ -4,7 +4,7 @@
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。 > 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。 > 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
> 2026-09-02 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化、stdio MCP Plugin Host 和 Plugin Command/Settings,当前完整后端回归基线为 103 项测试通过。 > 2026-09-02 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化、stdio MCP Plugin Host 和 Plugin Command/Settings,当前完整后端回归基线为 107 项测试通过。
## 1. 审阅结论 ## 1. 审阅结论