fix(extension): 收紧插件密钥与命令清单边界
This commit is contained in:
@@ -118,7 +118,7 @@ cd frontend
|
||||
pnpm test
|
||||
```
|
||||
|
||||
当前回归基线为后端 103 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
当前回归基线为后端 107 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
|
||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
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` 为准。
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class PluginCommandSpec(BaseModel):
|
||||
}
|
||||
)
|
||||
permission: str | None = None
|
||||
secrets: list[str] = Field(default_factory=list)
|
||||
handler: Literal["echo", "uppercase_selection"]
|
||||
timeout_seconds: int = Field(default=30, ge=1, le=120)
|
||||
|
||||
@@ -81,6 +82,7 @@ CommandExecutor = Callable[
|
||||
[dict[str, Any], dict[str, Any]],
|
||||
PluginCommandEffect | Awaitable[PluginCommandEffect],
|
||||
]
|
||||
PluginSecretResolver = Callable[[str], str | None]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -656,6 +658,12 @@ def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None:
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"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)
|
||||
if unknown_when:
|
||||
raise ExtensionError(
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.contracts import (
|
||||
PluginManifest,
|
||||
PluginHostStatus,
|
||||
PluginSecretStatus,
|
||||
PluginSettingType,
|
||||
PluginSettingsSchema,
|
||||
PluginStatus,
|
||||
RetrievalConfig,
|
||||
@@ -35,6 +36,7 @@ from app.contracts import (
|
||||
from app.extensions.contributions import (
|
||||
CommandRegistry,
|
||||
PluginCommandSpec,
|
||||
PluginSecretResolver,
|
||||
PluginSettingsDefinition,
|
||||
PluginSettingsStore,
|
||||
validate_command_spec,
|
||||
@@ -245,6 +247,7 @@ class DeclarativePluginHost:
|
||||
arguments: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
settings: dict[str, Any],
|
||||
resolve_secret: PluginSecretResolver,
|
||||
) -> PluginCommandEffect:
|
||||
"""执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。"""
|
||||
|
||||
@@ -381,6 +384,32 @@ class PluginRuntime:
|
||||
)
|
||||
if settings_definition is not None:
|
||||
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(
|
||||
plugin=Plugin(
|
||||
@@ -502,6 +531,16 @@ class PluginRuntime:
|
||||
_spec: PluginCommandSpec = spec,
|
||||
_record: _PluginRecord = record,
|
||||
) -> 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 = (
|
||||
self.settings.get(
|
||||
_record.plugin.manifest.plugin_id,
|
||||
@@ -510,8 +549,38 @@ class PluginRuntime:
|
||||
if _record.settings_definition is not None
|
||||
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(
|
||||
_spec.handler, arguments, context, settings
|
||||
_spec.handler,
|
||||
arguments,
|
||||
context,
|
||||
settings,
|
||||
resolve_secret,
|
||||
)
|
||||
|
||||
self.commands.register(plugin_id, spec, command_executor)
|
||||
@@ -782,10 +851,16 @@ class PluginRuntime:
|
||||
if not path.exists():
|
||||
return []
|
||||
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:
|
||||
return [
|
||||
PluginCommandSpec.model_validate(item)
|
||||
for item in raw.get("commands", [])
|
||||
for item in items
|
||||
]
|
||||
except ValidationError as exc:
|
||||
raise _manifest_error("plugin command", exc) from exc
|
||||
|
||||
@@ -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}$")
|
||||
_PLUGIN_CREDENTIAL_PREFIX = "plugin."
|
||||
|
||||
|
||||
class CredentialStoreError(RuntimeError):
|
||||
@@ -23,6 +24,15 @@ class CredentialResolver(Protocol):
|
||||
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:
|
||||
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
|
||||
|
||||
@@ -170,3 +180,14 @@ class ChainedCredentialResolver:
|
||||
if value:
|
||||
return value
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
|
||||
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.openai_compatible import OpenAICompatibleProvider
|
||||
|
||||
@@ -11,7 +11,9 @@ class UnsupportedProviderError(ValueError):
|
||||
|
||||
class ProviderFactory:
|
||||
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:
|
||||
if config.provider_type in {
|
||||
|
||||
+18
-1
@@ -75,7 +75,10 @@ from app.extensions import ExtensionError
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
from app.providers.factory import UnsupportedProviderError
|
||||
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.services import (
|
||||
index_service,
|
||||
@@ -92,6 +95,13 @@ def utc_now() -> datetime:
|
||||
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:
|
||||
id_line = f"id: {event_id}\n" if event_id is not None else ""
|
||||
return f"{id_line}event: {event}\ndata: {payload}\n\n"
|
||||
@@ -654,6 +664,7 @@ async def delete_plugin_setting_secret(
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def get_credential_status(credential_id: str) -> CredentialStatus:
|
||||
validate_public_credential_id(credential_id)
|
||||
try:
|
||||
configured = container.credentials.has(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
@@ -669,6 +680,7 @@ async def get_credential_status(credential_id: str) -> CredentialStatus:
|
||||
async def put_credential(
|
||||
credential_id: str, request: CredentialWriteRequest
|
||||
) -> CredentialStatus:
|
||||
validate_public_credential_id(credential_id)
|
||||
try:
|
||||
container.credentials.put(credential_id, request.api_key.get_secret_value())
|
||||
except CredentialStoreError as exc:
|
||||
@@ -682,6 +694,7 @@ async def put_credential(
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def delete_credential(credential_id: str) -> CredentialStatus:
|
||||
validate_public_credential_id(credential_id)
|
||||
try:
|
||||
container.credentials.delete(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
@@ -718,6 +731,7 @@ async def get_provider(provider_id: str) -> ProviderConfig:
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
|
||||
validate_public_credential_id(request.credential_id)
|
||||
config = ProviderConfig(
|
||||
provider_id=f"provider_{uuid4().hex}",
|
||||
provider_type=request.provider_type,
|
||||
@@ -761,6 +775,8 @@ async def update_provider(
|
||||
"name and enabled cannot be null when explicitly provided.",
|
||||
)
|
||||
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(
|
||||
{**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:
|
||||
registered = configurable_provider_or_404(request.provider_id)
|
||||
if request.credential_context_id:
|
||||
validate_public_credential_id(request.credential_context_id)
|
||||
temporary_config = registered.config.model_copy(
|
||||
update={"credential_id": request.credential_context_id, "enabled": True}
|
||||
)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import CredentialWriteRequest
|
||||
from app.errors import ApiError
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
CredentialStoreError,
|
||||
EncryptedCredentialStore,
|
||||
EnvironmentCredentialResolver,
|
||||
)
|
||||
from app.providers.factory import ProviderFactory
|
||||
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:
|
||||
@@ -72,3 +76,29 @@ def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch
|
||||
resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
|
||||
|
||||
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")
|
||||
|
||||
@@ -106,7 +106,9 @@ def test_command_only_receives_declared_context() -> None:
|
||||
def __init__(self) -> 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
|
||||
return PluginCommandEffect(type="none")
|
||||
|
||||
@@ -128,6 +130,81 @@ def test_command_only_receives_declared_context() -> None:
|
||||
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:
|
||||
container = build_container()
|
||||
|
||||
@@ -262,6 +339,30 @@ fields:
|
||||
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:
|
||||
container = build_container()
|
||||
|
||||
|
||||
@@ -2329,7 +2329,7 @@ Markdown Workspace
|
||||
|
||||
第一阶段 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 上接入:
|
||||
|
||||
|
||||
@@ -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。
|
||||
- Agent Run/Event 已持久化到 SQLite;SSE 帧携带 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。
|
||||
|
||||
需要 Secret 的 Command 必须在 `commands.yaml` 的内部 `secrets` 数组中声明对应 Setting Key,并在 Plugin Manifest 声明 `secrets.use` 权限。安装时宿主校验该字段确实属于当前 Plugin Settings Schema 的 `secret` 类型;只有权限已授予并启用后,运行时才向受控 handler 提供按需 Resolver,未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`。`secrets` 不属于前端 `PluginCommand` DTO,Secret 明文也不会并入普通 Settings 字典。
|
||||
|
||||
### 7.5 Settings Schema
|
||||
|
||||
`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 Reference;Secret 本身由宿主凭据存储加密保存。卸载 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 错误码
|
||||
|
||||
```text
|
||||
@@ -652,9 +656,11 @@ PLUGIN_SETTINGS_SCHEMA_INVALID
|
||||
PLUGIN_SETTINGS_VERSION_CONFLICT
|
||||
PLUGIN_SETTINGS_FIELD_INVALID
|
||||
PLUGIN_SECRET_FIELD_NOT_FOUND
|
||||
PLUGIN_SECRET_ACCESS_DENIED
|
||||
PLUGIN_SECRET_VALUE_INVALID
|
||||
PLUGIN_SECRET_STORE_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 已经落地的
|
||||
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
|
||||
|
||||
> 更新日期: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 开发说明
|
||||
|
||||
> 更新日期: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. 阶段目标
|
||||
|
||||
@@ -22,6 +22,7 @@ Plugin 在 `plugin.yaml` 的 `contributes.commands` 与 `contributes.settings_se
|
||||
- `locations`:`command_palette`、`context_menu` 或 `toolbar`;
|
||||
- `when` 与允许传入执行器的 Context 字段;
|
||||
- 参数 JSON Schema、可选权限、受控 handler 和超时。
|
||||
- 可选 `secrets` 字段:只声明当前 Command 允许按需读取的 Secret Setting Key,不暴露给前端 DTO。
|
||||
|
||||
`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 当作任意代码执行。
|
||||
|
||||
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 字符串直接执行。
|
||||
|
||||
@@ -61,7 +62,7 @@ APP_DATA_DIR/plugins/settings.json
|
||||
- 非敏感字段值;
|
||||
- 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 或系统 Keychain,HTTP Contract 无需因此改变。
|
||||
|
||||
@@ -102,6 +103,6 @@ pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、无效贡献文件、OpenAPI 路径和前端 Service 请求格式。
|
||||
阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径和前端 Service 请求格式。
|
||||
|
||||
生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。
|
||||
|
||||
@@ -187,12 +187,12 @@ pnpm build
|
||||
```text
|
||||
pnpm build passed
|
||||
pnpm test 29 passed
|
||||
uv run pytest 103 passed
|
||||
uv run pytest 107 passed
|
||||
preview smoke HTTP 200
|
||||
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 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
|
||||
|
||||
|
||||
@@ -104,4 +104,4 @@ pnpm build
|
||||
|
||||
自动化验证覆盖 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、公共接口和后端开发文档。
|
||||
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
|
||||
|
||||
> 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. 审阅结论
|
||||
|
||||
|
||||
Reference in New Issue
Block a user