mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c0711a66 | ||
|
|
f770ba127d | ||
|
|
66719f22e2 | ||
|
|
00a1956df7 |
@@ -3,7 +3,7 @@ name: plugin-dev
|
||||
description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编写 plugin.json、实现公开 SDK 处理器、管理页面、打包与本地验证。用于新建插件、修改插件或排查插件接入;不用于 OpenAI Codex 的 .codex-plugin 插件、Vue 插件或仅修改宿主插件管理功能。
|
||||
---
|
||||
|
||||
# Codex Proxy 插件开发
|
||||
# plugin-dev
|
||||
|
||||
面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。
|
||||
仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。
|
||||
@@ -25,7 +25,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace
|
||||
2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节
|
||||
3. 核对实际运行宿主的版本、目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。同时确认打包 CLI 使用相同作者清单合同;不要只看源码版本或依赖包版本号。清单版本、通信版本、能力版本各自独立
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布;通过 GitHub 交付时另读其中的[发布与安装来源](references/development.md#发布与安装来源)
|
||||
|
||||
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
|
||||
|
||||
@@ -50,6 +50,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
|
||||
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "Codex Proxy 插件开发"
|
||||
display_name: "plugin-dev"
|
||||
short_description: "从能力选择到 SDK 实现、管理页面、打包与验证的插件开发指南"
|
||||
default_prompt: "使用 $plugin-dev 为 Codex Proxy RS 开发插件,先确认需要的能力,再按公开 SDK 实现、打包并验证。"
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
| --- | --- |
|
||||
| 主仓 `backend/crates/gateway-plugin/sdk/` | Rust 公开合同、会话辅助与能力文档 |
|
||||
| 主仓 `backend/apps/plugin-cli/` | `cpr-plugin package`,只校验与打包,不编译插件源码 |
|
||||
| 独立 `codex-proxy-plugins/examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| 独立 `codex-proxy-ui/` | 可选的 Vue UI 库、组件文档与 playground |
|
||||
| [codex-proxy-plugins](https://github.com/zyycn/codex-proxy-plugins) 的 `examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
|
||||
|
||||
先检查用户给定工程,或主仓父目录下的同级仓库是否存在;读取实际仓库约定、README、`Cargo.toml`、`package.json` 与锁文件。
|
||||
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
|
||||
@@ -72,13 +72,23 @@ cpr-plugin package \
|
||||
```
|
||||
|
||||
没有 `web` 资源时去掉 `--resource-map`。映射源相对于作者清单目录,而非命令执行目录;资源必须留在插件工程内。
|
||||
目标平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
|
||||
目标平台指运行宿主的服务器/容器,不是访问管理端的浏览器所在电脑;平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
|
||||
|
||||
输出为 `.tar.gz` 与 `.sha256`,不是源码 zip、npm 包或页面目录。归档根目录应有生成后的 `plugin.json`、可执行文件和声明资源。
|
||||
安装时以本次输出的包摘要为准,核对包内版本、平台、能力和 `engines` 与实际运行宿主匹配;
|
||||
源码已更新不代表 `dist` 中旧包已重建。版本要求未满足时使用兼容宿主或确有依据的兼容声明,不跳过宿主检查。
|
||||
本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。
|
||||
|
||||
## 发布与安装来源
|
||||
|
||||
仅在用户要求发布或准备分发时执行;沿用目标插件仓库的发布流程,核对源码提交、包内版本、目标平台与产物摘要。
|
||||
|
||||
- GitHub 安装读取 Release 附件,发布时上传实际生成的 `.tar.gz`/`.tgz` 和校验文件;仅推送 tag 或保留 GitHub 自动生成的源码归档,不能提供可安装插件
|
||||
- 稳定版应发布为非 Draft、非 Pre-release;需要支持留空标签安装时,再核对最新稳定版查询能返回预期 Release。版本号没有预发行后缀,不代表 GitHub 的发布状态已经是正式版
|
||||
- 当前宿主在标签留空时只查询最新稳定版;预发行版安装需要同时指定标签并允许预发行,不把该开关解释成自动发现预发行版
|
||||
- 多平台或多插件附件由用户按实际发布内容选择,文件名帮助辨识,包内清单才是平台、版本与兼容范围的依据
|
||||
- 发布完成后,从公开下载来源核对附件和摘要,再按已授权的目标环境验证安装;本地包验证不等于远程发行附件已经验证
|
||||
|
||||
## 验证与交付
|
||||
|
||||
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
|
||||
|
||||
Generated
+1
-1
@@ -1830,7 +1830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gateway-plugin-sdk"
|
||||
version = "0.4.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Codex Proxy Plugin CLI
|
||||
|
||||
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
|
||||
当前输出使用 `manifestVersion: 3`、`protocolVersion: 4` 和 SDK `0.4` 合同。
|
||||
当前输出使用 `manifestVersion: 1`、`protocolVersion: 1` 和 SDK `0.1` 合同。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) {
|
||||
|
||||
fn source_manifest() -> &'static [u8] {
|
||||
br#"{
|
||||
"manifestVersion":3,
|
||||
"manifestVersion":1,
|
||||
"name":"request-workbench",
|
||||
"displayName":"Request Workbench",
|
||||
"publisher":"codex-proxy",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"manifest_schema_versions": [3],
|
||||
"protocol_versions": [4],
|
||||
"manifest_schema_versions": [1],
|
||||
"protocol_versions": [1],
|
||||
"capabilities": [
|
||||
{ "capability": "frontend_authentication", "versions": [1] },
|
||||
{ "capability": "scheduler", "versions": [1] },
|
||||
|
||||
@@ -45,9 +45,6 @@ impl PluginCommandSession {
|
||||
}
|
||||
let payload = serde_json::to_vec(&invocation)
|
||||
.map_err(|_| AdminError::invalid("插件命令参数无法编码"))?;
|
||||
if payload.len() > self.limits.maximum_frame_bytes.saturating_sub(4096) {
|
||||
return Err(AdminError::invalid("插件命令参数超过帧预算").into());
|
||||
}
|
||||
let mut saved_accounts = 0;
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
@@ -97,7 +94,8 @@ impl PluginCommand {
|
||||
)
|
||||
.await
|
||||
.map_err(rpc_failure)?;
|
||||
if reply.result != serde_json::json!({}) || reply.payload.len() > limits.maximum_frame_bytes
|
||||
if reply.result != serde_json::json!({})
|
||||
|| reply.payload.len() > limits.maximum_buffered_body_bytes
|
||||
{
|
||||
return Err("结果信封无效");
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ impl ManagementEntry {
|
||||
.iter()
|
||||
.find(|route| route.method == request.method && route.path == request.path)
|
||||
.ok_or_else(|| AdminError::not_found("插件管理路由不存在"))?;
|
||||
let maximum_body = MAXIMUM_BODY_BYTES.min(limits.maximum_frame_bytes.saturating_sub(4096));
|
||||
let maximum_body = MAXIMUM_BODY_BYTES.min(limits.maximum_buffered_body_bytes);
|
||||
if request.body.len() > maximum_body
|
||||
|| request.query.len() > 8192
|
||||
|| request.query.bytes().any(|byte| byte.is_ascii_control())
|
||||
@@ -112,8 +112,7 @@ fn decode_response(
|
||||
.map_err(|_| AdminError::unavailable("插件管理响应元数据无效"))?;
|
||||
if !(200..=599).contains(&response.status)
|
||||
|| !allowed_content_types.contains(&response.content_type)
|
||||
|| reply.payload.len()
|
||||
> MAXIMUM_BODY_BYTES.min(limits.maximum_frame_bytes.saturating_sub(4096))
|
||||
|| reply.payload.len() > MAXIMUM_BODY_BYTES.min(limits.maximum_buffered_body_bytes)
|
||||
|| (matches!(response.status, 204 | 304) && !reply.payload.is_empty())
|
||||
{
|
||||
return Err(AdminError::unavailable(
|
||||
|
||||
@@ -166,7 +166,7 @@ impl PluginObserverPlan {
|
||||
mut entries: Vec<ObserverEntry>,
|
||||
maximum_calls: usize,
|
||||
maximum_call_timeout: Duration,
|
||||
maximum_frame_bytes: usize,
|
||||
maximum_buffered_body_bytes: usize,
|
||||
) -> Option<Arc<Self>> {
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
@@ -178,8 +178,8 @@ impl PluginObserverPlan {
|
||||
&right.instance_id,
|
||||
))
|
||||
});
|
||||
let maximum_websocket_payload_bytes = maximum_frame_bytes
|
||||
.saturating_sub(OBSERVATION_ENVELOPE_RESERVE.min(maximum_frame_bytes));
|
||||
let maximum_websocket_payload_bytes = maximum_buffered_body_bytes
|
||||
.saturating_sub(OBSERVATION_ENVELOPE_RESERVE.min(maximum_buffered_body_bytes));
|
||||
Some(Arc::new(Self {
|
||||
entries: entries.into(),
|
||||
slots: Arc::new(Semaphore::new(maximum_calls)),
|
||||
|
||||
@@ -60,7 +60,6 @@ impl MiddlewarePlan for PluginRequestPolicyPlan {
|
||||
context,
|
||||
terminal: Some(next),
|
||||
timeout: self.middleware_timeout,
|
||||
maximum_payload_bytes: self.maximum_payload_bytes,
|
||||
})
|
||||
.run(request)
|
||||
}
|
||||
@@ -73,7 +72,6 @@ struct MiddlewareChainNext {
|
||||
context: MiddlewareContext,
|
||||
terminal: Option<Box<dyn MiddlewareNext>>,
|
||||
timeout: Duration,
|
||||
maximum_payload_bytes: usize,
|
||||
}
|
||||
|
||||
impl MiddlewareNext for MiddlewareChainNext {
|
||||
@@ -97,7 +95,6 @@ impl MiddlewareNext for MiddlewareChainNext {
|
||||
context: self.context.clone(),
|
||||
terminal: self.terminal.take(),
|
||||
timeout: self.timeout,
|
||||
maximum_payload_bytes: self.maximum_payload_bytes,
|
||||
});
|
||||
invoke_middleware(
|
||||
&self.entries[entry_index],
|
||||
@@ -105,7 +102,6 @@ impl MiddlewareNext for MiddlewareChainNext {
|
||||
request,
|
||||
next,
|
||||
self.timeout,
|
||||
self.maximum_payload_bytes,
|
||||
)
|
||||
.await
|
||||
})
|
||||
@@ -118,7 +114,6 @@ async fn invoke_middleware(
|
||||
request: MiddlewareRequest,
|
||||
next: Box<dyn MiddlewareNext>,
|
||||
maximum_timeout: Duration,
|
||||
maximum_payload_bytes: usize,
|
||||
) -> Result<MiddlewareResponse, MiddlewareError> {
|
||||
let Some(invocation_ports) = &entry.invocation else {
|
||||
return if entry.failure_policy
|
||||
@@ -142,9 +137,6 @@ async fn invoke_middleware(
|
||||
Ok(projection) => projection,
|
||||
Err(_) => return recover_invalid(entry, &invocation).await,
|
||||
};
|
||||
if payload.len() > maximum_payload_bytes {
|
||||
return recover_invalid(entry, &invocation).await;
|
||||
}
|
||||
let mut call_context = invocation_ports.session.context(
|
||||
match entry.mount {
|
||||
MiddlewareMount::Request => Stage::Request,
|
||||
|
||||
@@ -236,7 +236,6 @@ pub(crate) struct PluginRequestPolicyPlan {
|
||||
middleware: Arc<[MiddlewareEntry]>,
|
||||
policy_timeout: Duration,
|
||||
middleware_timeout: Duration,
|
||||
maximum_payload_bytes: usize,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PluginRequestPolicyPlan {
|
||||
@@ -254,7 +253,6 @@ impl PluginRequestPolicyPlan {
|
||||
pub(crate) fn compile(
|
||||
entries: Vec<PolicyEntry>,
|
||||
maximum_call_timeout: Duration,
|
||||
maximum_frame_bytes: usize,
|
||||
) -> Result<Option<Arc<Self>>, AdminError> {
|
||||
let mut routers = Vec::new();
|
||||
let mut schedulers = Vec::new();
|
||||
@@ -304,7 +302,6 @@ impl PluginRequestPolicyPlan {
|
||||
middleware: middleware.into(),
|
||||
policy_timeout: maximum_call_timeout.min(POLICY_TIMEOUT),
|
||||
middleware_timeout: maximum_call_timeout,
|
||||
maximum_payload_bytes: maximum_frame_bytes,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use std::{
|
||||
io,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use futures::future::BoxFuture;
|
||||
@@ -40,12 +37,11 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
|
||||
) -> BoxFuture<'static, Result<ModelRouteDecision, RequestPolicyFault>> {
|
||||
let routers = std::sync::Arc::clone(&self.routers);
|
||||
let timeout = self.policy_timeout;
|
||||
let maximum_payload_bytes = self.maximum_payload_bytes;
|
||||
Box::pin(async move {
|
||||
for entry in routers.iter().filter(|entry| {
|
||||
!input.suppresses_plugin(&entry.instance_id) && entry.scope.matches_route(&input)
|
||||
}) {
|
||||
let result = route_with_entry(entry, &input, timeout, maximum_payload_bytes).await;
|
||||
let result = route_with_entry(entry, &input, timeout).await;
|
||||
match result {
|
||||
Ok(ModelRouteDecision::Unhandled) => continue,
|
||||
Ok(decision) => return Ok(decision),
|
||||
@@ -103,12 +99,11 @@ async fn route_with_entry(
|
||||
entry: &ModelRouterEntry,
|
||||
input: &ModelRouteInput,
|
||||
timeout: Duration,
|
||||
maximum_payload_bytes: usize,
|
||||
) -> Result<ModelRouteDecision, ()> {
|
||||
let invocation = entry.invocation.as_ref().ok_or(())?;
|
||||
let projection = operation_projection(input.operation())?;
|
||||
let payload = if entry.requests_authorized {
|
||||
projection.body.to_bytes(maximum_payload_bytes)?
|
||||
projection.body.to_bytes()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -264,15 +259,10 @@ enum OperationBody<'a> {
|
||||
}
|
||||
|
||||
impl OperationBody<'_> {
|
||||
fn to_bytes(&self, maximum: usize) -> Result<Vec<u8>, ()> {
|
||||
fn to_bytes(&self) -> Result<Vec<u8>, ()> {
|
||||
match self {
|
||||
Self::Raw(body) if body.len() <= maximum => Ok(body.to_vec()),
|
||||
Self::Raw(_) => Err(()),
|
||||
Self::Object(body) => {
|
||||
let mut writer = LimitedWriter::new(maximum);
|
||||
serde_json::to_writer(&mut writer, body).map_err(|_| ())?;
|
||||
Ok(writer.into_inner())
|
||||
}
|
||||
Self::Raw(body) => Ok(body.to_vec()),
|
||||
Self::Object(body) => serde_json::to_vec(body).map_err(|_| ()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,42 +434,3 @@ fn system_time_millis(value: Option<SystemTime>) -> Option<u64> {
|
||||
.try_into()
|
||||
.ok()
|
||||
}
|
||||
|
||||
struct LimitedWriter {
|
||||
bytes: Vec<u8>,
|
||||
maximum: usize,
|
||||
}
|
||||
|
||||
impl LimitedWriter {
|
||||
fn new(maximum: usize) -> Self {
|
||||
Self {
|
||||
bytes: Vec::new(),
|
||||
maximum,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_inner(self) -> Vec<u8> {
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for LimitedWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if self
|
||||
.bytes
|
||||
.len()
|
||||
.checked_add(bytes.len())
|
||||
.is_none_or(|length| length > self.maximum)
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"request body exceeds plugin payload limit",
|
||||
));
|
||||
}
|
||||
self.bytes.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ impl PluginRuntime {
|
||||
};
|
||||
let callbacks = Arc::new(PluginCallbacks::new(
|
||||
&grants,
|
||||
self.config.rpc_limits.maximum_frame_bytes,
|
||||
self.config.rpc_limits.maximum_buffered_body_bytes,
|
||||
&manifest,
|
||||
self.log_slots.clone(),
|
||||
private_state.clone(),
|
||||
|
||||
@@ -450,7 +450,7 @@ impl PluginRuntime {
|
||||
observer_entries,
|
||||
self.config.rpc_limits.maximum_calls,
|
||||
self.config.rpc_limits.maximum_call_timeout,
|
||||
self.config.rpc_limits.maximum_frame_bytes,
|
||||
self.config.rpc_limits.maximum_buffered_body_bytes,
|
||||
)
|
||||
.map(|plan| {
|
||||
self.observers
|
||||
@@ -461,7 +461,6 @@ impl PluginRuntime {
|
||||
let policy_plan = crate::adapter::policy::PluginRequestPolicyPlan::compile(
|
||||
policy_entries,
|
||||
self.config.rpc_limits.maximum_call_timeout,
|
||||
self.config.rpc_limits.maximum_frame_bytes,
|
||||
)?;
|
||||
let policies = policy_plan
|
||||
.as_ref()
|
||||
|
||||
@@ -2,19 +2,15 @@ use std::sync::Arc;
|
||||
|
||||
use gateway_plugin_sdk::{
|
||||
ErrorCode, Frame, Message, Permission, PluginFault, Stage,
|
||||
client::{read_frame, write_frame},
|
||||
client::{read_frame, validate_frame, write_frame},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncWrite},
|
||||
sync::{mpsc, oneshot},
|
||||
};
|
||||
|
||||
use super::session::{CallbackHandler, RpcError, RpcLimits, RpcReply, Shared};
|
||||
use super::session::{CallbackHandler, RpcError, RpcReply, Shared};
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "双向资源在一个入口交给独立读写任务,不引入重复连接状态"
|
||||
)]
|
||||
pub(super) fn start<W, R>(
|
||||
writer: W,
|
||||
reader: R,
|
||||
@@ -23,19 +19,12 @@ pub(super) fn start<W, R>(
|
||||
shared: Arc<Shared>,
|
||||
callbacks: Arc<dyn CallbackHandler>,
|
||||
permissions: Vec<Permission>,
|
||||
limits: RpcLimits,
|
||||
) where
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::spawn(write_loop(
|
||||
writer,
|
||||
data,
|
||||
control,
|
||||
Arc::clone(&shared),
|
||||
limits.maximum_frame_bytes,
|
||||
));
|
||||
tokio::spawn(read_loop(reader, shared, callbacks, permissions, limits));
|
||||
tokio::spawn(write_loop(writer, data, control, Arc::clone(&shared)));
|
||||
tokio::spawn(read_loop(reader, shared, callbacks, permissions));
|
||||
}
|
||||
|
||||
async fn write_loop<W: AsyncWrite + Unpin>(
|
||||
@@ -43,7 +32,6 @@ async fn write_loop<W: AsyncWrite + Unpin>(
|
||||
mut data: mpsc::Receiver<Frame>,
|
||||
mut control: mpsc::Receiver<Frame>,
|
||||
shared: Arc<Shared>,
|
||||
maximum: usize,
|
||||
) {
|
||||
let mut stopped = shared.stopped.subscribe();
|
||||
loop {
|
||||
@@ -73,7 +61,7 @@ async fn write_loop<W: AsyncWrite + Unpin>(
|
||||
let written = tokio::select! {
|
||||
biased;
|
||||
_ = stopped.changed() => return,
|
||||
result = write_frame(&mut writer, &frame, maximum) => result,
|
||||
result = write_frame(&mut writer, &frame) => result,
|
||||
};
|
||||
if written.is_err() {
|
||||
shared.fail(RpcError::Closed);
|
||||
@@ -87,7 +75,6 @@ async fn read_loop<R: AsyncRead + Unpin>(
|
||||
shared: Arc<Shared>,
|
||||
callbacks: Arc<dyn CallbackHandler>,
|
||||
permissions: Vec<Permission>,
|
||||
limits: RpcLimits,
|
||||
) {
|
||||
let mut stopped = shared.stopped.subscribe();
|
||||
let mut last_callback = 0;
|
||||
@@ -98,7 +85,7 @@ async fn read_loop<R: AsyncRead + Unpin>(
|
||||
let frame = tokio::select! {
|
||||
biased;
|
||||
_ = stopped.changed() => return,
|
||||
frame = read_frame(&mut reader, limits.maximum_frame_bytes) => frame,
|
||||
frame = read_frame(&mut reader) => frame,
|
||||
};
|
||||
let Ok(frame) = frame else {
|
||||
shared.fail(RpcError::Closed);
|
||||
@@ -148,7 +135,7 @@ async fn read_loop<R: AsyncRead + Unpin>(
|
||||
return;
|
||||
}
|
||||
let result = handler.call(context, method, params, frame.payload).await;
|
||||
let reply = match result {
|
||||
let mut reply = match result {
|
||||
Ok(reply) => Frame {
|
||||
message: Message::Result {
|
||||
id,
|
||||
@@ -158,6 +145,16 @@ async fn read_loop<R: AsyncRead + Unpin>(
|
||||
},
|
||||
Err(error) => Frame::control(Message::Error { id, error }),
|
||||
};
|
||||
// 宿主回调的局部编码错误只能结束该调用,不能关闭共享写通道。
|
||||
if validate_frame(&reply).is_err() {
|
||||
reply = Frame::control(Message::Error {
|
||||
id,
|
||||
error: PluginFault::new(
|
||||
ErrorCode::Fault,
|
||||
"host callback response cannot be encoded",
|
||||
),
|
||||
});
|
||||
}
|
||||
response.send_control(reply);
|
||||
drop(permit);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ use futures::future::BoxFuture;
|
||||
use gateway_host::process::{ProcessControl, ProcessSpec, ProcessStartError, ProcessSupervisor};
|
||||
use gateway_plugin_sdk::{
|
||||
CallContext, Frame, Handshake, Message, PROTOCOL_VERSION, PluginFault,
|
||||
client::{read_frame, write_frame},
|
||||
client::{read_frame, validate_frame, write_frame},
|
||||
};
|
||||
use tokio::sync::{Mutex as AsyncMutex, Semaphore, mpsc, oneshot, watch};
|
||||
|
||||
@@ -21,7 +21,8 @@ use super::dispatch;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RpcLimits {
|
||||
pub maximum_frame_bytes: usize,
|
||||
/// 受管回调与观察队列的缓冲预算,不是请求正文或 IPC 消息总量上限。
|
||||
pub maximum_buffered_body_bytes: usize,
|
||||
pub maximum_calls: usize,
|
||||
pub maximum_callbacks: usize,
|
||||
pub handshake_timeout: Duration,
|
||||
@@ -32,7 +33,7 @@ pub struct RpcLimits {
|
||||
impl Default for RpcLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
maximum_frame_bytes: 1024 * 1024,
|
||||
maximum_buffered_body_bytes: 1024 * 1024,
|
||||
maximum_calls: 16,
|
||||
maximum_callbacks: 16,
|
||||
handshake_timeout: Duration::from_secs(5),
|
||||
@@ -457,8 +458,8 @@ impl RpcSession {
|
||||
|| limits.maximum_calls > 256
|
||||
|| limits.maximum_callbacks == 0
|
||||
|| limits.maximum_callbacks > 256
|
||||
|| limits.maximum_frame_bytes < 1024
|
||||
|| limits.maximum_frame_bytes > 16 * 1024 * 1024
|
||||
|| limits.maximum_buffered_body_bytes < 1024
|
||||
|| limits.maximum_buffered_body_bytes > 16 * 1024 * 1024
|
||||
|| limits.handshake_timeout.is_zero()
|
||||
|| limits.maximum_call_timeout.is_zero()
|
||||
{
|
||||
@@ -467,6 +468,11 @@ impl RpcSession {
|
||||
let manifest = package.package().manifest();
|
||||
let plugin_id = manifest.plugin_id().map_err(|_| RpcError::Handshake)?;
|
||||
if handshake.protocol_version != PROTOCOL_VERSION
|
||||
|| manifest
|
||||
.package
|
||||
.as_ref()
|
||||
.map(|package| package.protocol_version)
|
||||
!= Some(handshake.protocol_version)
|
||||
|| handshake.plugin_id != plugin_id
|
||||
|| handshake.artifact_sha256 != package.package().digest()
|
||||
|| handshake.contributes != manifest.contributes
|
||||
@@ -516,18 +522,22 @@ impl RpcSession {
|
||||
&Frame::control(Message::Hello {
|
||||
handshake: handshake.clone(),
|
||||
}),
|
||||
limits.maximum_frame_bytes,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| RpcError::Handshake)?;
|
||||
let reply = read_frame(&mut output, limits.maximum_frame_bytes)
|
||||
let reply = read_frame(&mut output)
|
||||
.await
|
||||
.map_err(|_| RpcError::Handshake)?;
|
||||
match reply.message {
|
||||
Message::Ready {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
protocol_version,
|
||||
incarnation,
|
||||
} if incarnation == handshake.incarnation && reply.payload.is_empty() => Ok(()),
|
||||
} if protocol_version == handshake.protocol_version
|
||||
&& incarnation == handshake.incarnation
|
||||
&& reply.payload.is_empty() =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(RpcError::Handshake),
|
||||
}
|
||||
})
|
||||
@@ -571,7 +581,6 @@ impl RpcSession {
|
||||
Arc::clone(&shared),
|
||||
callbacks.clone(),
|
||||
handshake.permissions.clone(),
|
||||
limits,
|
||||
);
|
||||
let monitor = Arc::clone(&shared);
|
||||
let instance_id = handshake.instance_id.clone();
|
||||
@@ -657,7 +666,6 @@ impl RpcSession {
|
||||
&self,
|
||||
method: &str,
|
||||
context: &CallContext,
|
||||
payload: &[u8],
|
||||
) -> Result<tokio::time::Instant, RpcError> {
|
||||
if context.instance_id != self.handshake.instance_id
|
||||
|| context.generation != self.handshake.generation
|
||||
@@ -666,7 +674,6 @@ impl RpcSession {
|
||||
|| Duration::from_millis(context.timeout_ms) > self.limits.maximum_call_timeout
|
||||
|| method.is_empty()
|
||||
|| method.len() > 128
|
||||
|| payload.len() > self.limits.maximum_frame_bytes
|
||||
{
|
||||
return Err(RpcError::Context);
|
||||
}
|
||||
@@ -717,7 +724,7 @@ impl RpcSession {
|
||||
stream: Option<StreamIngress>,
|
||||
credit: Option<(u32, u32)>,
|
||||
) -> Result<CompletedCall, RpcError> {
|
||||
let deadline = self.call_deadline(method, &context, &payload)?;
|
||||
let deadline = self.call_deadline(method, &context)?;
|
||||
// FIFO 锁同时拥有 ID 与 data 入队顺序;Call/初始 Credit 入队后必须立即释放,
|
||||
// 不能把插件回复或重入回调纳入串行区,也不能为等待锁重置调用期限。
|
||||
let mut next_call = tokio::time::timeout_at(deadline, self.next_call.lock())
|
||||
@@ -736,6 +743,8 @@ impl RpcSession {
|
||||
},
|
||||
payload,
|
||||
};
|
||||
// 本地元数据错误不能进入写队列并关闭整个会话。
|
||||
validate_frame(&frame).map_err(|_| RpcError::Context)?;
|
||||
let started = self.begin_call(context, deadline, stream)?;
|
||||
let mut guard = CallGuard {
|
||||
shared: Arc::clone(&self.shared),
|
||||
@@ -819,7 +828,7 @@ impl RpcSession {
|
||||
let slot = Arc::clone(&self.slots)
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| RpcError::Capacity)?;
|
||||
let bytes = self.limits.maximum_frame_bytes.min(256 * 1024) as u32;
|
||||
let bytes = self.limits.maximum_buffered_body_bytes.min(256 * 1024) as u32;
|
||||
let frames = 32;
|
||||
let (stream, chunks, terminal) = StreamIngress::new(bytes, frames);
|
||||
let completed = self
|
||||
|
||||
@@ -51,12 +51,12 @@ async fn install(environment: &Environment, config: Value) -> PluginInstance {
|
||||
.map(|(name, bytes)| (name.clone(), hex::encode(Sha256::digest(bytes))))
|
||||
.collect();
|
||||
let manifest = json!({
|
||||
"manifestVersion":3,"name":"example","displayName":"管理示例","publisher":"management","version":"1.0.0",
|
||||
"manifestVersion":1,"name":"example","displayName":"管理示例","publisher":"management","version":"1.0.0",
|
||||
"engines":{"codex-proxy-rs":">=1.0.0, <2.0.0"},"main":"bin/worker","author":"test","description":"管理测试","license":"MIT",
|
||||
"runtime":"trustedProcess","resources":{"ui/index.html":"text/html","ui/app.js":"text/javascript","ui/public.svg":"image/svg+xml"},
|
||||
"contributes":{"management":{"id":"management.example.management","version":1,"stages":["management"],"inputFormats":[],"outputFormats":[]}},
|
||||
"permissions":["public_endpoints"],
|
||||
"package":{"protocolVersion":4,"target":{"os":std::env::consts::OS,"architecture":std::env::consts::ARCH},"files":digests},
|
||||
"package":{"protocolVersion":gateway_plugin_sdk::PROTOCOL_VERSION,"target":{"os":std::env::consts::OS,"architecture":std::env::consts::ARCH},"files":digests},
|
||||
});
|
||||
files.insert("plugin.json".into(), serde_json::to_vec(&manifest).unwrap());
|
||||
let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap())
|
||||
|
||||
@@ -1149,3 +1149,86 @@ async fn a_failed_middleware_preserves_scope_and_the_configured_failure_policy()
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
}
|
||||
}
|
||||
|
||||
struct BodyCheckingNext {
|
||||
calls: Arc<AtomicUsize>,
|
||||
expected: Bytes,
|
||||
}
|
||||
|
||||
impl MiddlewareNext for BodyCheckingNext {
|
||||
fn run(
|
||||
self: Box<Self>,
|
||||
request: MiddlewareRequest,
|
||||
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
|
||||
Box::pin(async move {
|
||||
assert!(
|
||||
request.body() == &self.expected,
|
||||
"request body changed unexpectedly"
|
||||
);
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(MiddlewareResponse::new(
|
||||
"openai".to_owned(),
|
||||
200,
|
||||
vec![],
|
||||
Box::new(OneFrameBody {
|
||||
reads: Arc::default(),
|
||||
closes: Arc::default(),
|
||||
frame: Some(MiddlewareFrame::new(
|
||||
Bytes::from_static(b"{}"),
|
||||
MiddlewareFraming::JsonDocument,
|
||||
true,
|
||||
)),
|
||||
}),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_preserves_large_body_and_following_requests() {
|
||||
let (cache, runtime) = setup(
|
||||
vec![InstanceFixture {
|
||||
id: "large-body",
|
||||
configuration: serde_json::json!({}),
|
||||
grants: vec![grant(Permission::Requests)],
|
||||
bindings: vec![binding(
|
||||
MIDDLEWARE_CONTRIBUTION,
|
||||
"request",
|
||||
0,
|
||||
PluginFailurePolicy::Reject,
|
||||
)],
|
||||
}],
|
||||
vec![Permission::Requests],
|
||||
)
|
||||
.await;
|
||||
let generation = prepare(&runtime).await;
|
||||
let plan = runtime.middleware_registry().resolve(&generation).unwrap();
|
||||
for length in [4 * 1024 * 1024 + 17, 32] {
|
||||
let payload = Bytes::from(
|
||||
(0..length)
|
||||
.map(|index| (index % 251) as u8)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let response = plan
|
||||
.handle(
|
||||
middleware_context(ClientTransport::HttpSse),
|
||||
MiddlewareRequest::new("openai", vec![], payload.clone()),
|
||||
Box::new(BodyCheckingNext {
|
||||
calls: Arc::clone(&calls),
|
||||
expected: payload,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (_, status, _, mut body, _) = response.into_parts();
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 1);
|
||||
assert!(body.next_frame().await.unwrap().is_some());
|
||||
assert!(body.next_frame().await.unwrap().is_none());
|
||||
body.close().await;
|
||||
}
|
||||
drop(plan);
|
||||
drop(generation);
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ fn package_with_icon(path: &str, content_type: &str, icon: Vec<u8>) -> std::sync
|
||||
]);
|
||||
let resources = BTreeMap::from([(path.to_owned(), content_type.to_owned())]);
|
||||
let manifest = serde_json::json!({
|
||||
"manifestVersion": 3,
|
||||
"manifestVersion": 1,
|
||||
"name": "icon",
|
||||
"displayName": "Icon",
|
||||
"publisher": "test",
|
||||
@@ -73,7 +73,7 @@ fn package_with_icon(path: &str, content_type: &str, icon: Vec<u8>) -> std::sync
|
||||
"resources": resources,
|
||||
"icon": path,
|
||||
"package": {
|
||||
"protocolVersion": 4,
|
||||
"protocolVersion": 1,
|
||||
"target": {
|
||||
"os": std::env::consts::OS,
|
||||
"architecture": std::env::consts::ARCH
|
||||
@@ -278,7 +278,7 @@ fn icon_content_must_fully_decode_match_its_type_and_fit_dimensions() {
|
||||
#[test]
|
||||
fn obsolete_manifest_fields_are_rejected_without_aliases() {
|
||||
let manifest = serde_json::json!({
|
||||
"manifestVersion": 3,
|
||||
"manifestVersion": 1,
|
||||
"name": "example",
|
||||
"displayName": "Example",
|
||||
"publisher": "test",
|
||||
@@ -303,7 +303,7 @@ fn obsolete_manifest_fields_are_rejected_without_aliases() {
|
||||
#[test]
|
||||
fn source_manifest_cannot_be_installed_without_package_metadata() {
|
||||
let manifest = serde_json::json!({
|
||||
"manifestVersion": 3,
|
||||
"manifestVersion": 1,
|
||||
"name": "example",
|
||||
"displayName": "Example",
|
||||
"publisher": "test",
|
||||
@@ -330,7 +330,7 @@ fn source_manifest_cannot_be_installed_without_package_metadata() {
|
||||
#[test]
|
||||
fn duplicate_contribution_keys_are_rejected_before_map_overwrite() {
|
||||
let manifest = br#"{
|
||||
"manifestVersion":3,
|
||||
"manifestVersion":1,
|
||||
"name":"example",
|
||||
"displayName":"Example",
|
||||
"publisher":"test",
|
||||
|
||||
@@ -32,6 +32,28 @@ struct LifecycleCallbacks {
|
||||
finished: AtomicUsize,
|
||||
}
|
||||
|
||||
struct OversizedReplyCallbacks;
|
||||
|
||||
impl CallbackHandler for OversizedReplyCallbacks {
|
||||
fn call(
|
||||
&self,
|
||||
_context: CallContext,
|
||||
_method: String,
|
||||
params: serde_json::Value,
|
||||
_payload: Vec<u8>,
|
||||
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
|
||||
Box::pin(async move {
|
||||
if params["error"] == true {
|
||||
return Err(PluginFault::new(ErrorCode::Fault, "x".repeat(64 * 1024)));
|
||||
}
|
||||
Ok(RpcReply {
|
||||
result: json!({"text": "x".repeat(64 * 1024)}),
|
||||
payload: Vec::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct CallbackDrop(Arc<std::sync::atomic::AtomicBool>);
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -361,6 +383,106 @@ async fn slow_management_call_does_not_block_an_independent_call() {
|
||||
session.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_binary_payload_round_trips_through_calls_and_callbacks() {
|
||||
let callbacks = Arc::new(Callbacks::default());
|
||||
let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await;
|
||||
let payload = (0..4 * 1024 * 1024 + 17)
|
||||
.map(|index| (index % 251) as u8)
|
||||
.collect::<Vec<_>>();
|
||||
for method in ["echo", "callback"] {
|
||||
let reply = session
|
||||
.call(
|
||||
method,
|
||||
session.context(Stage::Request, Duration::from_secs(30)),
|
||||
json!({"large": true}),
|
||||
payload.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reply.result, json!({"large": true}));
|
||||
assert_eq!(reply.payload, payload);
|
||||
}
|
||||
assert_eq!(callbacks.called.load(Ordering::Relaxed), 1);
|
||||
session.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_call_frames_do_not_close_the_session_or_begin_callbacks() {
|
||||
let callbacks = Arc::new(LifecycleCallbacks::default());
|
||||
let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await;
|
||||
let cases = [(json!({"text": "x".repeat(64 * 1024)}), vec![])];
|
||||
for (params, payload) in cases {
|
||||
let begun = callbacks.begun.load(Ordering::Relaxed);
|
||||
let error = session
|
||||
.call(
|
||||
"echo",
|
||||
session.context(Stage::Request, Duration::from_secs(2)),
|
||||
params.clone(),
|
||||
payload.clone(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("oversized call must fail");
|
||||
assert_eq!(error, RpcError::Context);
|
||||
assert!(matches!(
|
||||
session
|
||||
.call_stream(
|
||||
"stream",
|
||||
session.context(Stage::Request, Duration::from_secs(2)),
|
||||
params,
|
||||
payload,
|
||||
)
|
||||
.await,
|
||||
Err(RpcError::Context)
|
||||
));
|
||||
assert_eq!(callbacks.begun.load(Ordering::Relaxed), begun);
|
||||
assert!(session.is_ready());
|
||||
let reply = session
|
||||
.call(
|
||||
"echo",
|
||||
session.context(Stage::Request, Duration::from_secs(2)),
|
||||
json!({"still": "ready"}),
|
||||
vec![1, 2, 3],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reply.result, json!({"still": "ready"}));
|
||||
assert_eq!(reply.payload, [1, 2, 3]);
|
||||
}
|
||||
session.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_callback_metadata_only_fails_its_parent_call() {
|
||||
let (_cache, session) = session(vec![], Arc::new(OversizedReplyCallbacks)).await;
|
||||
for error in [false, true] {
|
||||
assert!(matches!(
|
||||
session
|
||||
.call(
|
||||
"callback",
|
||||
session.context(Stage::Request, Duration::from_secs(2)),
|
||||
json!({"error": error}),
|
||||
vec![],
|
||||
)
|
||||
.await,
|
||||
Err(RpcError::Remote(_))
|
||||
));
|
||||
assert!(session.is_ready());
|
||||
let reply = session
|
||||
.call(
|
||||
"echo",
|
||||
session.context(Stage::Request, Duration::from_secs(2)),
|
||||
json!({"still": "ready"}),
|
||||
vec![1, 2, 3],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reply.payload, [1, 2, 3]);
|
||||
}
|
||||
session.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_calls_keep_their_wire_ids_monotonic() {
|
||||
let callbacks = Arc::new(GatedFirstBegin::default());
|
||||
|
||||
@@ -1026,10 +1026,10 @@ async fn main() {
|
||||
tokio::spawn(async move {
|
||||
let mut output = tokio::io::stdout();
|
||||
while let Some(frame) = frames.recv().await {
|
||||
write_frame(&mut output, &frame, 1024 * 1024).await.unwrap();
|
||||
write_frame(&mut output, &frame).await.unwrap();
|
||||
}
|
||||
});
|
||||
let hello = read_frame(&mut input, 1024 * 1024).await.unwrap();
|
||||
let hello = read_frame(&mut input).await.unwrap();
|
||||
let Message::Hello { mut handshake } = hello.message else {
|
||||
panic!("expected handshake")
|
||||
};
|
||||
@@ -1115,7 +1115,7 @@ async fn main() {
|
||||
let mut tasks = BTreeMap::new();
|
||||
let mut uncancellable = std::collections::BTreeSet::new();
|
||||
let mut last_call = 0;
|
||||
while let Ok(frame) = read_frame(&mut input, 1024 * 1024).await {
|
||||
while let Ok(frame) = read_frame(&mut input).await {
|
||||
match frame.message {
|
||||
Message::Call {
|
||||
id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gateway-plugin-sdk"
|
||||
version = "0.4.0"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
@@ -6,9 +6,9 @@ SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声
|
||||
|
||||
| 版本 | 当前值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| SDK 包 | `0.4.0` | Rust 开发依赖,独立于网关版本 |
|
||||
| `manifestVersion` | `3` | 安装清单格式 |
|
||||
| `package.protocolVersion` | `4` | 宿主与插件的进程通信 |
|
||||
| SDK 包 | `0.1.0` | Rust 开发依赖,独立于网关版本 |
|
||||
| `manifestVersion` | `1` | 安装清单格式 |
|
||||
| `package.protocolVersion` | `1` | 宿主与插件的进程通信 |
|
||||
| `contributes.*.version` | `1` | 单项能力合同 |
|
||||
|
||||
开发顺序:编写[清单](docs/manifest.md) → 实现[能力处理器](docs/capabilities.md) → 用 [CLI](../../../apps/plugin-cli/README.md)
|
||||
@@ -49,8 +49,9 @@ use gateway_plugin_sdk::client::{read_frame, write_frame};
|
||||
会话管理握手、有界并发、父调用关联、回调、流信用、取消、排空与关闭。
|
||||
`ResponseStream::channel` 提供有界流生产入口;`PluginCall` 的宿主客户端只代表当前父调用,
|
||||
不能用另一调用的 ID 扩大权限。插件程序自行选择运行时和启动入口,不会启动网关。
|
||||
`SessionConfig` 默认帧上限与宿主默认值一致,为 1 MiB;部署调整宿主帧上限时,插件应同步配置,
|
||||
当前握手不自动协商该限制。流式分块还必须满足宿主实际授予的信用窗口。
|
||||
`SessionConfig.maximum_stream_chunk_bytes` 默认 1 MiB,只约束单个业务流分块;
|
||||
流式响应还需满足宿主实际授予的信用窗口。普通调用与回调的正文不受此预算限制。
|
||||
业务处理器仍接收完整正文,内存占用随正文增长。
|
||||
|
||||
## 通信边界
|
||||
|
||||
@@ -76,10 +77,12 @@ sequenceDiagram
|
||||
|
||||
### 帧与会话
|
||||
|
||||
每帧以两个大端 `u32` 分别声明 JSON 元数据和二进制载荷的长度,随后依次传输两部分。
|
||||
调用方提供总长度上限;元数据另有 64 KiB 上限。读取在分配内存前校验长度,截断帧和
|
||||
无效元数据返回 `FrameError`。这些函数不自动重试调用;部分读取或写入中断后,不应
|
||||
把同一字节位置误当作新帧边界。
|
||||
协议 v1 的帧头由大端 `u32` 元数据长度和大端 `u64` 正文长度组成,随后依次传输 JSON 元数据和原始二进制正文。
|
||||
元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块,
|
||||
不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付。
|
||||
|
||||
`read_frame` / `write_frame` 使用当前协议,不接受历史开发协议。帧写入队列之前先用 `validate_frame` 校验元数据,
|
||||
避免本地输入错误关闭共享传输。协议校验不会自动重试业务调用;部分读写中断后不能把当前位置当作新帧边界。
|
||||
底层 `read_frame` / `write_frame` 不是取消安全的;自行编排会话时,不能在 `select!` 的其他分支完成后
|
||||
重建半途中的帧读写。`PluginSession` 会在处理调用完成通知时保留同一个读取 future,直到完整帧到达或会话关闭。
|
||||
|
||||
|
||||
@@ -1,62 +1,58 @@
|
||||
use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
|
||||
|
||||
use crate::{Frame, FrameError, Message};
|
||||
use crate::{Frame, FrameError};
|
||||
|
||||
const MAX_METADATA_BYTES: usize = 64 * 1024;
|
||||
const IO_CHUNK_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// 八字节头分别是元数据和载荷的 u32 大端长度,分配内存前校验两者总量。
|
||||
pub async fn read_frame<R: AsyncRead + Unpin>(
|
||||
reader: &mut R,
|
||||
maximum: usize,
|
||||
) -> Result<Frame, FrameError> {
|
||||
/// 读取完整消息;I/O 分块是传输细节,不限制业务正文总量。
|
||||
pub async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Frame, FrameError> {
|
||||
let metadata_len = reader.read_u32().await? as usize;
|
||||
let payload_len = reader.read_u32().await? as usize;
|
||||
check_lengths(metadata_len, payload_len, maximum)?;
|
||||
let payload_len = usize::try_from(reader.read_u64().await?).map_err(|_| FrameError::Length)?;
|
||||
check_metadata_length(metadata_len)?;
|
||||
let mut metadata = vec![0; metadata_len];
|
||||
reader.read_exact(&mut metadata).await?;
|
||||
let message = serde_json::from_slice(&metadata).map_err(|_| FrameError::Metadata)?;
|
||||
let mut payload = vec![0; payload_len];
|
||||
reader.read_exact(&mut payload).await?;
|
||||
let mut payload = Vec::new();
|
||||
// 按实际读取进度分配,避免仅凭对端声明的大长度立即占满内存。
|
||||
while payload.len() < payload_len {
|
||||
let amount = (payload_len - payload.len()).min(IO_CHUNK_BYTES);
|
||||
payload
|
||||
.try_reserve(amount)
|
||||
.map_err(|_| FrameError::Io(std::io::Error::from(std::io::ErrorKind::OutOfMemory)))?;
|
||||
let start = payload.len();
|
||||
payload.resize(start + amount, 0);
|
||||
reader.read_exact(&mut payload[start..]).await?;
|
||||
}
|
||||
Ok(Frame { message, payload })
|
||||
}
|
||||
|
||||
pub async fn write_frame<W: AsyncWrite + Unpin>(
|
||||
writer: &mut W,
|
||||
frame: &Frame,
|
||||
maximum: usize,
|
||||
) -> Result<(), FrameError> {
|
||||
let metadata = serde_json::to_vec(&frame.message).map_err(|_| FrameError::Metadata)?;
|
||||
check_lengths(metadata.len(), frame.payload.len(), maximum)?;
|
||||
check_metadata_length(metadata.len())?;
|
||||
let metadata_len = u32::try_from(metadata.len()).map_err(|_| FrameError::Length)?;
|
||||
let payload_len = u32::try_from(frame.payload.len()).map_err(|_| FrameError::Length)?;
|
||||
let payload_len = u64::try_from(frame.payload.len()).map_err(|_| FrameError::Length)?;
|
||||
writer.write_u32(metadata_len).await?;
|
||||
writer.write_u32(payload_len).await?;
|
||||
writer.write_u64(payload_len).await?;
|
||||
writer.write_all(&metadata).await?;
|
||||
writer.write_all(&frame.payload).await?;
|
||||
for chunk in frame.payload.chunks(IO_CHUNK_BYTES) {
|
||||
writer.write_all(chunk).await?;
|
||||
}
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_frame(frame: &Frame, maximum: usize) -> Result<(), FrameError> {
|
||||
validate_message(&frame.message, frame.payload.len(), maximum)
|
||||
/// 入队前校验本地元数据,避免单次编码失败中断共享传输。
|
||||
pub fn validate_frame(frame: &Frame) -> Result<(), FrameError> {
|
||||
let metadata = serde_json::to_vec(&frame.message).map_err(|_| FrameError::Metadata)?;
|
||||
check_metadata_length(metadata.len())
|
||||
}
|
||||
|
||||
pub(super) fn validate_message(
|
||||
message: &Message,
|
||||
payload_len: usize,
|
||||
maximum: usize,
|
||||
) -> Result<(), FrameError> {
|
||||
let metadata = serde_json::to_vec(message).map_err(|_| FrameError::Metadata)?;
|
||||
check_lengths(metadata.len(), payload_len, maximum)
|
||||
}
|
||||
|
||||
fn check_lengths(metadata: usize, payload: usize, maximum: usize) -> Result<(), FrameError> {
|
||||
if metadata == 0
|
||||
|| metadata > MAX_METADATA_BYTES
|
||||
|| metadata
|
||||
.checked_add(payload)
|
||||
.is_none_or(|total| total > maximum)
|
||||
{
|
||||
fn check_metadata_length(metadata: usize) -> Result<(), FrameError> {
|
||||
if metadata == 0 || metadata > MAX_METADATA_BYTES {
|
||||
return Err(FrameError::Length);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -100,7 +100,7 @@ impl MiddlewareBody {
|
||||
};
|
||||
*touched = true;
|
||||
let maximum_bytes =
|
||||
u32::try_from(host.maximum_frame_bytes()).map_err(|_| invalid_input())?;
|
||||
u32::try_from(host.maximum_stream_chunk_bytes()).map_err(|_| invalid_input())?;
|
||||
let reply = host
|
||||
.call(
|
||||
BODY_READ_METHOD,
|
||||
|
||||
@@ -5,7 +5,7 @@ mod middleware;
|
||||
mod plugin;
|
||||
mod session;
|
||||
|
||||
pub use frame::{read_frame, write_frame};
|
||||
pub use frame::{read_frame, validate_frame, write_frame};
|
||||
pub use middleware::{
|
||||
MiddlewareBody, MiddlewareBodySender, MiddlewareCall, MiddlewareNext, MiddlewarePlugin,
|
||||
MiddlewareRequest, MiddlewareResponse,
|
||||
|
||||
@@ -19,16 +19,17 @@ use crate::{
|
||||
CallContext, ErrorCode, Frame, FrameError, Handshake, Message, PROTOCOL_VERSION, PluginFault,
|
||||
};
|
||||
|
||||
use super::frame::{read_frame, validate_frame, validate_message, write_frame};
|
||||
use super::frame::{read_frame, validate_frame, write_frame};
|
||||
|
||||
const MAXIMUM_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAXIMUM_STREAM_CHUNK_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAXIMUM_CONCURRENCY: usize = 256;
|
||||
const MAXIMUM_BUFFERED_STREAM_CHUNKS: usize = 65_536;
|
||||
|
||||
/// 插件侧会话的内存、并发与期限边界。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SessionConfig {
|
||||
pub maximum_frame_bytes: usize,
|
||||
/// 单个业务流分块的预算,不限制普通调用正文的总长度。
|
||||
pub maximum_stream_chunk_bytes: usize,
|
||||
pub maximum_calls: usize,
|
||||
pub maximum_callbacks: usize,
|
||||
pub maximum_buffered_stream_chunks: usize,
|
||||
@@ -39,8 +40,8 @@ pub struct SessionConfig {
|
||||
impl Default for SessionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// 与宿主默认帧预算一致,避免正文回调以本地较大上限申请后被拒绝。
|
||||
maximum_frame_bytes: 1024 * 1024,
|
||||
// 流分块还需匹配宿主授予的信用窗口。
|
||||
maximum_stream_chunk_bytes: 1024 * 1024,
|
||||
maximum_calls: 32,
|
||||
maximum_callbacks: 32,
|
||||
maximum_buffered_stream_chunks: 1_024,
|
||||
@@ -52,8 +53,8 @@ impl Default for SessionConfig {
|
||||
|
||||
impl SessionConfig {
|
||||
fn validate(self) -> Result<Self, SessionError> {
|
||||
if self.maximum_frame_bytes < 1_024
|
||||
|| self.maximum_frame_bytes > MAXIMUM_FRAME_BYTES
|
||||
if self.maximum_stream_chunk_bytes < 1_024
|
||||
|| self.maximum_stream_chunk_bytes > MAXIMUM_STREAM_CHUNK_BYTES
|
||||
|| self.maximum_calls == 0
|
||||
|| self.maximum_calls > MAXIMUM_CONCURRENCY
|
||||
|| self.maximum_callbacks == 0
|
||||
@@ -167,12 +168,12 @@ pub struct HostClient {
|
||||
cancellation: CallCancellation,
|
||||
callbacks: Arc<CallbackRegistry>,
|
||||
output: Output,
|
||||
maximum_frame_bytes: usize,
|
||||
maximum_stream_chunk_bytes: usize,
|
||||
}
|
||||
|
||||
impl HostClient {
|
||||
pub(crate) const fn maximum_frame_bytes(&self) -> usize {
|
||||
self.maximum_frame_bytes
|
||||
pub(crate) const fn maximum_stream_chunk_bytes(&self) -> usize {
|
||||
self.maximum_stream_chunk_bytes
|
||||
}
|
||||
|
||||
/// 发起一次与父调用关联的宿主回调。
|
||||
@@ -201,7 +202,7 @@ impl HostClient {
|
||||
},
|
||||
payload,
|
||||
};
|
||||
if validate_frame(&frame, self.maximum_frame_bytes).is_err() {
|
||||
if validate_frame(&frame).is_err() {
|
||||
self.callbacks.retire(id);
|
||||
return Err(SessionError::Protocol);
|
||||
}
|
||||
@@ -338,7 +339,7 @@ impl ResponseStream {
|
||||
fn validate_buffered(
|
||||
&self,
|
||||
maximum_chunks: usize,
|
||||
maximum_frame_bytes: usize,
|
||||
maximum_stream_chunk_bytes: usize,
|
||||
window_bytes: u64,
|
||||
) -> Result<(), PluginFault> {
|
||||
if self.declared_capacity > maximum_chunks {
|
||||
@@ -348,7 +349,7 @@ impl ResponseStream {
|
||||
}
|
||||
if let StreamSource::Buffered(chunks) = &self.source {
|
||||
for chunk in chunks {
|
||||
validate_stream_chunk(chunk, maximum_frame_bytes, window_bytes)?;
|
||||
validate_stream_chunk(chunk, maximum_stream_chunk_bytes, window_bytes)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -411,12 +412,9 @@ where
|
||||
config: SessionConfig,
|
||||
) -> Result<Self, SessionError> {
|
||||
let config = config.validate()?;
|
||||
let frame = tokio::time::timeout(
|
||||
config.handshake_timeout,
|
||||
read_frame(&mut reader, config.maximum_frame_bytes),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| SessionError::Timeout)??;
|
||||
let frame = tokio::time::timeout(config.handshake_timeout, read_frame(&mut reader))
|
||||
.await
|
||||
.map_err(|_| SessionError::Timeout)??;
|
||||
let Message::Hello { handshake } = frame.message else {
|
||||
return Err(SessionError::Handshake);
|
||||
};
|
||||
@@ -468,7 +466,6 @@ where
|
||||
let output = Output {
|
||||
sender: outbound,
|
||||
data_slots: Arc::new(Semaphore::new(data_capacity)),
|
||||
maximum_frame_bytes: self.config.maximum_frame_bytes,
|
||||
};
|
||||
let callbacks = Arc::new(CallbackRegistry::new(self.config.maximum_callbacks));
|
||||
let handler: Arc<dyn PluginHandler> = Arc::new(handler);
|
||||
@@ -479,14 +476,9 @@ where
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
incarnation: self.handshake.incarnation.clone(),
|
||||
}),
|
||||
self.config.maximum_frame_bytes,
|
||||
)
|
||||
.await?;
|
||||
let writer = tokio::spawn(writer_loop(
|
||||
self.writer,
|
||||
received,
|
||||
self.config.maximum_frame_bytes,
|
||||
));
|
||||
let writer = tokio::spawn(writer_loop(self.writer, received));
|
||||
let (completed, completions) = mpsc::channel(self.config.maximum_calls);
|
||||
let mut driver = SessionDriver {
|
||||
config: self.config,
|
||||
@@ -513,7 +505,6 @@ where
|
||||
struct Output {
|
||||
sender: mpsc::Sender<Outbound>,
|
||||
data_slots: Arc<Semaphore>,
|
||||
maximum_frame_bytes: usize,
|
||||
}
|
||||
|
||||
impl Clone for Output {
|
||||
@@ -521,14 +512,13 @@ impl Clone for Output {
|
||||
Self {
|
||||
sender: self.sender.clone(),
|
||||
data_slots: Arc::clone(&self.data_slots),
|
||||
maximum_frame_bytes: self.maximum_frame_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Output {
|
||||
async fn send_data(&self, frame: Frame) -> Result<(), SessionError> {
|
||||
validate_frame(&frame, self.maximum_frame_bytes)?;
|
||||
validate_frame(&frame)?;
|
||||
let permit = Arc::clone(&self.data_slots)
|
||||
.acquire_owned()
|
||||
.await
|
||||
@@ -557,7 +547,7 @@ impl Output {
|
||||
}
|
||||
|
||||
fn send_control(&self, frame: Frame) -> Result<(), SessionError> {
|
||||
validate_frame(&frame, self.maximum_frame_bytes)?;
|
||||
validate_frame(&frame)?;
|
||||
self.sender
|
||||
.try_send(Outbound {
|
||||
frame,
|
||||
@@ -575,10 +565,9 @@ struct Outbound {
|
||||
async fn writer_loop<W: AsyncWrite + Unpin>(
|
||||
mut writer: W,
|
||||
mut received: mpsc::Receiver<Outbound>,
|
||||
maximum_frame_bytes: usize,
|
||||
) -> Result<(), SessionError> {
|
||||
while let Some(outbound) = received.recv().await {
|
||||
write_frame(&mut writer, &outbound.frame, maximum_frame_bytes).await?;
|
||||
write_frame(&mut writer, &outbound.frame).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -837,7 +826,7 @@ impl SessionDriver {
|
||||
async fn drive<R: AsyncRead + Unpin>(&mut self, mut reader: R) -> Result<(), SessionError> {
|
||||
loop {
|
||||
// read_exact 不是取消安全的;完成通知可以穿插处理,但必须保留同一个半帧读取 future。
|
||||
let incoming = read_frame(&mut reader, self.config.maximum_frame_bytes);
|
||||
let incoming = read_frame(&mut reader);
|
||||
tokio::pin!(incoming);
|
||||
let frame = loop {
|
||||
tokio::select! {
|
||||
@@ -970,7 +959,7 @@ impl SessionDriver {
|
||||
cancellation: cancellation_signal.clone(),
|
||||
callbacks: Arc::clone(&self.callbacks),
|
||||
output: self.output.clone(),
|
||||
maximum_frame_bytes: self.config.maximum_frame_bytes,
|
||||
maximum_stream_chunk_bytes: self.config.maximum_stream_chunk_bytes,
|
||||
};
|
||||
let call = PluginCall {
|
||||
method,
|
||||
@@ -984,7 +973,7 @@ impl SessionDriver {
|
||||
let output = self.output.clone();
|
||||
let completed = self.completed.clone();
|
||||
let task_credits = Arc::clone(&credits);
|
||||
let maximum_frame_bytes = self.config.maximum_frame_bytes;
|
||||
let maximum_stream_chunk_bytes = self.config.maximum_stream_chunk_bytes;
|
||||
let maximum_chunks = self.config.maximum_buffered_stream_chunks;
|
||||
let task_context = context.clone();
|
||||
let lifecycle = self.lifecycle.clone();
|
||||
@@ -998,7 +987,7 @@ impl SessionDriver {
|
||||
output,
|
||||
lifecycle,
|
||||
deadline,
|
||||
maximum_frame_bytes,
|
||||
maximum_stream_chunk_bytes,
|
||||
maximum_chunks,
|
||||
)
|
||||
.await;
|
||||
@@ -1072,7 +1061,7 @@ async fn run_call(
|
||||
output: Output,
|
||||
lifecycle: LifecycleHooks,
|
||||
deadline: Instant,
|
||||
maximum_frame_bytes: usize,
|
||||
maximum_stream_chunk_bytes: usize,
|
||||
maximum_chunks: usize,
|
||||
) -> Result<(), SessionError> {
|
||||
let id = context.call_id;
|
||||
@@ -1095,7 +1084,7 @@ async fn run_call(
|
||||
Err(error) => {
|
||||
let _ = send_initial(
|
||||
&output,
|
||||
bounded_fault_frame(id, error, maximum_frame_bytes),
|
||||
bounded_fault_frame(id, error),
|
||||
&context,
|
||||
&lifecycle,
|
||||
deadline,
|
||||
@@ -1112,12 +1101,12 @@ async fn run_call(
|
||||
},
|
||||
payload: reply.payload,
|
||||
};
|
||||
if validate_frame(&initial, maximum_frame_bytes).is_err() {
|
||||
if validate_frame(&initial).is_err() {
|
||||
output.send_control(fault_frame(
|
||||
id,
|
||||
PluginFault::new(
|
||||
ErrorCode::InvalidInput,
|
||||
"plugin response exceeds the frame limit",
|
||||
"plugin response metadata exceeds its limit",
|
||||
),
|
||||
))?;
|
||||
return Ok(());
|
||||
@@ -1147,7 +1136,8 @@ async fn run_call(
|
||||
Err(SessionError::Cancelled) => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if let Err(fault) = stream.validate_buffered(maximum_chunks, maximum_frame_bytes, window_bytes)
|
||||
if let Err(fault) =
|
||||
stream.validate_buffered(maximum_chunks, maximum_stream_chunk_bytes, window_bytes)
|
||||
{
|
||||
output.send_control(fault_frame(id, fault))?;
|
||||
return Ok(());
|
||||
@@ -1186,11 +1176,13 @@ async fn run_call(
|
||||
let payload = match item {
|
||||
Ok(payload) => payload,
|
||||
Err(fault) => {
|
||||
output.send_control(bounded_end_frame(id, fault, maximum_frame_bytes))?;
|
||||
output.send_control(bounded_end_frame(id, fault))?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Err(fault) = validate_stream_chunk(&payload, maximum_frame_bytes, window_bytes) {
|
||||
if let Err(fault) =
|
||||
validate_stream_chunk(&payload, maximum_stream_chunk_bytes, window_bytes)
|
||||
{
|
||||
output.send_control(end_frame(id, Some(fault)))?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1372,7 +1364,7 @@ impl CreditWindow {
|
||||
|
||||
fn validate_stream_chunk(
|
||||
payload: &[u8],
|
||||
maximum_frame_bytes: usize,
|
||||
maximum_stream_chunk_bytes: usize,
|
||||
window_bytes: u64,
|
||||
) -> Result<(), PluginFault> {
|
||||
if payload.is_empty() {
|
||||
@@ -1387,16 +1379,10 @@ fn validate_stream_chunk(
|
||||
"response event exceeds the host stream credit window",
|
||||
));
|
||||
}
|
||||
if validate_message(
|
||||
&Message::Stream { id: 1, sequence: 0 },
|
||||
payload.len(),
|
||||
maximum_frame_bytes,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
if payload.len() > maximum_stream_chunk_bytes {
|
||||
return Err(PluginFault::new(
|
||||
ErrorCode::InvalidInput,
|
||||
"response event exceeds the frame limit",
|
||||
"response event exceeds the stream chunk budget",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -1414,28 +1400,28 @@ fn end_frame(id: u64, error: Option<PluginFault>) -> Frame {
|
||||
Frame::control(Message::End { id, error })
|
||||
}
|
||||
|
||||
fn bounded_fault_frame(id: u64, error: PluginFault, maximum_frame_bytes: usize) -> Frame {
|
||||
fn bounded_fault_frame(id: u64, error: PluginFault) -> Frame {
|
||||
let frame = fault_frame(id, error);
|
||||
if validate_frame(&frame, maximum_frame_bytes).is_ok() {
|
||||
if validate_frame(&frame).is_ok() {
|
||||
frame
|
||||
} else {
|
||||
fault_frame(
|
||||
id,
|
||||
PluginFault::new(ErrorCode::Fault, "plugin error exceeds the frame limit"),
|
||||
PluginFault::new(ErrorCode::Fault, "plugin error metadata exceeds its limit"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_end_frame(id: u64, error: PluginFault, maximum_frame_bytes: usize) -> Frame {
|
||||
fn bounded_end_frame(id: u64, error: PluginFault) -> Frame {
|
||||
let frame = end_frame(id, Some(error));
|
||||
if validate_frame(&frame, maximum_frame_bytes).is_ok() {
|
||||
if validate_frame(&frame).is_ok() {
|
||||
frame
|
||||
} else {
|
||||
end_frame(
|
||||
id,
|
||||
Some(PluginFault::new(
|
||||
ErrorCode::Fault,
|
||||
"plugin stream error exceeds the frame limit",
|
||||
"plugin stream error metadata exceeds its limit",
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use serde_json::Value;
|
||||
use crate::{Capability, Contributions, PROTOCOL_VERSION, Permission, Stage};
|
||||
|
||||
/// 当前插件清单格式版本。
|
||||
pub const MANIFEST_VERSION: u32 = 3;
|
||||
pub const MANIFEST_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{CallContext, Handshake, PluginFault};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 4;
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// 元数据与二进制载荷分开,流分块不经过 JSON/base64。
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -28,11 +28,11 @@ use tokio::{
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
const MAXIMUM_FRAME_BYTES: usize = 64 * 1024;
|
||||
const MAXIMUM_STREAM_CHUNK_BYTES: usize = 64 * 1024;
|
||||
|
||||
fn author_manifest(contributes: Value, state: Value) -> Vec<u8> {
|
||||
serde_json::to_vec(&json!({
|
||||
"manifestVersion": 3,
|
||||
"manifestVersion": 1,
|
||||
"name": "composed",
|
||||
"displayName": "Composed",
|
||||
"publisher": "9acme",
|
||||
@@ -333,14 +333,14 @@ async fn start_session<H: PluginHandler + 'static>(
|
||||
handler: H,
|
||||
contributes: Contributions,
|
||||
) -> (HostPeer, JoinHandle<Result<(), SessionError>>) {
|
||||
let (host, plugin) = tokio::io::duplex(MAXIMUM_FRAME_BYTES * 2);
|
||||
let (host, plugin) = tokio::io::duplex(MAXIMUM_STREAM_CHUNK_BYTES * 2);
|
||||
let (plugin_reader, plugin_writer) = tokio::io::split(plugin);
|
||||
let task = tokio::spawn(async move {
|
||||
PluginSession::accept(
|
||||
plugin_reader,
|
||||
plugin_writer,
|
||||
SessionConfig {
|
||||
maximum_frame_bytes: MAXIMUM_FRAME_BYTES,
|
||||
maximum_stream_chunk_bytes: MAXIMUM_STREAM_CHUNK_BYTES,
|
||||
maximum_calls: 4,
|
||||
maximum_callbacks: 4,
|
||||
maximum_buffered_stream_chunks: 16,
|
||||
@@ -368,11 +368,10 @@ async fn start_session<H: PluginHandler + 'static>(
|
||||
contributes,
|
||||
},
|
||||
}),
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let ready = read_frame(&mut reader, MAXIMUM_FRAME_BYTES).await.unwrap();
|
||||
let ready = read_frame(&mut reader).await.unwrap();
|
||||
assert!(matches!(
|
||||
ready,
|
||||
Frame {
|
||||
@@ -417,20 +416,16 @@ async fn send_call(
|
||||
},
|
||||
payload,
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn receive(host: &mut HostPeer) -> Frame {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
read_frame(&mut host.reader, MAXIMUM_FRAME_BYTES),
|
||||
)
|
||||
.await
|
||||
.expect("plugin response timed out")
|
||||
.expect("plugin response frame must be valid")
|
||||
tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
|
||||
.await
|
||||
.expect("plugin response timed out")
|
||||
.expect("plugin response frame must be valid")
|
||||
}
|
||||
|
||||
fn unwrap_result(frame: Frame, expected_id: u64) -> (Value, Vec<u8>) {
|
||||
@@ -454,13 +449,9 @@ fn assert_invalid_input(frame: Frame, expected_id: u64) {
|
||||
}
|
||||
|
||||
async fn shutdown(host: &mut HostPeer, task: JoinHandle<Result<(), SessionError>>) {
|
||||
write_frame(
|
||||
&mut host.writer,
|
||||
&Frame::control(Message::Shutdown),
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
write_frame(&mut host.writer, &Frame::control(Message::Shutdown))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("plugin session did not shut down")
|
||||
|
||||
@@ -29,7 +29,7 @@ use tokio::{
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
const MAXIMUM_FRAME_BYTES: usize = 64 * 1024;
|
||||
const MAXIMUM_STREAM_CHUNK_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_payload_round_trips_without_json_encoding() {
|
||||
@@ -41,32 +41,72 @@ async fn binary_payload_round_trips_without_json_encoding() {
|
||||
payload: vec![0, 255, 13, 10, 128],
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
write_frame(&mut bytes, &frame, 1024).await.unwrap();
|
||||
write_frame(&mut bytes, &frame).await.unwrap();
|
||||
assert!(bytes.ends_with(&frame.payload));
|
||||
assert_eq!(
|
||||
read_frame(&mut bytes.as_slice(), 1024).await.unwrap(),
|
||||
frame
|
||||
);
|
||||
assert_eq!(read_frame(&mut bytes.as_slice()).await.unwrap(), frame);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malicious_lengths_fail_before_reading_or_allocating_payload() {
|
||||
let bytes = [0, 0, 0, 1, 255, 255, 255, 255];
|
||||
let bytes = [0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
assert!(matches!(
|
||||
read_frame(&mut bytes.as_slice(), 1024).await,
|
||||
read_frame(&mut bytes.as_slice()).await,
|
||||
Err(FrameError::Length)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_payload_is_exact_across_multiple_io_buffers_and_following_frames() {
|
||||
let frame = Frame {
|
||||
message: Message::Result {
|
||||
id: 1,
|
||||
result: json!({}),
|
||||
},
|
||||
payload: (0..101 * 1024 * 1024 + 13)
|
||||
.map(|index| (index % 251) as u8)
|
||||
.collect(),
|
||||
};
|
||||
let next = Frame::control(Message::Cancel { id: 3 });
|
||||
let (mut writer, mut reader) = tokio::io::duplex(16 * 1024);
|
||||
let writing = tokio::spawn(async move {
|
||||
write_frame(&mut writer, &frame).await.unwrap();
|
||||
write_frame(&mut writer, &next).await.unwrap();
|
||||
(frame, next)
|
||||
});
|
||||
let received = read_frame(&mut reader).await.unwrap();
|
||||
let following = read_frame(&mut reader).await.unwrap();
|
||||
let (original, next) = writing.await.unwrap();
|
||||
assert_eq!(received, original);
|
||||
assert_eq!(following, next);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncated_large_payload_never_becomes_a_successful_message() {
|
||||
let message = serde_json::to_vec(&Message::Result {
|
||||
id: 1,
|
||||
result: json!({}),
|
||||
})
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(&(message.len() as u32).to_be_bytes());
|
||||
bytes.extend_from_slice(&(101_u64 * 1024 * 1024).to_be_bytes());
|
||||
bytes.extend_from_slice(&message);
|
||||
bytes.extend_from_slice(&[1, 2, 3]);
|
||||
assert!(matches!(
|
||||
read_frame(&mut bytes.as_slice()).await,
|
||||
Err(FrameError::Io(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncated_frame_never_becomes_a_successful_message() {
|
||||
let mut bytes = Vec::new();
|
||||
write_frame(&mut bytes, &Frame::control(Message::Cancel { id: 3 }), 1024)
|
||||
write_frame(&mut bytes, &Frame::control(Message::Cancel { id: 3 }))
|
||||
.await
|
||||
.unwrap();
|
||||
bytes.pop();
|
||||
assert!(matches!(
|
||||
read_frame(&mut bytes.as_slice(), 1024).await,
|
||||
read_frame(&mut bytes.as_slice()).await,
|
||||
Err(FrameError::Io(_))
|
||||
));
|
||||
}
|
||||
@@ -135,10 +175,10 @@ impl PluginHandler for TestHandler {
|
||||
}
|
||||
"oversized_fault" => Err(PluginFault::new(
|
||||
ErrorCode::Upstream,
|
||||
"x".repeat(MAXIMUM_FRAME_BYTES),
|
||||
"x".repeat(MAXIMUM_STREAM_CHUNK_BYTES),
|
||||
)),
|
||||
"oversized_result" => Ok(CallReply::unary(
|
||||
Value::String("x".repeat(MAXIMUM_FRAME_BYTES)),
|
||||
Value::String("x".repeat(MAXIMUM_STREAM_CHUNK_BYTES)),
|
||||
Vec::new(),
|
||||
)),
|
||||
"dynamic_oversized_fault" => {
|
||||
@@ -147,7 +187,7 @@ impl PluginHandler for TestHandler {
|
||||
let _ = sender
|
||||
.fail(PluginFault::new(
|
||||
ErrorCode::Upstream,
|
||||
"x".repeat(MAXIMUM_FRAME_BYTES),
|
||||
"x".repeat(MAXIMUM_STREAM_CHUNK_BYTES),
|
||||
))
|
||||
.await;
|
||||
});
|
||||
@@ -191,7 +231,7 @@ struct HostPeer {
|
||||
async fn start_session<H: PluginHandler>(
|
||||
handler: H,
|
||||
) -> (HostPeer, JoinHandle<Result<(), SessionError>>) {
|
||||
start_session_with_capacity(handler, MAXIMUM_FRAME_BYTES * 2).await
|
||||
start_session_with_capacity(handler, MAXIMUM_STREAM_CHUNK_BYTES * 2).await
|
||||
}
|
||||
|
||||
async fn start_session_with_capacity<H: PluginHandler>(
|
||||
@@ -205,7 +245,7 @@ async fn start_session_with_capacity<H: PluginHandler>(
|
||||
plugin_reader,
|
||||
plugin_writer,
|
||||
SessionConfig {
|
||||
maximum_frame_bytes: MAXIMUM_FRAME_BYTES,
|
||||
maximum_stream_chunk_bytes: MAXIMUM_STREAM_CHUNK_BYTES,
|
||||
maximum_calls: 4,
|
||||
maximum_callbacks: 4,
|
||||
maximum_buffered_stream_chunks: 16,
|
||||
@@ -222,11 +262,10 @@ async fn start_session_with_capacity<H: PluginHandler>(
|
||||
&Frame::control(Message::Hello {
|
||||
handshake: handshake(),
|
||||
}),
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let ready = read_frame(&mut reader, MAXIMUM_FRAME_BYTES).await.unwrap();
|
||||
let ready = read_frame(&mut reader).await.unwrap();
|
||||
assert!(matches!(
|
||||
ready,
|
||||
Frame {
|
||||
@@ -354,7 +393,6 @@ async fn middleware_plugin_registers_without_invoking_business_handler() {
|
||||
},
|
||||
payload: vec![],
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -405,7 +443,6 @@ async fn middleware_plugin_rejects_invalid_calls_before_business_dispatch() {
|
||||
},
|
||||
payload,
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -511,30 +548,22 @@ async fn send_call_with_timeout(
|
||||
},
|
||||
payload,
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn send_control(host: &mut HostPeer, message: Message) {
|
||||
write_frame(
|
||||
&mut host.writer,
|
||||
&Frame::control(message),
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
write_frame(&mut host.writer, &Frame::control(message))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn receive(host: &mut HostPeer) -> Frame {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
read_frame(&mut host.reader, MAXIMUM_FRAME_BYTES),
|
||||
)
|
||||
.await
|
||||
.expect("plugin response timed out")
|
||||
.expect("plugin response frame must be valid")
|
||||
tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
|
||||
.await
|
||||
.expect("plugin response timed out")
|
||||
.expect("plugin response frame must be valid")
|
||||
}
|
||||
|
||||
async fn shutdown(host: &mut HostPeer, task: JoinHandle<Result<(), SessionError>>) {
|
||||
@@ -584,9 +613,7 @@ async fn completed_call_does_not_discard_a_partially_read_next_frame() {
|
||||
payload: vec![0, 255, 13, 10, 128],
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
write_frame(&mut encoded, &next, MAXIMUM_FRAME_BYTES)
|
||||
.await
|
||||
.unwrap();
|
||||
write_frame(&mut encoded, &next).await.unwrap();
|
||||
let payload_start = encoded.len() - next.payload.len();
|
||||
for split in [2, 6, 10, payload_start + 2] {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
@@ -646,7 +673,6 @@ async fn host_callback_round_trips_while_parent_call_is_waiting() {
|
||||
},
|
||||
payload: vec![9],
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -699,7 +725,6 @@ async fn callbacks_from_concurrent_calls_remain_correlated() {
|
||||
},
|
||||
payload: vec![parent_id as u8],
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -734,7 +759,6 @@ async fn middleware_next_and_body_mapping_reuse_callback_and_credit_flow() {
|
||||
},
|
||||
payload: b"request".to_vec(),
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -787,7 +811,6 @@ async fn middleware_next_and_body_mapping_reuse_callback_and_credit_flow() {
|
||||
},
|
||||
payload: Vec::new(),
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -848,7 +871,6 @@ async fn middleware_next_and_body_mapping_reuse_callback_and_credit_flow() {
|
||||
},
|
||||
payload: b"data: done\n\n".to_vec(),
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -891,7 +913,6 @@ async fn middleware_next_and_body_mapping_reuse_callback_and_credit_flow() {
|
||||
},
|
||||
payload: Vec::new(),
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -916,7 +937,6 @@ async fn untouched_middleware_response_transfers_opaque_body_without_reading_it(
|
||||
},
|
||||
payload: b"hidden-by-preserve".to_vec(),
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -955,7 +975,6 @@ async fn untouched_middleware_response_transfers_opaque_body_without_reading_it(
|
||||
},
|
||||
payload: Vec::new(),
|
||||
},
|
||||
MAXIMUM_FRAME_BYTES,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -992,12 +1011,9 @@ async fn stream_waits_for_credit_and_rejects_uncreditable_buffered_chunk_before_
|
||||
let (mut host, task) = start_session(TestHandler::default()).await;
|
||||
send_call(&mut host, 1, "stream", json!({}), Vec::new()).await;
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(25),
|
||||
read_frame(&mut host.reader, MAXIMUM_FRAME_BYTES)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
tokio::time::timeout(Duration::from_millis(25), read_frame(&mut host.reader))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
send_control(
|
||||
&mut host,
|
||||
@@ -1021,12 +1037,9 @@ async fn stream_waits_for_credit_and_rejects_uncreditable_buffered_chunk_before_
|
||||
} if payload == b"abcd"
|
||||
));
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(25),
|
||||
read_frame(&mut host.reader, MAXIMUM_FRAME_BYTES)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
tokio::time::timeout(Duration::from_millis(25), read_frame(&mut host.reader))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
send_control(
|
||||
&mut host,
|
||||
@@ -1233,7 +1246,7 @@ async fn oversized_handler_faults_are_replaced_without_closing_the_session() {
|
||||
ref message,
|
||||
..
|
||||
},
|
||||
} if message == "plugin error exceeds the frame limit"
|
||||
} if message == "plugin error metadata exceeds its limit"
|
||||
));
|
||||
|
||||
send_call(
|
||||
@@ -1266,7 +1279,7 @@ async fn oversized_handler_faults_are_replaced_without_closing_the_session() {
|
||||
ref message,
|
||||
..
|
||||
}),
|
||||
} if message == "plugin stream error exceeds the frame limit"
|
||||
} if message == "plugin stream error metadata exceeds its limit"
|
||||
));
|
||||
|
||||
send_call(&mut host, 7, "echo", json!("still-ready"), Vec::new()).await;
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::json;
|
||||
|
||||
fn source_manifest() -> Manifest {
|
||||
serde_json::from_value(json!({
|
||||
"manifestVersion": 3,
|
||||
"manifestVersion": 1,
|
||||
"name": "request-tags",
|
||||
"displayName": "请求标签",
|
||||
"publisher": "9acme",
|
||||
@@ -339,7 +339,7 @@ fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() {
|
||||
}"#;
|
||||
let manifest = format!(
|
||||
r#"{{
|
||||
"manifestVersion":3,"name":"example","displayName":"Example","publisher":"test",
|
||||
"manifestVersion":1,"name":"example","displayName":"Example","publisher":"test",
|
||||
"version":"1.0.0","description":"Example","license":"MIT",
|
||||
"engines":{{"codex-proxy-rs":"*"}},"main":"bin/worker","runtime":"trustedProcess",
|
||||
"contributes":{{"middleware":{declaration},"middleware":{declaration}}}
|
||||
|
||||
@@ -88,7 +88,7 @@ UI 包只公开组件、主题与样式入口;Pinia 持久化、登录、路
|
||||
|
||||
`gateway-plugin/` 只是目录分组,包含独立 SDK 和宿主 Runtime,不存在聚合 crate。
|
||||
SDK 定义双方通信合同;Runtime 把插件能力接入既有 Core / Admin 端口,不另建请求引擎或持久化体系。
|
||||
当前合同为 SDK `0.4.0`、清单 `manifestVersion: 3`、进程协议 `4`;宿主只准备通过当前合同与兼容范围校验的制品。
|
||||
当前合同为 SDK `0.1.0`、清单 `manifestVersion: 1`、进程协议 `1`;宿主只准备通过当前合同与兼容范围校验的制品。
|
||||
|
||||
下图表示运行时协作,不表示 crate 直接依赖;具体实现由组合根注入:
|
||||
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@ flowchart LR
|
||||
D -->|缺少必填项| F[补充配置后启用]
|
||||
```
|
||||
|
||||
宿主支持 SDK `0.4.0`、`manifestVersion: 3` 和进程协议 `4`。包必须同时满足当前平台和宿主兼容范围。
|
||||
宿主支持 SDK `0.1.0`、`manifestVersion: 1` 和进程协议 `1`,只接受当前合同的插件包。
|
||||
正文按块传输,插件通信不额外限制正文总量。宿主、SDK 和打包 CLI 必须使用同一套合同;
|
||||
现有开发插件需要重新构建并打包,不能只修改包内版本字段。包必须同时满足当前平台和宿主兼容范围。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import type { PluginReleaseAsset } from '@/api'
|
||||
import { BasePopover, BaseScrollbar, BaseTag } from '@codex-proxy/ui'
|
||||
import { ChevronDown, FileArchive } from '@lucide/vue'
|
||||
import { useElementSize, useEventListener } from '@vueuse/core'
|
||||
import { computed, nextTick, shallowRef, useId, useTemplateRef, watch } from 'vue'
|
||||
import { formatPluginFileSize } from '../utils/model'
|
||||
|
||||
const props = defineProps<{ assets: PluginReleaseAsset[], disabled?: boolean }>()
|
||||
const selected = defineModel<string>({ required: true })
|
||||
const open = shallowRef(false)
|
||||
const trigger = useTemplateRef<HTMLButtonElement>('trigger')
|
||||
const panel = useTemplateRef<HTMLDivElement>('panel')
|
||||
const panelId = useId()
|
||||
const { width } = useElementSize(trigger, undefined, { box: 'border-box' })
|
||||
const selectedAsset = computed(() => props.assets.find(asset => asset.name === selected.value))
|
||||
|
||||
async function close() {
|
||||
open.value = false
|
||||
await nextTick()
|
||||
trigger.value?.focus()
|
||||
}
|
||||
|
||||
function select(name: string) {
|
||||
if (props.disabled)
|
||||
return
|
||||
selected.value = name
|
||||
void close()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !open.value)
|
||||
return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void close()
|
||||
}
|
||||
|
||||
useEventListener(panel, 'keydown', handleKeydown)
|
||||
useEventListener(panel, 'keydown', (event) => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key))
|
||||
return
|
||||
const options = [...(panel.value?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])]
|
||||
if (!options.length)
|
||||
return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const current = options.indexOf(event.target as HTMLButtonElement)
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? options.length - 1 : (current + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length
|
||||
options[next]?.focus()
|
||||
})
|
||||
watch(open, async (value) => {
|
||||
if (!value)
|
||||
return
|
||||
await nextTick()
|
||||
if (open.value)
|
||||
(panel.value?.querySelector<HTMLButtonElement>('[aria-selected="true"]') ?? panel.value?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus()
|
||||
})
|
||||
watch(() => props.disabled, (value) => {
|
||||
if (value)
|
||||
open.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePopover v-model="open" placement="bottom-start" :disabled="disabled" class="w-full min-w-0">
|
||||
<template #trigger>
|
||||
<button
|
||||
ref="trigger"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:title="selectedAsset?.name"
|
||||
:aria-label="selectedAsset ? `插件包:${selectedAsset.name}` : '选择插件包'"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="open ? panelId : undefined"
|
||||
class="flex min-h-cp-control w-full min-w-0 items-center gap-3 rounded-cp border-0 px-3.5 py-2 text-left text-cp shadow-cp-input outline-none transition-[background-color,box-shadow] duration-160 motion-reduce:transition-none"
|
||||
:class="disabled
|
||||
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
|
||||
: open
|
||||
? 'cursor-pointer bg-(--cp-input-active-bg) text-cp-text shadow-cp-input-active'
|
||||
: 'cursor-pointer bg-(--cp-input-bg) text-cp-text hover:bg-(--cp-input-hover-bg) hover:shadow-cp-input-hover focus-visible:bg-(--cp-input-active-bg) focus-visible:shadow-cp-input-active'"
|
||||
@keydown="handleKeydown"
|
||||
@keydown.down.prevent="open = !disabled"
|
||||
>
|
||||
<FileArchive v-if="selectedAsset" class="size-4 shrink-0 text-cp-text-tertiary" aria-hidden="true" />
|
||||
<span class="min-w-0 flex-1 truncate text-cp-sm" :class="{ 'text-cp-text-quaternary': !selectedAsset }">
|
||||
{{ selectedAsset?.name ?? '选择插件包' }}
|
||||
</span>
|
||||
<ChevronDown class="size-4 shrink-0 text-cp-text-tertiary transition-transform motion-reduce:transition-none" :class="{ 'rotate-180': open }" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
<div :id="panelId" ref="panel" role="listbox" aria-label="插件包" aria-required="true" :style="{ width: `${width}px` }" class="max-w-[calc(100vw-2rem)] p-1.5">
|
||||
<BaseScrollbar max-height="min(18rem, 45dvh)">
|
||||
<div class="grid gap-0.5 p-0.5">
|
||||
<button
|
||||
v-for="asset in assets"
|
||||
:key="asset.name"
|
||||
type="button"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
:title="asset.name"
|
||||
:aria-selected="selected === asset.name"
|
||||
:disabled="disabled"
|
||||
class="flex w-full min-w-0 items-center gap-3 rounded-cp-sm border-0 px-3 py-2.5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-inset"
|
||||
:class="disabled ? 'cursor-not-allowed opacity-55' : selected === asset.name ? 'cursor-pointer bg-cp-control-item-bg-active hover:bg-cp-control-item-bg-active-hover' : 'cursor-pointer bg-transparent hover:bg-cp-fill-tertiary'"
|
||||
@click="select(asset.name)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-cp-sm" :class="selected === asset.name ? 'text-cp-primary-text' : 'text-cp-text'">{{ asset.name }}</span>
|
||||
<BaseTag class="shrink-0 tabular-nums">
|
||||
{{ formatPluginFileSize(asset.size) }}
|
||||
</BaseTag>
|
||||
</button>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
</BasePopover>
|
||||
</template>
|
||||
@@ -13,14 +13,15 @@ import type {
|
||||
} from '@/api'
|
||||
|
||||
import { Github } from '@boxicons/vue'
|
||||
import { BaseButton, BaseCheckbox, BaseForm, BaseFormItem, BaseInput, BaseModal, BaseSegmented, BaseSelect, BaseTag, toast } from '@codex-proxy/ui'
|
||||
import { BaseButton, BaseCheckbox, BaseForm, BaseFormItem, BaseInput, BaseModal, BaseSegmented, BaseTag, toast } from '@codex-proxy/ui'
|
||||
|
||||
import { CheckCircle2, Download, FileArchive, Link, PackageOpen } from '@lucide/vue'
|
||||
import { useEventListener, useSessionStorage } from '@vueuse/core'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { computed, nextTick, reactive, shallowRef, watch } from 'vue'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { normalizePluginRepository, pluginInstallSelectionKey } from '../utils/model'
|
||||
import { formatPluginFileSize, normalizePluginRepository, pluginInstallSelectionKey } from '../utils/model'
|
||||
import PluginAssetPicker from './PluginAssetPicker.vue'
|
||||
import PluginDownloadAuthentication from './PluginDownloadAuthentication.vue'
|
||||
import PluginHelpPopover from './PluginHelpPopover.vue'
|
||||
import PluginPermissionSummary from './PluginPermissionSummary.vue'
|
||||
@@ -99,11 +100,7 @@ function changeMode(value: string) {
|
||||
return
|
||||
emit('changeMode', value)
|
||||
}
|
||||
const assetOptions = computed(() => (props.release?.assets ?? []).filter(asset => /\.(?:tar\.gz|tgz)$/i.test(asset.name)).map(asset => ({
|
||||
label: `${asset.name} · ${formatFileSize(asset.size)}`,
|
||||
value: asset.name,
|
||||
description: asset.sha256 ? `SHA-256 ${asset.sha256}` : undefined,
|
||||
})))
|
||||
const releaseAssets = computed(() => (props.release?.assets ?? []).filter(asset => /\.(?:tar\.gz|tgz)$/i.test(asset.name)))
|
||||
const digestPattern = /^[a-f0-9]{64}$/
|
||||
const digestError = computed(() => {
|
||||
const digest = connectionForm.value.sha256.trim().toLowerCase()
|
||||
@@ -381,14 +378,6 @@ function githubQueryKey() {
|
||||
})
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
if (bytes < 1024)
|
||||
return `${bytes} B`
|
||||
if (bytes < 1024 * 1024)
|
||||
return `${(bytes / 1024).toFixed(1)} KiB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
reset()
|
||||
@@ -422,7 +411,7 @@ watch(
|
||||
() => props.release,
|
||||
async (release) => {
|
||||
// 唯一归档可直接解析,多个归档交给用户选择,平台兼容性仍由包检查器确认。
|
||||
githubForm.asset = release && assetOptions.value.length === 1 ? assetOptions.value[0]!.value : ''
|
||||
githubForm.asset = release && releaseAssets.value.length === 1 ? releaseAssets.value[0]!.name : ''
|
||||
if (githubForm.asset) {
|
||||
await nextTick()
|
||||
await verifySelected()
|
||||
@@ -458,7 +447,7 @@ watch(
|
||||
<FileArchive class="size-5 shrink-0 text-cp-text-secondary" aria-hidden="true" />
|
||||
<span class="grid min-w-0 flex-1 gap-1">
|
||||
<span class="truncate text-cp-sm font-emphasis text-cp-text">{{ uploadFile?.name ?? '选择插件包' }}</span>
|
||||
<span class="text-cp-xs text-cp-text-secondary">{{ uploadFile ? formatFileSize(uploadFile.size) : 'tar.gz / tgz,最大 32 MiB' }}</span>
|
||||
<span class="text-cp-xs text-cp-text-secondary">{{ uploadFile ? formatPluginFileSize(uploadFile.size) : 'tar.gz / tgz,最大 32 MiB' }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 text-cp-xs text-cp-primary-text">{{ uploadFile ? '更换' : '浏览文件' }}</span>
|
||||
<input
|
||||
@@ -531,16 +520,14 @@ watch(
|
||||
|
||||
<section v-if="!verified && mode === 'github' && release" class="mt-4 grid gap-3">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<strong class="min-w-0 wrap-break-word text-cp text-cp-text">{{ release.name || release.tag }}</strong>
|
||||
<BaseTag type="primary">
|
||||
{{ release.tag }}
|
||||
</BaseTag>
|
||||
<span class="text-cp-sm text-cp-text-secondary">已找到版本</span>
|
||||
<strong class="min-w-0 wrap-break-word text-cp-sm font-emphasis text-cp-text">{{ release.tag }}</strong>
|
||||
<BaseTag v-if="release.prerelease" type="warning">
|
||||
预发行版
|
||||
</BaseTag>
|
||||
</div>
|
||||
<BaseFormItem v-if="assetOptions.length > 1" label="插件包" required>
|
||||
<BaseSelect v-model="githubForm.asset" :options="assetOptions" :disabled="busy" placeholder="选择插件包" class="w-full" />
|
||||
<BaseFormItem v-if="releaseAssets.length > 1" label="插件包" required>
|
||||
<PluginAssetPicker v-model="githubForm.asset" :assets="releaseAssets" :disabled="busy" />
|
||||
</BaseFormItem>
|
||||
<p v-else class="m-0 break-all text-cp-sm text-cp-text-secondary">
|
||||
{{ githubForm.asset || '此版本没有 tar.gz 或 tgz 插件包' }}
|
||||
|
||||
@@ -64,6 +64,14 @@ export function normalizePluginRepository(value: string) {
|
||||
return value.trim().replace(/^https:\/\/github\.com\//i, '').replace(/\/$/, '').replace(/\.git$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
export function formatPluginFileSize(bytes: number) {
|
||||
if (bytes < 1024)
|
||||
return `${bytes} B`
|
||||
if (bytes < 1024 * 1024)
|
||||
return `${(bytes / 1024).toFixed(1)} KiB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
|
||||
}
|
||||
|
||||
export function pluginContributionForCapability(
|
||||
metadata: PluginArtifactMetadata,
|
||||
capability: string,
|
||||
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
# v3.14.0
|
||||
# v3.14.1
|
||||
|
||||
## 新增功能
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
- 修复插件路由拒绝下划线等合法 HTTP 请求头的问题,会话和身份字段继续按权限隐藏;插件启动遇到瞬时文件忙时在启动期限内重试
|
||||
- 补齐 GPT-6 Sol、Luna 内置价格,并更新后端依赖、管理端组件和容器基础镜像
|
||||
|
||||
- 改善 GitHub 插件安装包的选择与辨识:包名单行展示,悬停可查看完整名称,文件大小以标签展示,选中项使用高亮区分
|
||||
- 明确插件版本查询结果,避免重复显示发行标题与版本号
|
||||
- 统一插件开发技能的名称为 `plugin-dev`,补充公开资源入口、目标平台与 GitHub 发布指引
|
||||
- 修复插件大正文请求被传输或缓冲预算限制的问题,保留完整请求内容;单次调用的元数据编码失败不再中断共享插件会话
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 从 v3.13.1 升级会自动执行 `0018_plugins.sql`,包含插件、代理位置检测和定时预热所需结构;已发布的 0001–0017 迁移保持原样。升级前备份 PostgreSQL 和部署配置
|
||||
- 曾运行未发布源码并执行旧版 0018–0021 迁移的开发库,应使用新开发库;这些中间迁移已合并,不能直接复用其 SQLx 迁移记录
|
||||
- 宿主发行包不内置插件,插件独立安装和升级;仅安装可信插件,插件进程不是不可信代码沙箱
|
||||
- 固定为 SSE 的账号不承接必须依赖 WebSocket 的协议预热和连接内续接;仅使用这类账号时,Codex 客户端应关闭 `supports_websockets`
|
||||
- v3.14.1 调整了插件通信合同,使用 SDK `0.1.0`、`manifestVersion: 1` 和 `protocolVersion: 1`;v3.14.0 使用的旧合同插件包不兼容,需要使用配套 SDK 与 CLI 重新构建、打包后安装,不能只修改包内版本字段
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.14.0"
|
||||
version: "3.14.1"
|
||||
|
||||
Reference in New Issue
Block a user