Compare commits

...
4 Commits
Author SHA1 Message Date
ZyyandCodex 21c0711a66 chore(release): v3.14.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 08:26:04 +08:00
ZyyandCodex f770ba127d fix(plugin): separate body transport from buffer budgets
Define the initial plugin manifest and wire protocol without legacy branches.
Preserve large request payloads and isolate local RPC encoding failures.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 08:11:16 +08:00
ZyyandCodex 66719f22e2 docs(skills): clarify plugin development and release guidance
Align the visible skill name with plugin-dev and document published resources, host platform selection, configuration defaults, and release installation behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 07:11:27 +08:00
ZyyandCodex 00a1956df7 fix(plugins): clarify release asset selection
Show release assets with filename tooltips, size tags, and a compact selection state. Remove duplicate release version labels.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 06:53:10 +08:00
40 changed files with 616 additions and 348 deletions
+3 -2
View File
@@ -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 -1
View File
@@ -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)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
+1 -1
View File
@@ -1830,7 +1830,7 @@ dependencies = [
[[package]]
name = "gateway-plugin-sdk"
version = "0.4.0"
version = "0.1.0"
dependencies = [
"semver",
"serde",
+1 -1
View File
@@ -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` 合同。
## 安装
+1 -1
View File
@@ -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 -1
View File
@@ -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
+12 -9
View File
@@ -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}}}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
version: "3.14.0"
version: "3.14.1"