From 79f4dafb2a86896a9bfe655e591dc4b807b7fcf2 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:03:52 +0800 Subject: [PATCH] Plugin management: config section, loading with hash checks, recording and the control API Track 2a of script plugins, on top of the scaffolding (#250). Configuration (tw-config) - A `plugins:` list, in run order: id, file, sha256, enabled (default true), on_error (reject | skip, default reject), scope (clients, models, upstreams globs) and settings (string | number | boolean). Validation: id pattern and uniqueness, `order`/`inspect` reserved (fixed paths under /plugins), file must be plugins/.js, sha256 64 lowercase hex, no blank scope entry, scalar settings. Manifest checks stay in the gateway. - Documented in docs/config*.md (generated tables). Loading (tw-gateway) - `Plugins` keeps the engine, the config directory, per-plugin stats and log rings across reloads, and a compile cache keyed by SHA-256. - Every build reads each plugin file once (capped at 1 MiB + 1), hashes those bytes and compiles those bytes only when the hash equals the approved one (I9). A mismatch or a missing file is `changed`; a load error, an undeclared or mistyped setting is `error`. Neither fails the reload. Settings are merged over the manifest defaults. - `reload_plugins` re-reads the files without a config change; runtime swaps are serialized so it never puts back an older configuration. - An enabled plugin that newly stops running emits `plugin_failed` (no request id). - `plugin_ran` also hands each run to the store (`RunRecord`). - A fake engine (`plugin::fake`) for tests; the real runtime is still the "engine unavailable" stand-in. Recording (tw-store) - SCHEMA 24: `plugin_runs` (request_id, seq, at_ms, plugin_id, plugin_name, hook, outcome, error + code + args, cpu_us, detail), pruned with the requests. - The post-plugin request body is stored as `{id}.after-plugins`. Control API (tw-api, tw-control) - GET /plugins, POST /plugins/inspect (no side effects), POST /plugins, PUT /plugins/order, PUT /plugins/{id}, DELETE /plugins/{id}, PUT /plugins/{id}/source, GET /plugins/{id}/source, POST /plugins/{id}/approve, POST /plugins/{id}/trial, GET /plugins/{id}/logs. - Create, replace and approve write the plugin file and the approved copy (0600, directories 0700) before the configuration and put them back if the configuration write fails. Approve accepts the file on disk only if its hash equals the one the caller reviewed. CreatePlugin, ReplacePluginSource and ApprovePluginFile are documented as not for the desktop webview (I12). - RequestDetail gains `plugins` and `request_after_plugins`; history rows gain `plugin_changed`. - A watcher on plugins/ reloads the plugins when a file changes. - Trial runs answer "not available yet" until the data-plane trial lands. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + bin/twcore/src/main.rs | 50 +- crates/tw-api/msg-codes.txt | 30 + crates/tw-api/src/ep.rs | 29 + crates/tw-api/src/lib.rs | 280 ++++++- crates/tw-api/src/ts.rs | 41 + crates/tw-config/src/edit.rs | 61 +- crates/tw-config/src/lib.rs | 6 + crates/tw-config/src/plugins.rs | 372 +++++++++ crates/tw-config/src/validate.rs | 46 ++ crates/tw-config/tests/manual.rs | 6 + crates/tw-config/tests/manual/schema.rs | 118 +++ crates/tw-control/Cargo.toml | 2 + crates/tw-control/src/config.rs | 2 + crates/tw-control/src/keys.rs | 1 + crates/tw-control/src/lib.rs | 55 +- crates/tw-control/src/plugins.rs | 954 +++++++++++++++++++++++ crates/tw-control/src/security.rs | 3 + crates/tw-control/tests/plugins.rs | 949 ++++++++++++++++++++++ crates/tw-control/tests/stored_bodies.rs | 1 + crates/tw-gateway/src/bodies.rs | 24 + crates/tw-gateway/src/plugin/engine.rs | 41 + crates/tw-gateway/src/plugin/fake.rs | 318 ++++++++ crates/tw-gateway/src/plugin/load.rs | 685 ++++++++++++++++ crates/tw-gateway/src/plugin/mod.rs | 28 +- crates/tw-gateway/src/state.rs | 90 ++- crates/tw-gateway/tests/passthrough.rs | 1 + crates/tw-store/src/blobs.rs | 3 + crates/tw-store/src/db.rs | 215 ++++- crates/tw-store/src/lib.rs | 2 +- crates/tw-store/src/recorder.rs | 7 + crates/tw-store/src/task.rs | 13 + crates/tw-yaml/src/edit.rs | 114 +++ crates/tw-yaml/src/lib.rs | 2 +- docs/config.md | 57 ++ docs/config.zh-CN.md | 47 ++ scripts/smoke.sh | 5 +- 37 files changed, 4636 insertions(+), 23 deletions(-) create mode 100644 crates/tw-config/src/plugins.rs create mode 100644 crates/tw-control/src/plugins.rs create mode 100644 crates/tw-control/tests/plugins.rs create mode 100644 crates/tw-gateway/src/plugin/fake.rs create mode 100644 crates/tw-gateway/src/plugin/load.rs diff --git a/Cargo.lock b/Cargo.lock index 9ac40e37..e4a784f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2842,6 +2842,7 @@ dependencies = [ "tw-secret", "tw-store", "tw-types", + "tw-watch", "tw-yaml", ] diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index ebdc9882..97eba022 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -783,9 +783,18 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> // body 的通道在这里建:**它是唯一同时看得见网关和存储的地方**, // 而两边各有各的同形结构,是为了不让「观测」挂到「转发」下面。 let (body_tx, body_rx) = tw_gateway::bodies::channel(); - let store = build_store(&dir, state.bus.clone(), state.pricing.clone(), body_rx); + // 插件在每个请求上的运行记录,和正文同一个道理:网关交出去,存储层落库 + let (run_tx, run_rx) = tokio::sync::mpsc::channel(tw_gateway::plugin::RUN_CHANNEL_CAP); + let store = build_store( + &dir, + state.bus.clone(), + state.pricing.clone(), + body_rx, + run_rx, + ); if store.is_some() { state.set_body_sink(body_tx); + state.set_plugin_sink(run_tx); } /* @@ -843,6 +852,16 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> None } }; + // 插件目录也盯着:**插件文件被改了,那个插件马上停用**,不等下一次改配置。 + // 盯不住时退回到每次换配置时重算哈希,所以同样只说一句 + let _plugin_watch = match tw_control::plugins::spawn_watcher(state.clone(), manager.path()) + { + Ok(w) => Some(w), + Err(e) => { + tracing::warn!("the plugin directory cannot be watched, so a changed plugin file is noticed only at the next configuration change: {e}"); + None + } + }; // 控制面无论如何都要起来 —— **网关挂了的时候,用户最需要的恰恰 // 是能改配置**。安全模式就是「只有这一半」。 @@ -946,6 +965,7 @@ fn build_store( // **和网关同一份价格簿**,不是一份副本:改了价目表,下一个结束的请求就按新价算 pricing: tw_pricing::Shared, bodies: tokio::sync::mpsc::Receiver, + runs: tokio::sync::mpsc::Receiver, ) -> Option>> { let events = bus.subscribe(); let (db, blobs) = match tw_store::open(dir) { @@ -1005,6 +1025,7 @@ fn build_store( which: match kind { tw_gateway::bodies::BodyKind::Request => tw_store::Which::Request, tw_gateway::bodies::BodyKind::Response => tw_store::Which::Response, + tw_gateway::bodies::BodyKind::AfterPlugins => tw_store::Which::AfterPlugins, }, body, original_len, @@ -1016,13 +1037,36 @@ fn build_store( drop(held); } }); - Some(tw_store::task::spawn( + let recorder = tw_store::task::spawn( // 算完价钱往回报一条 —— 见 `Event::RequestPriced`。这里是唯一 // 同时看得见总线和存储层的地方,所以接线在这儿完成。 tw_store::Recorder::new(db, blobs, pricing).reporting_to(bus), events, rx, - )) + ); + // 插件的运行记录同样在这里对接:网关那边的一次运行,换成存储层的一行 + let (run_tx, run_rx) = tokio::sync::mpsc::channel(tw_gateway::plugin::RUN_CHANNEL_CAP); + let mut runs = runs; + tokio::spawn(async move { + while let Some(r) = runs.recv().await { + let row = tw_store::PluginRunRow { + request_id: r.request_id as i64, + at_ms: r.at_ms as i64, + plugin_id: r.run.plugin_id, + plugin_name: r.run.plugin_name, + hook: r.run.hook, + outcome: r.run.outcome, + error: r.run.error, + cpu_us: r.run.cpu_us.min(i64::MAX as u64) as i64, + detail: r.run.detail.map(|d| d.to_string()), + }; + if run_tx.send(row).await.is_err() { + return; + } + } + }); + tw_store::task::record_plugin_runs(recorder.clone(), run_rx); + Some(recorder) } /// 等一个「该退了」。 diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index a3f68cb0..8dce27cb 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -62,6 +62,13 @@ config.failover_range config.name_collision config.no_clients config.output_limit_range +config.plugin.bad_id +config.plugin.blank_pattern +config.plugin.duplicate +config.plugin.file +config.plugin.reserved_id +config.plugin.setting_type +config.plugin.sha256 config.rejected config.rejected_at config.remote_port_is_gateway @@ -136,6 +143,18 @@ control.no_such_version control.not_a_chatgpt_account control.patch.no_entry control.patch.not_an_entry +control.plugin.bad_id +control.plugin.blank_pattern +control.plugin.file_missing +control.plugin.file_moved_on +control.plugin.id_taken +control.plugin.not_found +control.plugin.order +control.plugin.reserved_id +control.plugin.trial_changed +control.plugin.trial_unavailable +control.plugin.unreadable +control.plugin.write_failed control.pricing.broke_off control.pricing.not_a_dataset control.pricing.save_failed @@ -260,7 +279,18 @@ gw.oauth.status gw.oauth.unreachable gw.output_limit.cut gw.output_limit.withheld +gw.plugin.api +gw.plugin.engine gw.plugin.failed +gw.plugin.file_changed +gw.plugin.manifest +gw.plugin.not_located +gw.plugin.setting_type +gw.plugin.setting_unknown +gw.plugin.syntax +gw.plugin.syntax_at +gw.plugin.too_large +gw.plugin.unreadable gw.probe.aws_token_expired gw.probe.bedrock_list_denied gw.probe.connect diff --git a/crates/tw-api/src/ep.rs b/crates/tw-api/src/ep.rs index cf15a809..dd6f3aa5 100644 --- a/crates/tw-api/src/ep.rs +++ b/crates/tw-api/src/ep.rs @@ -129,6 +129,35 @@ endpoints! { DeleteCustomRule: DELETE "/security/{guard}/custom/{name}" [guard, name], api::BaseVersion => api::ConfigWritten; TestSecurity: POST "/security/{guard}/test" [guard], api::SecurityTestRequest => api::SecurityTestResult; + // ─────────────────────────────────────────────── 脚本插件 + // + // **装、换源码、批准三个端点不给网页调**(桌面端的 `call` 白名单里没有它们): + // 这三件事要在系统的确认框里点头,那一步在桌面端的 Rust 里 —— 它自己再编一遍 + // 源码,把名字、权限和哈希摆给人看,点了头才发请求。网页里的脚本做不到这件事, + // 就做不成这三件事。 + /// 全部插件,按运行的顺序:状态、计数 + Plugins: GET "/plugins", () => Vec; + /// 编一份源码看看它是什么插件。**什么都不留下** + PluginInspect: POST "/plugins/inspect", api::PluginSource => api::PluginInspection; + /// 装一个:写插件文件和它的底稿,配置里加一条。**网页不能调** + CreatePlugin: POST "/plugins", api::PluginCreate => api::ConfigWritten; + /// 排顺序,也就是运行的顺序 + ReorderPlugins: PUT "/plugins/order", api::PluginOrder => api::ConfigWritten; + /// 开关、出错时怎么办、范围、设置 + UpdatePlugin: PUT "/plugins/{id}" [id], api::PluginUpdate => api::ConfigWritten; + /// 删掉:配置里那一条、插件文件和底稿 + DeletePlugin: DELETE "/plugins/{id}" [id], api::BaseVersion => api::ConfigWritten; + /// 换一份源码,批准的就是新的这一份。**网页不能调** + ReplacePluginSource: PUT "/plugins/{id}/source" [id], api::PluginSourceReplace => api::ConfigWritten; + /// 批准过的那一份和磁盘上现在那一份 + PluginSourceDiff: GET "/plugins/{id}/source" [id], () => api::PluginSourceView; + /// 批准磁盘上改过的那个文件。**网页不能调** + ApprovePluginFile: POST "/plugins/{id}/approve" [id], api::PluginApprove => api::ConfigWritten; + /// 拿一条记下的请求试跑。**不连上游** + TrialPlugin: POST "/plugins/{id}/trial" [id], api::PluginTrial => api::PluginTrialResult; + /// 最近的日志,老的在前 + PluginLogs: GET "/plugins/{id}/logs" [id], () => Vec; + // ─────────────────────────────────────────────── 账号登录 StartChatgptLogin: POST "/chatgpt/login", api::ChatgptLoginStart => api::ChatgptLogin; ChatgptLoginStatus: GET "/chatgpt/login/{id}" [id], () => api::ChatgptLoginStatus; diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 0f54f7f7..5686737c 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -676,8 +676,11 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// 从存下来的正文里读出每一轮新说的话、回答、推理、工具调用和结果,读不到的地方逐轮说出来 /// ([`TranscriptGap`])。照 31 写的界面只有每一轮的用量和金额。 /// -/// **33 起有脚本插件**:事件多了 [`Event::PluginFailed`](插件在请求上出错,或者文件变了、 -/// 加载不了而停用)。照 32 写的界面不认这个事件。 +/// **33 起有脚本插件**:`/plugins` 一组端点(列表、试编、装、改、换源码、看改动、批准、 +/// 排顺序、删、试跑、日志),事件多了 [`Event::PluginFailed`](插件在请求上出错,或者 +/// 文件变了、加载不了而停用),[`RequestDetail`] 多了 `plugins`(每一次运行)和 +/// `request_after_plugins`(插件改过的请求体),[`HistoryRow`] 多了 `plugin_changed`。 +/// 装、换源码、批准三个端点不给网页调:要在系统的确认框里点头。照 32 写的界面看不到插件。 pub const CONTROL_API_VERSION: u32 = 33; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -3606,6 +3609,9 @@ pub struct HistoryRow { /// 而那正是用户回头翻「那一条到底被换了什么」的时候。 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub security: Vec, + /// 插件改过这个请求或它的回答。**流量页的徽标靠它**;改了什么见详情里的 + /// [`RequestDetail::plugins`] + pub plugin_changed: bool, } /// 搜索的一页(`POST /history/search`),新的在前。 @@ -3688,8 +3694,13 @@ pub struct TranslatedView { #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct RequestDetail { pub row: HistoryRow, + /// 客户端发来的原样 pub request_body: Option, + /// 插件改过之后、发往上游的那一份。**只有插件改了请求才有** + pub request_after_plugins: Option, pub response_body: Option, + /// 插件在这个请求上的每一次运行,按先后(请求钩子在前,回答钩子在后) + pub plugins: Vec, /// 这个请求还在跑。**记录在结局到了才落库**,这时的 `row` 是到目前为止 /// 知道的那些:开始时的身份和上游,响应头到了就有状态码,路由走完就有 /// 尝试链;耗时、用量、金额都还没有。请求体已经存下了,响应体要等结局。 @@ -4950,6 +4961,271 @@ pub struct PluginLastError { pub message: Msg, } +/// 一个设置的值:字符串、数字或 true/false。 +/// +/// **线上就是那个值本身**(不带类型标记):`"今天"`、`3`、`true`。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[serde(untagged)] +pub enum SettingValue { + Bool(bool), + Number(f64), + String(String), +} + +impl SettingValue { + /// 是不是这种设置的类型 + pub fn kind(&self) -> SettingKind { + match self { + SettingValue::Bool(_) => SettingKind::Boolean, + SettingValue::Number(_) => SettingKind::Number, + SettingValue::String(_) => SettingKind::String, + } + } +} + +/// 插件管哪些请求。**每张单子里都是 `*` 通配**(不分大小写),空着是「都管」。 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginScope { + /// 客户端应用:`claude-code`、`codex`……(请求记录上的 `client_hint`) + pub clients: Vec, + /// 客户端要的模型 + pub models: Vec, + /// 服务回答的上游。**只管回答那一段**:改请求时还没选上游 + pub upstreams: Vec, +} + +/// 插件声明的一个设置项。`label` 是**插件写的字**:界面当纯文本显示。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct SettingSpecView { + pub key: String, + pub kind: SettingKind, + pub label: String, + /// 和 `kind` 同一种类型 + pub default: SettingValue, +} + +/// 插件导出了哪些钩子。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginHooks { + /// `onRequest` + pub request: bool, + /// `onReplyText` + pub reply_text: bool, + /// `onToolCall` + pub tool_call: bool, +} + +/// 插件文件里的 manifest,加上它导出了哪些钩子。名字、说明、设置项的 `label` +/// **都是插件写的字**。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct ManifestView { + pub name: String, + pub description: Option, + pub permissions: Vec, + /// 插件建议的范围。装上时照它填 + pub scope: PluginScope, + pub reply_mode: ReplyMode, + pub settings_schema: Vec, + pub hooks: PluginHooks, +} + +/// 插件此刻能不能跑。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PluginStatus { + /// 在跑 + Ok, + /// 停用着 + Disabled, + /// 磁盘上的文件和批准过的不一样了(或者没了),**不跑**。看过改动、重新批准才 + /// 回来([`PluginSourceView`]、`ApprovePluginFile`)。停用着的插件文件变了也是它 + Changed, + /// 加载不了:语法错、manifest 不合规矩、设置和 manifest 对不上…… + Error { message: Msg }, +} + +/// 一个装上了的插件(`GET /plugins`),按运行的顺序。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginView { + pub id: String, + /// 插件自己起的名字。**插件写的字**。读不出 manifest 时是 id + pub name: String, + /// 插件写的字 + pub description: Option, + pub enabled: bool, + pub on_error: OnError, + /// 读不出 manifest 时是空的 + pub permissions: Vec, + /// 生效的范围(配置里的) + pub scope: PluginScope, + pub reply_mode: ReplyMode, + pub settings_schema: Vec, + /// 交给插件的值:配置里写的,没写的是默认值 + pub settings: std::collections::BTreeMap, + /// 批准过的那一份的 SHA-256,小写十六进制 + pub sha256: String, + pub status: PluginStatus, + pub stats: PluginStats, +} + +/// 一份源码(`POST /plugins/inspect`)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginSource { + pub source: String, +} + +/// 编一份源码看到的东西。**什么都没留下**:不写文件、不改配置。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginInspection { + /// 编得成才有 + pub manifest: Option, + /// 这份源码(UTF-8 字节)的 SHA-256。装、批准时核对的就是它 + pub sha256: String, + /// 编不成的原因 + pub error: Option, +} + +/// 编不成的原因。语法错带着行列(从 1 起)。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginLoadError { + pub message: Msg, + pub line: Option, + pub column: Option, +} + +/// 装一个插件(`POST /plugins`)。 +/// +/// **网页不能调。**装插件要在系统的确认框里点头,那一步在桌面端的 Rust 里:它自己 +/// 再编一遍源码、把名字和权限摆给人看,点了头才发这个请求。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginCreate { + pub source: String, + /// 不给就从名字生成一个 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub enabled: bool, + pub on_error: OnError, + pub scope: PluginScope, + /// 没给的取默认值 + pub settings: std::collections::BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_version: Option, +} + +/// 改一个插件的开关、出错时怎么办、范围、设置(`PUT /plugins/{id}`)。**整份交**: +/// 交上来的就是保存之后的样子。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginUpdate { + pub enabled: bool, + pub on_error: OnError, + pub scope: PluginScope, + /// 没给的取默认值 + pub settings: std::collections::BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_version: Option, +} + +/// 换一份源码(`PUT /plugins/{id}/source`)。**网页不能调**,理由同 [`PluginCreate`]。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginSourceReplace { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_version: Option, +} + +/// 批准磁盘上改过的那个文件(`POST /plugins/{id}/approve`)。**网页不能调**,理由同 +/// [`PluginCreate`]。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginApprove { + /// 看过的那一份的哈希([`PluginSourceView::current_sha256`])。**磁盘上的文件得 + /// 正好是它**:看完到点头之间又被改了的,不批 + pub sha256: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_version: Option, +} + +/// 批准过的那一份和磁盘上现在那一份(`GET /plugins/{id}/source`)。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginSourceView { + /// 批准时存下的那一份。**底稿没了、或者也被改过(哈希对不上)时是空的**: + /// 说不出批准的是什么,就不拿别的冒充 + pub approved: String, + /// 配置里批准的哈希 + pub approved_sha256: String, + /// 磁盘上现在的那一份。文件没了是 None + pub current: Option, + pub current_sha256: Option, +} + +/// 排顺序(`PUT /plugins/order`):**全部 id**,按新的顺序。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginOrder { + pub ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_version: Option, +} + +/// 拿一条记下的请求试跑一个插件(`POST /plugins/{id}/trial`)。**不连上游**。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginTrial { + /// 请求记录的号([`HistoryRow::id`]) + pub request_id: i64, +} + +/// 试跑的结果。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginTrialResult { + /// 请求钩子跑在记下的请求上。插件没有请求钩子、请求体没留下时没有 + pub request: Option, + /// 回答钩子跑在记下的回答上。插件没有回答钩子、回答没留下时没有 + pub reply: Option, + /// 这次试跑写的日志。**不进插件的日志** + pub logs: Vec, + /// 试不了的原因(插件没加载起来、记录里没有可试的东西……) + pub error: Option, +} + +/// 试跑的一边:前后两份,排好版的 JSON,**已打码**。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct TrialSide { + pub before: String, + pub after: String, + pub outcome: PluginOutcome, +} + +/// 一个插件在一个请求上的一次运行(详情抽屉的时间线)。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct PluginRunView { + pub plugin_id: String, + /// 当时的名字。**插件写的字** + pub plugin_name: String, + pub hook: PluginHook, + pub outcome: PluginOutcome, + /// 出错、拒绝的原因 + pub error: Option, + pub cpu_us: u64, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tw-api/src/ts.rs b/crates/tw-api/src/ts.rs index f2f28142..08035f67 100644 --- a/crates/tw-api/src/ts.rs +++ b/crates/tw-api/src/ts.rs @@ -334,6 +334,47 @@ mod tests { ); } + /// 插件:设置值是那个值本身,状态按 `kind` 分派,三个要系统确认框的端点照样在表里 + /// (网页白名单在桌面端,不在这里) + #[test] + fn plugins_come_through() { + let ts = typescript(); + assert_eq!( + decl_of(&ts, "SettingValue"), + "export type SettingValue = boolean | number | string" + ); + assert_eq!( + decl_of(&ts, "PluginStatus"), + "export type PluginStatus = { \"kind\": \"ok\" } | { \"kind\": \"disabled\" } | \ + { \"kind\": \"changed\" } | { \"kind\": \"error\", message: Msg, }" + ); + let view = decl_of(&ts, "PluginView"); + assert!( + view.contains("settings: { [key in string]: SettingValue }"), + "{view}" + ); + assert!(view.contains("stats: PluginStats"), "{view}"); + let detail = decl_of(&ts, "RequestDetail"); + assert!(detail.contains("plugins: Array"), "{detail}"); + assert!( + detail.contains("request_after_plugins: BodyView | null"), + "{detail}" + ); + assert!(decl_of(&ts, "HistoryRow").contains("plugin_changed: boolean")); + let event = decl_of(&ts, "Event"); + assert!(event.contains("\"kind\": \"plugin_failed\""), "{event}"); + assert!(event.contains("request_id?: number"), "{event}"); + for line in [ + " CreatePlugin: { req: PluginCreate; res: ConfigWritten };", + " ReplacePluginSource: { req: PluginSourceReplace; res: ConfigWritten };", + " ApprovePluginFile: { req: PluginApprove; res: ConfigWritten };", + " DeletePlugin: { req: BaseVersion; res: ConfigWritten };", + " TrialPlugin: { req: PluginTrial; res: PluginTrialResult };", + ] { + assert!(ts.contains(line), "{line}"); + } + } + #[test] fn every_endpoint_is_in_the_table() { let ts = typescript(); diff --git a/crates/tw-config/src/edit.rs b/crates/tw-config/src/edit.rs index 6e98b2b4..737edebb 100644 --- a/crates/tw-config/src/edit.rs +++ b/crates/tw-config/src/edit.rs @@ -95,27 +95,40 @@ impl EditError { pub struct Section { pub path: &'static [&'static str], pub what: &'static str, + /// 每一项靠哪个键认:几乎都是 `name`,插件是 `id` + pub key: &'static str, } pub const PROVIDERS: Section = Section { path: &["providers"], what: "upstream", + key: "name", }; pub const PROXIES: Section = Section { path: &["proxies"], what: "proxy", + key: "name", }; pub const PRICE_SHEETS: Section = Section { path: &["pricing", "sheets"], what: "price sheet", + key: "name", }; pub const ROUTES: Section = Section { path: &["routes"], what: "route", + key: "name", }; pub const GROUPS: Section = Section { path: &["groups"], what: "group", + key: "name", +}; + +pub const PLUGINS: Section = Section { + path: &["plugins"], + what: "plugin", + key: "id", }; impl Section { @@ -138,7 +151,7 @@ impl Section { pub fn index_of(&self, doc: &Value, name: &str) -> Option { self.items(doc) .iter() - .position(|it| it.get("name").and_then(Value::as_str) == Some(name)) + .position(|it| it.get(self.key).and_then(Value::as_str) == Some(name)) } } @@ -158,7 +171,7 @@ pub fn upsert( ) -> Result { let doc = parse(text)?; let name = item - .get("name") + .get(section.key) .and_then(Value::as_str) .ok_or(EditError::Nameless { what: section.what })? .to_string(); @@ -244,6 +257,50 @@ pub fn remove(text: &str, section: Section, name: &str) -> Result Result { + let doc = parse(text)?; + let items = section.items(&doc); + let order = keys + .iter() + .map(|k| { + section + .index_of(&doc, k) + .ok_or_else(|| EditError::NotFound { + what: section.what, + name: k.clone(), + }) + }) + .collect::, _>>()?; + let mut expected = doc.clone(); + let reordered: Vec = order.iter().map(|&i| items[i].clone()).collect(); + let steps = section.steps(); + let out = match tw_yaml::reorder(text, &steps, &order) { + Ok(out) => out, + // 行内写法、或者别的块式之外的写法:整段换成重排后的样子 + Err(tw_yaml::PatchError::NotFound(_)) => { + set(text, &steps, Some(&Value::Sequence(reordered.clone())))? + } + Err(e) => return Err(e.into()), + }; + // ── 语义核对 ───────────────────────────────────────────────────── + let mut cur = &mut expected; + for k in section.path { + cur = cur + .get_mut(*k) + .ok_or_else(|| EditError::SelfCheck(format!("{} order", section.what)))?; + } + *cur = Value::Sequence(reordered); + let got = parse(&out).map_err(|e| EditError::SelfCheck(e.to_string()))?; + if got != expected { + return Err(EditError::SelfCheck(format!("{} order", section.what))); + } + Ok(out) +} + /// 设一个值。`None` 表示删掉这个键、退回默认值 —— **默认值不写进文件**。 pub fn set(text: &str, path: &[Step], value: Option<&Value>) -> Result { let exists = { diff --git a/crates/tw-config/src/lib.rs b/crates/tw-config/src/lib.rs index 135e111a..ec466330 100644 --- a/crates/tw-config/src/lib.rs +++ b/crates/tw-config/src/lib.rs @@ -15,6 +15,7 @@ mod failover; pub mod history; mod init; pub mod nics; +pub mod plugins; pub mod private_dir; mod probes; pub mod proxy; @@ -30,6 +31,7 @@ mod wire; pub use credential::{CredentialError, Header, Headers, Secret, SecretResolveError, auth_header}; pub use init::{generate_control_key, generate_initial, generate_key}; +pub use plugins::{Plugin, PluginOnError, PluginScope}; pub use proxy::{DIRECT, OnProxyFail, Proxy, ProxyKind, SYSTEM}; pub use validate::ValidationError; @@ -113,6 +115,9 @@ pub struct Config { /// 说不清的时刻断掉。不写就是名字叫 `default` 的那把。 #[serde(default, skip_serializing_if = "Option::is_none")] pub default_key: Option, + /// 脚本插件,**从上到下就是运行的顺序**。不写就没有 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub plugins: Vec, } /// 便于构造,**不代表一份可用的配置** —— `providers` 和 `clients` 都是 @@ -135,6 +140,7 @@ impl Default for Config { routes: Vec::new(), default_route: None, default_key: None, + plugins: Vec::new(), } } } diff --git a/crates/tw-config/src/plugins.rs b/crates/tw-config/src/plugins.rs new file mode 100644 index 00000000..fc99388f --- /dev/null +++ b/crates/tw-config/src/plugins.rs @@ -0,0 +1,372 @@ +//! `plugins` 一节:装了哪些脚本插件、批准的是哪一份、管哪些请求。 +//! +//! **这里只有数据。**插件文件里写的 manifest(名字、权限、设置项)要编译才读得出来, +//! 那是网关加载插件时的事:设置的键和类型对不对得上 manifest、文件还是不是批准的那 +//! 一份,都在那里查,查出问题只让那一个插件停用,**不挡配置换入**。这里查的是不看 +//! 插件文件也能判断的那些:id 的写法、重名、文件路径、哈希的写法、范围里的空模式、 +//! 设置值的类型。 +//! +//! 文件由 core 写:`plugins/.js` 是插件,`plugins/.approved/.js` 是批准时 +//! 的那一份(给界面显示改了什么)。路径都相对配置文件所在的目录。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// 插件文件所在的目录,相对配置文件所在的目录。 +pub const DIR: &str = "plugins"; + +/// 批准过的那一份放在哪个子目录。点开头:它不是插件,是插件的底稿 +pub const APPROVED_DIR: &str = ".approved"; + +/// id 最长多少个字符 +pub const ID_MAX: usize = 40; + +/// 不能当 id 的词:控制面上 `/plugins/order`、`/plugins/inspect` 是两个固定的端点, +/// 叫这两个名字的插件会和它们撞在同一个路径上 +pub const RESERVED_IDS: &[&str] = &["order", "inspect"]; + +/// 一个装上了的插件。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plugin { + /// 小写字母、数字和连字符,1 到 40 个字符,不重复 + pub id: String, + /// 插件文件,相对配置文件所在的目录。**只能是 `plugins/.js`**:文件是 core + /// 写的,指到别处的路径只会让「替换源码」去写一个不该写的文件 + pub file: String, + /// 批准过的那一份的 SHA-256,64 个小写十六进制字符。**文件的哈希和它不一样, + /// 插件就不跑** + pub sha256: String, + #[serde(default = "yes")] + pub enabled: bool, + /// 插件出错、文件变了、加载不了时,它管的请求怎么办 + #[serde(default)] + pub on_error: PluginOnError, + /// 管哪些请求。装上时照插件建议的填,之后以这里为准 + #[serde(default, skip_serializing_if = "PluginScope::is_empty")] + pub scope: PluginScope, + /// 设置的值:字符串、数字或 true/false。**没写的取插件的默认值** + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub settings: BTreeMap, +} + +fn yes() -> bool { + true +} + +/// 插件出错时这个请求怎么办。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginOnError { + /// 拒绝这个请求。**不写就是它**:插件管不了的请求不该悄悄照原样发出去 + #[default] + Reject, + /// 跳过这个插件,请求照常 + Skip, +} + +impl PluginOnError { + pub fn slug(&self) -> &'static str { + match self { + PluginOnError::Reject => "reject", + PluginOnError::Skip => "skip", + } + } +} + +/// 插件管哪些请求。**每张单子里都是 `*` 通配**(不分大小写),空着是「都管」。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginScope { + /// 客户端应用:`claude-code`、`codex`…… + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub clients: Vec, + /// 客户端要的模型 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, + /// 服务回答的上游。只管回答那一段:改请求时还没选上游 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upstreams: Vec, +} + +impl PluginScope { + pub fn is_empty(&self) -> bool { + self.clients.is_empty() && self.models.is_empty() && self.upstreams.is_empty() + } + + fn patterns(&self) -> impl Iterator { + self.clients + .iter() + .chain(&self.models) + .chain(&self.upstreams) + } +} + +impl Plugin { + /// 这个 id 的插件文件写在配置里的样子:`plugins/.js` + pub fn file_for(id: &str) -> String { + format!("{DIR}/{id}.js") + } + + /// 插件文件在磁盘上的位置。`dir` 是配置文件所在的目录 + pub fn path_in(&self, dir: &Path) -> PathBuf { + dir.join(&self.file) + } +} + +/// 插件文件的目录。`dir` 是配置文件所在的目录 +pub fn dir_in(dir: &Path) -> PathBuf { + dir.join(DIR) +} + +/// 批准过的那一份在哪儿:`plugins/.approved/.js` +pub fn approved_path(dir: &Path, id: &str) -> PathBuf { + dir.join(DIR).join(APPROVED_DIR).join(format!("{id}.js")) +} + +/// 插件文件的位置:`plugins/.js` +pub fn file_path(dir: &Path, id: &str) -> PathBuf { + dir.join(Plugin::file_for(id)) +} + +/// id 写得对不对:小写字母、数字、连字符,1 到 [`ID_MAX`] 个字符。保留词另查 +pub fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= ID_MAX + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// 哈希写得对不对:64 个小写十六进制字符 +pub fn valid_sha256(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// 设置值能不能交给插件:字符串、数字、true/false +pub fn valid_setting(v: &serde_yaml_ng::Value) -> bool { + matches!( + v, + serde_yaml_ng::Value::String(_) + | serde_yaml_ng::Value::Number(_) + | serde_yaml_ng::Value::Bool(_) + ) +} + +/// 查一份 `plugins`。哪一条不对就说哪一条。 +pub(crate) fn check(plugins: &[Plugin]) -> Result<(), crate::ValidationError> { + use crate::ValidationError as E; + let mut seen = std::collections::HashSet::new(); + for p in plugins { + if !valid_id(&p.id) { + return Err(E::PluginId { id: p.id.clone() }); + } + if RESERVED_IDS.contains(&p.id.as_str()) { + return Err(E::PluginIdReserved { id: p.id.clone() }); + } + if !seen.insert(p.id.as_str()) { + return Err(E::DuplicatePlugin { id: p.id.clone() }); + } + if p.file != Plugin::file_for(&p.id) { + return Err(E::PluginFile { + id: p.id.clone(), + file: p.file.clone(), + }); + } + if !valid_sha256(&p.sha256) { + return Err(E::PluginSha256 { id: p.id.clone() }); + } + if p.scope.patterns().any(|x| x.trim().is_empty()) { + return Err(E::BlankPluginPattern { id: p.id.clone() }); + } + if let Some((key, _)) = p.settings.iter().find(|(_, v)| !valid_setting(v)) { + return Err(E::PluginSettingType { + id: p.id.clone(), + key: key.clone(), + }); + } + } + Ok(()) +} + +impl From for tw_api::OnError { + fn from(o: PluginOnError) -> Self { + match o { + PluginOnError::Reject => Self::Reject, + PluginOnError::Skip => Self::Skip, + } + } +} + +impl From for PluginOnError { + fn from(o: tw_api::OnError) -> Self { + match o { + tw_api::OnError::Reject => Self::Reject, + tw_api::OnError::Skip => Self::Skip, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HASH: &str = "6f1c000000000000000000000000000000000000000000000000000000000abc"; + + fn parse(yaml: &str) -> Result { + let text = format!( + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\nplugins:\n{yaml}" + ); + crate::try_parse(&text).map_err(|r| format!("{}: {}", r.message.code, r.message.text)) + } + + fn entry(id: &str) -> String { + format!(" - id: {id}\n file: plugins/{id}.js\n sha256: {HASH}\n") + } + + #[test] + fn the_shortest_entry_runs_enabled_and_rejects_on_error() { + let cfg = parse(&entry("add-date")).unwrap(); + let p = &cfg.plugins[0]; + assert_eq!(p.id, "add-date"); + assert!(p.enabled); + assert_eq!(p.on_error, PluginOnError::Reject); + assert!(p.scope.is_empty() && p.settings.is_empty()); + } + + #[test] + fn every_field_reads_back() { + let cfg = parse(&format!( + "{} enabled: false\n on_error: skip\n scope: {{ clients: [claude-code], models: [\"claude-*\"], upstreams: [anthropic] }}\n settings: {{ note: hi, count: 3, loud: true }}\n", + entry("x1") + )) + .unwrap(); + let p = &cfg.plugins[0]; + assert!(!p.enabled); + assert_eq!(p.on_error, PluginOnError::Skip); + assert_eq!(p.scope.models, ["claude-*"]); + assert_eq!(p.settings["count"], serde_yaml_ng::Value::from(3)); + assert_eq!(p.settings["loud"], serde_yaml_ng::Value::from(true)); + } + + /// 写错的字段名是错误,不是空操作 —— 和别的段落一样 + #[test] + fn an_unknown_field_is_refused() { + let e = parse(&format!("{} onerror: skip\n", entry("x1"))).unwrap_err(); + assert!(e.contains("onerror"), "{e}"); + let e = parse(&format!("{} scope: {{ model: [a] }}\n", entry("x1"))).unwrap_err(); + assert!(e.contains("model"), "{e}"); + let e = parse(&format!("{} on_error: ignore\n", entry("x1"))).unwrap_err(); + assert!(e.contains("ignore"), "{e}"); + } + + #[test] + fn ids_are_lowercase_words_up_to_forty_characters() { + for bad in ["Add-Date", "add_date", "add.date", &"a".repeat(41)] { + let yaml = + format!(" - id: \"{bad}\"\n file: plugins/{bad}.js\n sha256: {HASH}\n"); + let e = parse(&yaml).unwrap_err(); + assert!(e.starts_with("config.plugin.bad_id"), "{bad}: {e}"); + } + assert!(parse(&entry(&"a".repeat(40))).is_ok()); + assert!(parse(&entry("a-1-b")).is_ok()); + } + + /// `/plugins/order` 和 `/plugins/inspect` 是控制面上两个固定的端点 + #[test] + fn the_words_the_control_plane_uses_are_not_ids() { + for id in RESERVED_IDS { + let e = parse(&entry(id)).unwrap_err(); + assert!(e.starts_with("config.plugin.reserved_id"), "{id}: {e}"); + } + } + + /// 控制面上 `/plugins/` 底下每一个固定的词都不能当 id:不然那个插件的 + /// `/plugins/{id}` 和固定的端点落在同一个路径上 + #[test] + fn every_fixed_word_under_plugins_on_the_control_plane_is_reserved() { + let fixed: std::collections::BTreeSet<&str> = tw_api::ep::ALL + .iter() + .filter_map(|e| e.path.strip_prefix("/plugins/")) + .map(|rest| rest.split('/').next().unwrap_or_default()) + .filter(|seg| !seg.starts_with('{')) + .collect(); + assert_eq!( + fixed, + RESERVED_IDS.iter().copied().collect(), + "the reserved ids and the control plane disagree" + ); + } + + #[test] + fn an_id_appears_once() { + let e = parse(&format!("{}{}", entry("a"), entry("a"))).unwrap_err(); + assert!(e.starts_with("config.plugin.duplicate"), "{e}"); + } + + /// 文件是 core 写的:指到别处的路径只会让「替换源码」去写一个不该写的文件 + #[test] + fn the_file_is_the_one_core_writes_for_that_id() { + for file in [ + "plugins/other.js", + "../plugins/a.js", + "/etc/a.js", + "plugins/a.mjs", + "plugins/.approved/a.js", + ] { + let yaml = format!(" - id: a\n file: {file}\n sha256: {HASH}\n"); + let e = parse(&yaml).unwrap_err(); + assert!(e.starts_with("config.plugin.file"), "{file}: {e}"); + } + } + + #[test] + fn the_hash_is_sixty_four_lowercase_hex_characters() { + for bad in ["abc", &HASH.to_uppercase(), &format!("{}g", &HASH[..63])] { + let yaml = format!(" - id: a\n file: plugins/a.js\n sha256: \"{bad}\"\n"); + let e = parse(&yaml).unwrap_err(); + assert!(e.starts_with("config.plugin.sha256"), "{bad}: {e}"); + } + } + + #[test] + fn a_scope_pattern_cannot_be_blank() { + let e = parse(&format!("{} scope: {{ models: [\" \"] }}\n", entry("a"))).unwrap_err(); + assert!(e.starts_with("config.plugin.blank_pattern"), "{e}"); + } + + #[test] + fn a_setting_is_a_string_a_number_or_a_boolean() { + for bad in ["[1, 2]", "{ a: 1 }", "null"] { + let e = parse(&format!("{} settings: {{ note: {bad} }}\n", entry("a"))).unwrap_err(); + assert!(e.starts_with("config.plugin.setting_type"), "{bad}: {e}"); + } + } + + #[test] + fn paths_are_under_the_plugins_directory_of_the_config() { + let dir = Path::new("/home/u/.thinkwatch"); + assert_eq!( + file_path(dir, "a"), + Path::new("/home/u/.thinkwatch/plugins/a.js") + ); + assert_eq!( + approved_path(dir, "a"), + Path::new("/home/u/.thinkwatch/plugins/.approved/a.js") + ); + assert_eq!(dir_in(dir), Path::new("/home/u/.thinkwatch/plugins")); + } + + /// 不写 `plugins` 就是没有插件,写回去也不多出这一行 + #[test] + fn no_plugins_means_no_section_in_the_file() { + let cfg = crate::Config::default(); + assert!(cfg.plugins.is_empty()); + let out = serde_yaml_ng::to_string(&cfg).unwrap(); + assert!(!out.contains("plugins"), "{out}"); + } +} diff --git a/crates/tw-config/src/validate.rs b/crates/tw-config/src/validate.rs index 045bff80..74b3b074 100644 --- a/crates/tw-config/src/validate.rs +++ b/crates/tw-config/src/validate.rs @@ -87,6 +87,20 @@ pub enum ValidationError { RemotePortIsGateway { port: u16 }, #[error("{}", self.msg())] BadRemoteCidr { entry: String }, + #[error("{}", self.msg())] + PluginId { id: String }, + #[error("{}", self.msg())] + PluginIdReserved { id: String }, + #[error("{}", self.msg())] + DuplicatePlugin { id: String }, + #[error("{}", self.msg())] + PluginFile { id: String, file: String }, + #[error("{}", self.msg())] + PluginSha256 { id: String }, + #[error("{}", self.msg())] + BlankPluginPattern { id: String }, + #[error("{}", self.msg())] + PluginSettingType { id: String, key: String }, } impl ValidationError { @@ -245,6 +259,35 @@ impl ValidationError { "`{entry}` in listen.control.remote.allow_from is wrong: not a valid IP address or \ CIDR; it is written as 192.168.0.0/16" ), + PluginId { id } => msg!( + "config.plugin.bad_id", plugin = id, max = crate::plugins::ID_MAX => + "the plugin id `{plugin}` is written wrongly: lowercase letters, digits and \ + hyphens, 1 to {max} characters" + ), + PluginIdReserved { id } => msg!( + "config.plugin.reserved_id", plugin = id => + "`{plugin}` cannot be a plugin id: the control plane uses that word itself" + ), + DuplicatePlugin { id } => msg!( + "config.plugin.duplicate", plugin = id => + "the plugin id `{plugin}` appears twice" + ), + PluginFile { id, file } => msg!( + "config.plugin.file", plugin = id, file = file => + "the file of plugin `{plugin}` is {file}; it has to be plugins/{plugin}.js" + ), + PluginSha256 { id } => msg!( + "config.plugin.sha256", plugin = id => + "the sha256 of plugin `{plugin}` has to be 64 lowercase hexadecimal characters" + ), + BlankPluginPattern { id } => msg!( + "config.plugin.blank_pattern", plugin = id => + "the scope of plugin `{plugin}` has an empty entry" + ), + PluginSettingType { id, key } => msg!( + "config.plugin.setting_type", plugin = id, key = key => + "setting `{key}` of plugin `{plugin}` has to be a string, a number or true/false" + ), } } } @@ -471,6 +514,9 @@ pub fn validate(cfg: &Config) -> Result<(), ValidationError> { } Some(_) => {} } + // 插件。**只查不看插件文件也判断得了的**:文件变没变、设置对不对得上 manifest, + // 是网关加载那一个插件时的事,出了问题只停那一个,不挡整份配置 + crate::plugins::check(&cfg.plugins)?; // 远程控制端口。**没开也照样查**:开关一拨就生效,写错的地方要在写下去 // 的那一刻说,不是等到有人打开它的时候 if let Some(r) = &cfg.listen.control.remote { diff --git a/crates/tw-config/tests/manual.rs b/crates/tw-config/tests/manual.rs index 649208f0..7d8c755b 100644 --- a/crates/tw-config/tests/manual.rs +++ b/crates/tw-config/tests/manual.rs @@ -166,6 +166,8 @@ pub enum Kind { Compare, /// 请求头名 → 值 Headers, + /// 插件的设置项 → 字符串、数字或布尔 + Settings, /// 可选值由枚举生成 Enum(fn() -> Vec<&'static str>), /// 键 → 枚举值 @@ -286,6 +288,10 @@ fn kind(k: &Kind, l: Lang) -> String { "比较式(`>200k`、`<=4k`、`==3`)", ), Kind::Headers => pick("map of header name → value", "请求头名 → 值的映射"), + Kind::Settings => pick( + "map of setting → string, number or bool", + "设置项 → 字符串、数字或布尔的映射", + ), Kind::Enum(f) => values(&f()), Kind::EnumMap(key, f) => format!( "{} {} → {}", diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index a05baa63..0819690e 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -58,6 +58,9 @@ fn content_matches() -> Vec<&'static str> { fn group_types() -> Vec<&'static str> { super::fields::() } +fn plugin_on_error() -> Vec<&'static str> { + super::fields::() +} const RULE_ID: T2 = t("built-in rule id", "内置规则 id"); const MODE_DOC: T2 = t( @@ -216,6 +219,15 @@ pub fn sections() -> Vec
{ "没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。", ), ), + row( + "plugins", + Kind::Objs("plugins[]"), + Def::Is("[]"), + t( + "Script plugins, in the order they run. The app installs them; each one's code is a file next to this one.", + "脚本插件,按运行的顺序。由应用安装,每个插件的代码是本文件旁边的一个文件。", + ), + ), ], }, // ── listen ──────────────────────────────────────────── @@ -1502,6 +1514,112 @@ pub fn sections() -> Vec
{ ), ], }, + // ── plugins ─────────────────────────────────────────── + Section { + path: "plugins[]", + ty: checked!( + Plugin, + "{id: a, file: plugins/a.js, sha256: 0000000000000000000000000000000000000000000000000000000000000000}" + ), + rows: vec![ + row( + "id", + Kind::Str, + Def::Required, + t( + "Lowercase letters, digits and hyphens, 1 to 40 characters; unique. `order` and `inspect` are taken by the control plane.", + "小写字母、数字和连字符,1 到 40 个字符,不能重复。`order` 和 `inspect` 被控制面占用。", + ), + ), + row( + "file", + Kind::Str, + Def::Required, + t( + "The plugin's code, relative to this file's directory. It is always `plugins/.js`; the app writes it.", + "插件的代码,相对本文件所在的目录。只能是 `plugins/.js`,由应用写入。", + ), + ), + row( + "sha256", + Kind::Str, + Def::Required, + t( + "SHA-256 of the approved code, 64 lowercase hexadecimal characters. When the file no longer has this hash, the plugin stops running until the change is approved in the app. The approved code is kept in `plugins/.approved/.js`.", + "批准过的代码的 SHA-256,64 个小写十六进制字符。文件的哈希与它不符时插件停止运行,直到在应用里批准这次改动。批准过的代码另存在 `plugins/.approved/.js`。", + ), + ), + row( + "enabled", + Kind::Bool, + Def::Is("true"), + t( + "Run the plugin. `false` keeps it installed and out of every request.", + "是否运行这个插件。`false`:插件保留,不参与任何请求。", + ), + ), + row( + "on_error", + Kind::Enum(plugin_on_error), + Def::Is("reject"), + t( + "When the plugin fails on a request, or cannot run because its file changed or does not load: `reject` refuses the requests it covers; `skip` lets them through without it.", + "插件在请求上出错,或者因文件改动、加载失败而无法运行时:`reject` 拒绝它所覆盖的请求;`skip` 跳过这个插件,请求照常。", + ), + ), + row( + "scope", + Kind::Obj("plugins[].scope"), + Def::Section, + t( + "Which requests the plugin handles. Filled from the plugin's own suggestion when it is installed.", + "插件处理哪些请求。安装时按插件自己的建议填写。", + ), + ), + row( + "settings", + Kind::Settings, + Def::Is("{}"), + t( + "Values for the settings the plugin declares. A setting left out takes the plugin's default; one the plugin does not declare, or of the wrong type, stops the plugin from loading.", + "插件所声明设置项的值。未写的取插件的默认值;插件未声明的设置项或类型不符的值会使插件无法加载。", + ), + ), + ], + }, + Section { + path: "plugins[].scope", + ty: checked!(PluginScope, "{}"), + rows: vec![ + row( + "clients", + Kind::Strs, + Def::Is("[]"), + t( + "Client apps (`claude-code`, `codex`, …), as names or globs. `[]`: every client, including requests whose app is not recognised.", + "客户端应用(`claude-code`、`codex` 等),写名字或通配。`[]`:所有客户端,包括认不出应用的请求。", + ), + ), + row( + "models", + Kind::Strs, + Def::Is("[]"), + t( + "Models the client asks for, as model ids or globs (`claude-*`). `[]`: every model.", + "客户端请求的模型,写模型 ID 或通配(`claude-*`)。`[]`:所有模型。", + ), + ), + row( + "upstreams", + Kind::Strs, + Def::Is("[]"), + t( + "Upstreams whose answers the plugin handles, by name or glob. It applies to answers only: a request is changed before an upstream is chosen. `[]`: every upstream.", + "插件处理哪些上游的回答,写名字或通配。只作用于回答:请求在选定上游之前就已改写。`[]`:所有上游。", + ), + ), + ], + }, ] } diff --git a/crates/tw-control/Cargo.toml b/crates/tw-control/Cargo.toml index 45957a67..a94ced22 100644 --- a/crates/tw-control/Cargo.toml +++ b/crates/tw-control/Cargo.toml @@ -40,6 +40,8 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml_ng = { workspace = true } tw-yaml = { workspace = true } +# 盯着插件目录:插件文件一动就重读(和配置文件同一份去抖) +tw-watch = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-stream = { workspace = true, features = ["sync"] } diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index 5c00d572..38917597 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -101,6 +101,8 @@ impl ApplyError { impl ConfigManager { pub fn new(path: PathBuf, gateway: tw_gateway::AppState, bus: tw_observe::EventBus) -> Self { let seen = store::read(&path).ok().map(|l| l.fingerprint); + // 插件文件的路径相对配置文件所在的目录:**知道配置在哪儿的是这里**,告诉网关一声 + gateway.set_config_dir(crate::plugins::dir_of(&path)); Self { path, gateway, diff --git a/crates/tw-control/src/keys.rs b/crates/tw-control/src/keys.rs index 4ed45acc..b4a7ed70 100644 --- a/crates/tw-control/src/keys.rs +++ b/crates/tw-control/src/keys.rs @@ -42,6 +42,7 @@ pub fn router() -> axum::Router { pub(crate) const CLIENTS: edit::Section = edit::Section { path: &["clients"], what: "gateway key", + key: "name", }; fn not_found(name: &str) -> ApplyError { diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index 93ac21a6..cac80af0 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -31,6 +31,7 @@ pub mod dryrun; mod gate; pub mod keys; pub mod listen; +pub mod plugins; pub mod pricing; pub mod remote; pub mod replay; @@ -138,6 +139,7 @@ pub fn router(state: ControlState) -> Router { .merge(resources::router()) .merge(routes::router()) .merge(security::router()) + .merge(plugins::router()) .merge(pricing::router()) .merge(chatgpt::router()) .merge(zai::router()) @@ -763,11 +765,23 @@ async fn history( (Some(from), Some(to)) => g.db().security_of_requests(from, to).map_err(records)?, _ => Default::default(), }; + // 插件改过的那些,同样一次取完 + let changed = match ( + rows.iter().map(|r| r.id).min(), + rows.iter().map(|r| r.id).max(), + ) { + (Some(from), Some(to)) => g + .db() + .changed_by_plugins_between(from, to) + .map_err(records)?, + _ => Default::default(), + }; Ok(Json( rows.into_iter() .map(|r| { let sec = security.remove(&r.id).unwrap_or_default(); - history_row(r, sec) + let by_plugins = changed.contains(&r.id); + history_row(r, sec, by_plugins) }) .collect(), )) @@ -799,14 +813,21 @@ async fn history_search( }; // 这一页的安全记录,徽标靠它(和 `GET /history` 一样) let ids: Vec = found.rows.iter().map(|r| r.id).collect(); - let mut security = store.lock().await.db().security_of(&ids).map_err(records)?; + let (mut security, changed) = { + let g = store.lock().await; + ( + g.db().security_of(&ids).map_err(records)?, + g.db().changed_by_plugins(&ids).map_err(records)?, + ) + }; Ok(Json(tw_api::HistorySearchPage { rows: found .rows .into_iter() .map(|r| { let sec = security.remove(&r.id).unwrap_or_default(); - history_row(r, sec) + let by_plugins = changed.contains(&r.id); + history_row(r, sec, by_plugins) }) .collect(), hits: found.hits, @@ -1028,10 +1049,30 @@ async fn request_detail( .map_err(records)? .remove(&id) .unwrap_or_default(); + // 插件的每一次运行。**还在跑的请求也有**:请求钩子在发往上游之前就记下了 + let plugins: Vec = g + .db() + .plugin_runs(id) + .map_err(records)? + .into_iter() + .map(|r| tw_api::PluginRunView { + plugin_id: r.plugin_id, + plugin_name: r.plugin_name, + hook: r.hook, + outcome: r.outcome, + error: r.error, + cpu_us: r.cpu_us.max(0) as u64, + }) + .collect(); + let by_plugins = plugins + .iter() + .any(|p| p.outcome == tw_api::PluginOutcome::Changed); let detail = tw_api::RequestDetail { request_body: body(tw_store::Which::Request), + request_after_plugins: body(tw_store::Which::AfterPlugins), response_body: body(tw_store::Which::Response), - row: history_row(row, security), + plugins, + row: history_row(row, security, by_plugins), in_flight, }; Ok(Json(detail)) @@ -1157,7 +1198,9 @@ async fn storage(State(s): State) -> Json { }) } -fn need_store(s: &ControlState) -> Result<&Arc>, Fail> { +pub(crate) fn need_store( + s: &ControlState, +) -> Result<&Arc>, Fail> { s.store.as_ref().ok_or_else(|| { fail( StatusCode::SERVICE_UNAVAILABLE, @@ -1173,6 +1216,7 @@ fn need_store(s: &ControlState) -> Result<&Arc, + plugin_changed: bool, ) -> tw_api::HistoryRow { tw_api::HistoryRow { id: r.id, @@ -1219,6 +1263,7 @@ fn history_row( key_masked: r.key_masked, session_log_bytes: r.session_log_bytes, security, + plugin_changed, } } diff --git a/crates/tw-control/src/plugins.rs b/crates/tw-control/src/plugins.rs new file mode 100644 index 00000000..7744563e --- /dev/null +++ b/crates/tw-control/src/plugins.rs @@ -0,0 +1,954 @@ +//! 脚本插件:装、改、换源码、批准、排顺序、删、试跑、日志。 +//! +//! # 文件和配置是一件事的两半 +//! +//! 插件文件(`plugins/.js`)和它的底稿(`plugins/.approved/.js`)由这里写, +//! 批准的哈希在配置里。**先写文件、再写配置**:配置一落盘,网关就照它重读文件、比 +//! 哈希(不变式 I9)。配置没写成(版本对不上、校验没过),刚写的文件按写之前的样子 +//! 还原 —— 不留下一个和配置对不上的插件文件。整个过程攥着 `Plugins::edits`,目录 +//! 监听不会落在两半之间。 +//! +//! # 三个端点网页调不了 +//! +//! 装(`CreatePlugin`)、换源码(`ReplacePluginSource`)、批准改过的文件 +//! (`ApprovePluginFile`)**不在桌面端网页的 `call` 白名单里**(不变式 I12):这三件事 +//! 要在系统的确认框里点头,那一步在桌面端的 Rust 里,它自己再编一遍源码,把名字、 +//! 权限和哈希摆给人看。所以这里不假设调用方看过什么:源码在这里再编一遍,批准时 +//! 磁盘上的文件得正好是调用方看过的那一份(哈希核对)。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use axum::Json; +use axum::extract::{Path as UrlPath, Query, State}; +use axum::http::StatusCode; +use serde_yaml_ng::{Mapping, Value}; +use tw_api::{SettingValue, ep}; +use tw_config::edit::{self, EditError}; +use tw_config::history::Origin; +use tw_gateway::plugin::load::{read_capped, sha256_hex}; +use tw_gateway::plugin::{Active, Broken, LoadError, Manifest}; +use tw_types::{Msg, msg}; + +use crate::contract::RouterExt; +use crate::{ApplyError, ControlState, Fail, apply_fail, fail, internal}; + +pub fn router() -> axum::Router { + axum::Router::new() + .at(ep::Plugins, list) + .at(ep::PluginInspect, inspect) + .at(ep::CreatePlugin, create) + .at(ep::ReorderPlugins, reorder) + .at(ep::UpdatePlugin, update) + .at(ep::DeletePlugin, delete) + .at(ep::ReplacePluginSource, replace_source) + .at(ep::PluginSourceDiff, source_diff) + .at(ep::ApprovePluginFile, approve) + .at(ep::TrialPlugin, trial) + .at(ep::PluginLogs, logs) +} + +/// 配置文件所在的目录:插件文件的路径相对它。**远程 core 也一样** —— 文件在 core +/// 那台机器上,由 core 写 +pub fn dir_of(config: &Path) -> PathBuf { + match config.parent() { + Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), + _ => PathBuf::from("."), + } +} + +fn config_dir(s: &ControlState) -> PathBuf { + dir_of(s.config_path()) +} + +fn not_found(id: &str) -> Fail { + fail( + StatusCode::NOT_FOUND, + msg!( + "control.plugin.not_found", plugin = id => + "There is no plugin `{plugin}`." + ), + ) +} + +/// 配置里没有这个插件(在 `transform` 里,配置是磁盘上那一份) +fn missing(id: &str) -> ApplyError { + ApplyError::Edit(EditError::NotFound { + what: "plugin", + name: id.to_string(), + }) +} + +// ---------------------------------------------------------------- 读 + +async fn list(State(s): State) -> Json> { + let rt = s.gateway.runtime(); + Json( + rt.plugins + .all() + .iter() + .filter_map(|a| { + let entry = rt.config.plugins.iter().find(|p| p.id == a.id)?; + Some(view(a, entry)) + }) + .collect(), + ) +} + +fn view(a: &Active, entry: &tw_config::Plugin) -> tw_api::PluginView { + let m = a.manifest.as_ref(); + // 交给插件的那一份(默认值补齐了);插件跑不了、没算出来时就照配置里写的说 + let settings = if a.settings.is_empty() { + entry + .settings + .iter() + .filter_map(|(k, v)| Some((k.clone(), from_yaml(v)?))) + .collect() + } else { + a.settings + .iter() + .filter_map(|(k, v)| Some((k.clone(), from_json(v)?))) + .collect() + }; + tw_api::PluginView { + id: a.id.clone(), + name: a.name.clone(), + description: m.and_then(|m| m.description.clone()), + enabled: a.enabled, + on_error: a.on_error, + permissions: a.permissions.clone(), + scope: scope_view(&entry.scope), + reply_mode: a.reply_mode, + settings_schema: m.map(schema).unwrap_or_default(), + settings, + sha256: entry.sha256.clone(), + status: status_of(a), + stats: a.stats.view(), + } +} + +/// 状态:跑不了的原因优先于「停用」—— 停用着的插件文件被人改了,也要看得出来 +fn status_of(a: &Active) -> tw_api::PluginStatus { + match a.broken() { + Some(Broken::Changed) => tw_api::PluginStatus::Changed, + Some(Broken::Error(m)) => tw_api::PluginStatus::Error { message: m.clone() }, + None if a.enabled => tw_api::PluginStatus::Ok, + None => tw_api::PluginStatus::Disabled, + } +} + +fn scope_view(s: &tw_config::PluginScope) -> tw_api::PluginScope { + tw_api::PluginScope { + clients: s.clients.clone(), + models: s.models.clone(), + upstreams: s.upstreams.clone(), + } +} + +fn schema(m: &Manifest) -> Vec { + m.settings + .iter() + .map(|s| tw_api::SettingSpecView { + key: s.key.clone(), + kind: s.kind, + label: s.label.clone(), + default: from_json(&s.default).unwrap_or(SettingValue::String(String::new())), + }) + .collect() +} + +fn manifest_view(m: &Manifest) -> tw_api::ManifestView { + tw_api::ManifestView { + name: m.name.clone(), + description: m.description.clone(), + permissions: m.permissions.clone(), + scope: tw_api::PluginScope { + clients: m.scope.clients.clone(), + models: m.scope.models.clone(), + upstreams: m.scope.upstreams.clone(), + }, + reply_mode: m.reply_mode, + settings_schema: schema(m), + hooks: tw_api::PluginHooks { + request: m.hooks.request, + reply_text: m.hooks.reply_text, + tool_call: m.hooks.tool_call, + }, + } +} + +fn from_json(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Bool(b) => Some(SettingValue::Bool(*b)), + serde_json::Value::Number(n) => n.as_f64().map(SettingValue::Number), + serde_json::Value::String(s) => Some(SettingValue::String(s.clone())), + _ => None, + } +} + +fn from_yaml(v: &Value) -> Option { + match v { + Value::Bool(b) => Some(SettingValue::Bool(*b)), + Value::Number(n) => n.as_f64().map(SettingValue::Number), + Value::String(s) => Some(SettingValue::String(s.clone())), + _ => None, + } +} + +/// 写进配置的样子。**整数写成整数**:界面交来的数字一律是 f64,`3` 不该变成 `3.0` +fn to_yaml(v: &SettingValue) -> Value { + match v { + SettingValue::Bool(b) => Value::Bool(*b), + SettingValue::Number(f) if f.fract() == 0.0 && f.abs() < 9.0e15 => { + Value::Number((*f as i64).into()) + } + SettingValue::Number(f) => Value::Number((*f).into()), + SettingValue::String(s) => Value::String(s.clone()), + } +} + +/// 一份源码编出来的样子。**不留任何东西** +async fn inspect( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let sha256 = sha256_hex(req.source.as_bytes()); + Ok(Json( + match load(&s, req.source.into_bytes(), false).await? { + Ok(m) => tw_api::PluginInspection { + manifest: Some(manifest_view(&m)), + sha256, + error: None, + }, + Err(e) => tw_api::PluginInspection { + manifest: None, + sha256, + error: Some(load_error(&e)), + }, + }, + )) +} + +fn load_error(e: &LoadError) -> tw_api::PluginLoadError { + let (line, column) = match e { + LoadError::Syntax { line, column, .. } => (*line, *column), + _ => (None, None), + }; + tw_api::PluginLoadError { + message: e.msg(), + line, + column, + } +} + +/// 编一遍:**放到阻塞线程上**,编译是实打实的 CPU 活。`keep`:结果留进缓存(马上 +/// 要装上的那一份),否则什么都不留(只是看看) +async fn load( + s: &ControlState, + source: Vec, + keep: bool, +) -> Result, Fail> { + let plugins = s.gateway.plugins.clone(); + tokio::task::spawn_blocking(move || { + let compiled = if keep { + plugins.prepare(&source) + } else { + plugins.inspect(&source) + }; + compiled.map(|host| host.manifest().clone()) + }) + .await + .map_err(internal) +} + +/// 编一遍,编不成就拒绝这次写入(装、换源码、批准都要编得成) +async fn load_or_refuse(s: &ControlState, source: Vec) -> Result { + load(s, source, true) + .await? + .map_err(|e| fail(StatusCode::BAD_REQUEST, e.msg())) +} + +async fn source_diff( + State(s): State, + UrlPath(id): UrlPath, +) -> Result, Fail> { + let rt = s.gateway.runtime(); + let p = rt + .config + .plugins + .iter() + .find(|p| p.id == id) + .ok_or_else(|| not_found(&id))?; + let dir = config_dir(&s); + // 底稿只在它就是批准的那一份时给:被人动过的不能冒充「批准过的」 + let approved = read_capped(&tw_config::plugins::approved_path(&dir, &id)) + .ok() + .filter(|b| sha256_hex(b) == p.sha256) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default(); + let (current, current_sha256) = match read_capped(&p.path_in(&dir)) { + Ok(b) => ( + Some(String::from_utf8_lossy(&b).into_owned()), + Some(sha256_hex(&b)), + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (None, None), + Err(e) => return Err(unreadable(&p.file, e)), + }; + Ok(Json(tw_api::PluginSourceView { + approved, + approved_sha256: p.sha256.clone(), + current, + current_sha256, + })) +} + +fn unreadable(file: &str, e: std::io::Error) -> Fail { + fail( + StatusCode::INTERNAL_SERVER_ERROR, + msg!( + "control.plugin.unreadable", file = file, detail = e => + "The plugin file {file} cannot be read: {detail}" + ), + ) +} + +async fn logs( + State(s): State, + UrlPath(id): UrlPath, +) -> Result>, Fail> { + let rt = s.gateway.runtime(); + let a = rt.plugins.get(&id).ok_or_else(|| not_found(&id))?; + Ok(Json(a.logs.lines())) +} + +// ---------------------------------------------------------------- 写 + +/// 配置里的一条。字段的顺序就是写进文件的顺序 +fn entry( + id: &str, + sha256: &str, + enabled: bool, + on_error: tw_api::OnError, + scope: &tw_api::PluginScope, + settings: &BTreeMap, +) -> Mapping { + let mut m = Mapping::new(); + m.insert("id".into(), id.into()); + m.insert("file".into(), tw_config::Plugin::file_for(id).into()); + m.insert("sha256".into(), sha256.into()); + m.insert("enabled".into(), enabled.into()); + m.insert("on_error".into(), on_error.slug().into()); + let mut sc = Mapping::new(); + for (key, list) in [ + ("clients", &scope.clients), + ("models", &scope.models), + ("upstreams", &scope.upstreams), + ] { + if !list.is_empty() { + sc.insert( + key.into(), + Value::Sequence(list.iter().map(|x| Value::from(x.trim())).collect()), + ); + } + } + if !sc.is_empty() { + m.insert("scope".into(), Value::Mapping(sc)); + } + if !settings.is_empty() { + m.insert( + "settings".into(), + Value::Mapping( + settings + .iter() + .map(|(k, v)| (Value::from(k.as_str()), to_yaml(v))) + .collect(), + ), + ); + } + m +} + +/// 范围里不能有空着的一项(和配置校验同一条)。**写文件之前查** +fn check_scope(scope: &tw_api::PluginScope) -> Result<(), Fail> { + let blank = scope + .clients + .iter() + .chain(&scope.models) + .chain(&scope.upstreams) + .any(|x| x.trim().is_empty()); + if blank { + return Err(fail( + StatusCode::BAD_REQUEST, + msg!( + "control.plugin.blank_pattern" => + "A scope entry is empty. Remove it, or write a name or a pattern with *." + ), + )); + } + Ok(()) +} + +/// 交上来的设置对着 manifest 查:插件没声明的键、类型不对的值都拒绝;没给的补上默认 +/// 值 —— **配置里每个设置都写明**。和网关加载时同一套判据 +fn settings_for( + m: &Manifest, + given: &BTreeMap, +) -> Result, Fail> { + let all = tw_gateway::plugin::load::settings_of(m, given) + .map_err(|why| fail(StatusCode::BAD_REQUEST, why))?; + Ok(all + .iter() + .filter_map(|(k, v)| Some((k.clone(), from_json(v)?))) + .collect()) +} + +/// 换了一份源码之后的设置:**还对得上的留着**(键还在、类型没变),对不上的丢掉, +/// 新声明的补默认值。换源码、批准改过的文件都不该因为设置而让插件跑不了 +fn reconcile(m: &Manifest, old: &BTreeMap) -> BTreeMap { + m.settings + .iter() + .map(|spec| { + let kept = old + .get(&spec.key) + .and_then(from_yaml) + .filter(|v| v.kind() == spec.kind); + let v = kept + .or_else(|| from_json(&spec.default)) + .unwrap_or(SettingValue::String(String::new())); + (spec.key.clone(), v) + }) + .collect() +} + +/// 新插件的 id:给了就查写法和重名,没给就从名字生成一个不重的 +fn new_id(given: Option<&str>, name: &str, taken: &[&str]) -> Result { + if let Some(id) = given { + if !tw_config::plugins::valid_id(id) { + return Err(fail( + StatusCode::BAD_REQUEST, + msg!( + "control.plugin.bad_id", plugin = id, max = tw_config::plugins::ID_MAX => + "`{plugin}` is not a valid plugin id: lowercase letters, digits and hyphens, 1 \ + to {max} characters." + ), + )); + } + if tw_config::plugins::RESERVED_IDS.contains(&id) { + return Err(fail( + StatusCode::BAD_REQUEST, + msg!( + "control.plugin.reserved_id", plugin = id => + "`{plugin}` cannot be a plugin id: the control plane uses that word itself." + ), + )); + } + if taken.contains(&id) { + return Err(fail( + StatusCode::CONFLICT, + msg!( + "control.plugin.id_taken", plugin = id => + "There is already a plugin `{plugin}`." + ), + )); + } + return Ok(id.to_string()); + } + Ok(id_from_name(name, taken)) +} + +/// 从名字生成 id:小写的字母和数字,其余的并成一个连字符。**名字里一个拉丁字母都 +/// 没有(「附加日期」)就叫 `plugin`**;重了就在后面加 `-2`、`-3`…… +fn id_from_name(name: &str, taken: &[&str]) -> String { + let mut base = String::new(); + for c in name.chars() { + if c.is_ascii_alphanumeric() { + base.push(c.to_ascii_lowercase()); + } else if !base.is_empty() && !base.ends_with('-') { + base.push('-'); + } + } + base.truncate(tw_config::plugins::ID_MAX); + let mut base = base.trim_end_matches('-').to_string(); + if base.is_empty() || tw_config::plugins::RESERVED_IDS.contains(&base.as_str()) { + base = if base.is_empty() { + "plugin".into() + } else { + format!("{base}-plugin") + }; + } + let free = |id: &str| !taken.contains(&id); + if free(&base) { + return base; + } + (2..) + .map(|n| { + let tail = format!("-{n}"); + let mut head = base.clone(); + head.truncate(tw_config::plugins::ID_MAX - tail.len()); + format!("{}{tail}", head.trim_end_matches('-')) + }) + .find(|id| free(id)) + .unwrap_or(base) +} + +/// 写之前的样子,配置没写成时照它还原。 +struct Undo(Vec<(PathBuf, Option>)>); + +impl Undo { + fn restore(self) { + for (path, before) in self.0 { + let r = match before { + Some(b) => std::fs::write(&path, b), + None => std::fs::remove_file(&path), + }; + if let Err(e) = r { + tracing::warn!(path = %path.display(), "a plugin file could not be put back: {e}"); + } + } + } +} + +fn write_failed(path: &Path, e: impl std::fmt::Display) -> Fail { + fail( + StatusCode::INTERNAL_SERVER_ERROR, + msg!( + "control.plugin.write_failed", path = path.display(), detail = e => + "{path} could not be written: {detail}" + ), + ) +} + +/// 写一组文件:**目录只给自己(0700),文件 0600**,原子替换。返回写之前的样子 +fn write_files(dir: &Path, files: &[(PathBuf, &[u8])]) -> Result { + let plugins = tw_config::plugins::dir_in(dir); + let approved = plugins.join(tw_config::plugins::APPROVED_DIR); + for d in [&plugins, &approved] { + tw_config::private_dir::create(d).map_err(|e| write_failed(d, e))?; + } + let mut undo = Undo(Vec::new()); + for (path, bytes) in files { + let before = match std::fs::read(path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + undo.restore(); + return Err(write_failed(path, e)); + } + }; + if let Err(e) = write_private(path, bytes) { + undo.restore(); + return Err(write_failed(path, e)); + } + undo.0.push((path.clone(), before)); + } + Ok(undo) +} + +/// 原子地写一个只给自己看的文件:**建的那一刻就是 0600**,写完再改名过去。写的是 +/// 原样的字节 —— 批准的那一份要和哈希过的一字不差 +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let tmp = path.with_extension(format!("tmp{}", std::process::id())); + let _ = std::fs::remove_file(&tmp); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let written = opts.open(&tmp).and_then(|mut f| { + f.write_all(bytes)?; + f.sync_all() + }); + if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, path)) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) +} + +/// 先写文件、再写配置;配置没写成就把文件还原 +async fn with_files( + s: &ControlState, + files: &[(PathBuf, &[u8])], + base_version: Option<&str>, + f: F, +) -> Result +where + F: FnOnce(&str, &tw_config::Config) -> Result, +{ + let undo = write_files(&config_dir(s), files)?; + match s.cfg.transform(base_version, Origin::Ui, f).await { + Ok(version) => Ok(version), + Err(e) => { + undo.restore(); + Err(apply_fail(e)) + } + } +} + +async fn create( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let m = load_or_refuse(&s, req.source.clone().into_bytes()).await?; + check_scope(&req.scope)?; + let settings = settings_for(&m, &req.settings)?; + // **id 在拿到写的那把锁之后再定**:两个同名的插件同时装,后一个看得见前一个 + let _edit = s.gateway.plugins.edits.lock().await; + let id = { + let rt = s.gateway.runtime(); + let taken: Vec<&str> = rt.config.plugins.iter().map(|p| p.id.as_str()).collect(); + new_id(req.id.as_deref(), &m.name, &taken)? + }; + let sha = sha256_hex(req.source.as_bytes()); + let item = entry(&id, &sha, req.enabled, req.on_error, &req.scope, &settings); + let dir = config_dir(&s); + let src = req.source.as_bytes(); + let version = with_files( + &s, + &[ + (tw_config::plugins::file_path(&dir, &id), src), + (tw_config::plugins::approved_path(&dir, &id), src), + ], + req.base_version.as_deref(), + |text, _| Ok(edit::upsert(text, edit::PLUGINS, None, &item)?), + ) + .await?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +async fn update( + State(s): State, + UrlPath(id): UrlPath, + Json(req): Json, +) -> Result, Fail> { + check_scope(&req.scope)?; + // 设置对着它此刻的 manifest 查。读不出 manifest(文件变了、底稿也没了)就照交来的 + // 写:加载时还会再查一遍 + let manifest = s + .gateway + .runtime() + .plugins + .get(&id) + .and_then(|a| a.manifest.clone()); + let settings = match &manifest { + Some(m) => settings_for(m, &req.settings)?, + None => req.settings.clone(), + }; + let version = s + .cfg + .transform(req.base_version.as_deref(), Origin::Ui, |text, cfg| { + let p = cfg + .plugins + .iter() + .find(|p| p.id == id) + .ok_or_else(|| missing(&id))?; + let item = entry( + &id, + &p.sha256, + req.enabled, + req.on_error, + &req.scope, + &settings, + ); + Ok(edit::upsert(text, edit::PLUGINS, Some(&id), &item)?) + }) + .await + .map_err(apply_fail)?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +async fn replace_source( + State(s): State, + UrlPath(id): UrlPath, + Json(req): Json, +) -> Result, Fail> { + if !s + .gateway + .runtime() + .config + .plugins + .iter() + .any(|p| p.id == id) + { + return Err(not_found(&id)); + } + let m = load_or_refuse(&s, req.source.clone().into_bytes()).await?; + let sha = sha256_hex(req.source.as_bytes()); + let dir = config_dir(&s); + let _edit = s.gateway.plugins.edits.lock().await; + let src = req.source.as_bytes(); + let version = with_files( + &s, + &[ + (tw_config::plugins::file_path(&dir, &id), src), + (tw_config::plugins::approved_path(&dir, &id), src), + ], + req.base_version.as_deref(), + |text, cfg| { + let p = cfg + .plugins + .iter() + .find(|p| p.id == id) + .ok_or_else(|| missing(&id))?; + let item = entry( + &id, + &sha, + p.enabled, + p.on_error.into(), + &scope_view(&p.scope), + &reconcile(&m, &p.settings), + ); + Ok(edit::upsert(text, edit::PLUGINS, Some(&id), &item)?) + }, + ) + .await?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +/// 批准磁盘上改过的文件。**批的是调用方看过的那一份**:读一次,哈希得和交来的一样, +/// 编的、存进底稿的、写进配置的都是这一次读到的字节。 +async fn approve( + State(s): State, + UrlPath(id): UrlPath, + Json(req): Json, +) -> Result, Fail> { + let file = { + let rt = s.gateway.runtime(); + let p = rt + .config + .plugins + .iter() + .find(|p| p.id == id) + .ok_or_else(|| not_found(&id))?; + p.path_in(&config_dir(&s)) + }; + let _edit = s.gateway.plugins.edits.lock().await; + let bytes = match read_capped(&file) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(fail( + StatusCode::CONFLICT, + msg!( + "control.plugin.file_missing", plugin = &id => + "The file of plugin `{plugin}` is gone, so there is nothing to approve. Replace \ + its source or delete it." + ), + )); + } + Err(e) => return Err(unreadable(&file.display().to_string(), e)), + }; + let sha = sha256_hex(&bytes); + if sha != req.sha256 { + return Err(fail( + StatusCode::CONFLICT, + msg!( + "control.plugin.file_moved_on", plugin = &id => + "The file of plugin `{plugin}` changed again after it was reviewed. Review it again." + ), + )); + } + let m = load_or_refuse(&s, bytes.clone()).await?; + let dir = config_dir(&s); + let version = with_files( + &s, + &[(tw_config::plugins::approved_path(&dir, &id), &bytes)], + req.base_version.as_deref(), + |text, cfg| { + let p = cfg + .plugins + .iter() + .find(|p| p.id == id) + .ok_or_else(|| missing(&id))?; + let item = entry( + &id, + &sha, + p.enabled, + p.on_error.into(), + &scope_view(&p.scope), + &reconcile(&m, &p.settings), + ); + Ok(edit::upsert(text, edit::PLUGINS, Some(&id), &item)?) + }, + ) + .await?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +/// 删掉:**先改配置、再删文件**。配置没改成,文件一个都不动;文件删不掉只记一行 +/// 日志 —— 配置里已经没有它了,留下的文件不会再被读 +async fn delete( + State(s): State, + UrlPath(id): UrlPath, + Query(q): Query, +) -> Result, Fail> { + let _edit = s.gateway.plugins.edits.lock().await; + let version = s + .cfg + .transform(q.base_version.as_deref(), Origin::Ui, |text, _| { + Ok(edit::remove(text, edit::PLUGINS, &id)?) + }) + .await + .map_err(apply_fail)?; + let dir = config_dir(&s); + for path in [ + tw_config::plugins::file_path(&dir, &id), + tw_config::plugins::approved_path(&dir, &id), + ] { + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => tracing::warn!(path = %path.display(), "a deleted plugin's file stays: {e}"), + } + } + Ok(Json(tw_api::ConfigWritten { version })) +} + +async fn reorder( + State(s): State, + Json(req): Json, +) -> Result, Fail> { + let version = s + .cfg + .transform(req.base_version.as_deref(), Origin::Ui, |text, cfg| { + let mut now: Vec<&str> = cfg.plugins.iter().map(|p| p.id.as_str()).collect(); + let mut want: Vec<&str> = req.ids.iter().map(String::as_str).collect(); + now.sort_unstable(); + want.sort_unstable(); + if now != want { + return Err(ApplyError::Invalid(msg!( + "control.plugin.order" => + "The new order has to name every plugin exactly once." + ))); + } + Ok(edit::reorder(text, edit::PLUGINS, &req.ids)?) + }) + .await + .map_err(apply_fail)?; + Ok(Json(tw_api::ConfigWritten { version })) +} + +// ---------------------------------------------------------------- 试跑 + +/// 拿一条记下的请求试跑。**不连上游**,也不进插件的计数和日志。 +async fn trial( + State(s): State, + UrlPath(id): UrlPath, + Json(req): Json, +) -> Result, Fail> { + let active = s + .gateway + .runtime() + .plugins + .get(&id) + .cloned() + .ok_or_else(|| not_found(&id))?; + let store = crate::need_store(&s)?; + let (row, request, reply) = { + let g = store.lock().await; + let row = g.db().get(req.request_id).map_err(crate::records)?.ok_or_else(|| { + fail( + StatusCode::NOT_FOUND, + msg!("control.request_not_found", id = req.request_id => "There is no request {id}."), + ) + })?; + let request = g.blobs().get(row.at_ms, row.id, tw_store::Which::Request); + let reply = g.blobs().get(row.at_ms, row.id, tw_store::Which::Response); + (row, request, reply) + }; + // 跑不了的插件不试:改过的代码不跑(I9),加载不了的也跑不了 + if let Some(b) = active.broken() { + return Ok(Json(refused(match b { + Broken::Changed => msg!( + "control.plugin.trial_changed", plugin = &active.name => + "The file of plugin `{plugin}` changed and has not been approved, so it cannot be \ + tried." + ), + Broken::Error(m) => m.clone(), + }))); + } + Ok(Json(run_trial(&active, &row, request, reply))) +} + +fn refused(why: Msg) -> tw_api::PluginTrialResult { + tw_api::PluginTrialResult { + request: None, + reply: None, + logs: Vec::new(), + error: Some(why), + } +} + +/// 试跑本身在数据面那一侧(视图、写回都在那里)。**还没接上**:在那之前说一句做不了 +fn run_trial( + _active: &Active, + _row: &tw_store::RequestRow, + _request: Option>, + _reply: Option>, +) -> tw_api::PluginTrialResult { + refused(msg!( + "control.plugin.trial_unavailable" => + "Trial runs are not available in this build yet." + )) +} + +// ---------------------------------------------------------------- 监听 + +/// 盯着 `plugins/` 目录:**插件文件一动,就把插件重读一遍**(重读文件、重算哈希)。 +/// 文件和批准的不一样了,那个插件马上停用、说一声。 +/// +/// 目录不在就先建出来(只给自己,0700):盯不住一个不存在的目录。返回的 `Watch` +/// 要留着,扔掉就不盯了。 +pub fn spawn_watcher( + gateway: tw_gateway::AppState, + config: &Path, +) -> Result { + let dir = tw_config::plugins::dir_in(&dir_of(config)); + if let Err(e) = tw_config::private_dir::create(&dir) { + tracing::warn!(dir = %dir.display(), "the plugin directory could not be created: {e}"); + } + let (w, mut rx) = tw_watch::watch( + std::slice::from_ref(&dir), + tw_config::watch::DEBOUNCE, + |p| p.extension().is_some_and(|x| x == "js"), + )?; + tokio::spawn(async move { + while rx.recv().await.is_some() { + // 控制面正在写插件文件和配置时等它写完:两半之间的样子不作数 + let _edit = gateway.plugins.edits.lock().await; + let gw = gateway.clone(); + // 重读要读文件、可能还要编译,不占异步线程 + let _ = tokio::task::spawn_blocking(move || gw.reload_plugins()).await; + } + }); + Ok(w) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_come_from_names_and_do_not_collide() { + assert_eq!(id_from_name("Add Date!", &[]), "add-date"); + assert_eq!(id_from_name("附加日期", &[]), "plugin"); + assert_eq!(id_from_name("附加日期", &["plugin"]), "plugin-2"); + assert_eq!( + id_from_name("附加日期", &["plugin", "plugin-2"]), + "plugin-3" + ); + assert_eq!(id_from_name("Order", &[]), "order-plugin"); + let long = "x".repeat(60); + let id = id_from_name(&long, &[&"x".repeat(40)]); + assert!(id.len() <= 40 && id.ends_with("-2"), "{id}"); + assert!(tw_config::plugins::valid_id(&id)); + } + + #[test] + fn whole_numbers_stay_whole_in_the_file() { + assert_eq!(to_yaml(&SettingValue::Number(3.0)), Value::from(3)); + assert_eq!(to_yaml(&SettingValue::Number(0.5)), Value::from(0.5)); + } +} diff --git a/crates/tw-control/src/security.rs b/crates/tw-control/src/security.rs index 53dfc39a..550fae99 100644 --- a/crates/tw-control/src/security.rs +++ b/crates/tw-control/src/security.rs @@ -78,14 +78,17 @@ impl GuardExt for Guard { Guard::Redact => Ok(edit::Section { path: &["security", "redact", "custom"], what: "redaction rule", + key: "name", }), Guard::InspectTools => Ok(edit::Section { path: &["security", "inspect_tools", "custom"], what: "tool-call rule", + key: "name", }), Guard::Content => Ok(edit::Section { path: &["security", "content", "custom"], what: "content rule", + key: "name", }), Guard::HiddenText | Guard::OutputLimit => Err(fail( StatusCode::BAD_REQUEST, diff --git a/crates/tw-control/tests/plugins.rs b/crates/tw-control/tests/plugins.rs new file mode 100644 index 00000000..e1395403 --- /dev/null +++ b/crates/tw-control/tests/plugins.rs @@ -0,0 +1,949 @@ +//! 脚本插件的管理面:装、改、换源码、文件被改了、批准、排顺序、删、日志、记录。 +//! +//! 断言落在**磁盘上**:插件文件和底稿写没写、写的是不是那一份字节、配置里那一条长 +//! 什么样 —— 网关照着这些重读插件,哈希对不上就不跑(不变式 I9)。引擎是假的 +//! (`tw_gateway::plugin::fake`):它照约定的写法读出 manifest,不跑 JavaScript。 + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use serde_json::{Value, json}; +use tower::ServiceExt; +use tw_control::{ConfigManager, ControlState}; +use tw_gateway::plugin::fake::{FakeEngine, source}; + +const BASE: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +# 默认那把 +clients: + - name: default + key: tw-aaaa +"; + +struct Bed { + _tmp: tempfile::TempDir, + /// 配置文件所在的目录 + dir: PathBuf, + gw: tw_gateway::AppState, + store: Arc>, + app: axum::Router, +} + +impl Bed { + fn config(&self) -> String { + std::fs::read_to_string(self.dir.join("config.yaml")).unwrap() + } + fn parsed(&self) -> tw_config::Config { + tw_config::try_parse(&self.config()).unwrap() + } + fn file(&self, id: &str) -> PathBuf { + tw_config::plugins::file_path(&self.dir, id) + } + fn approved(&self, id: &str) -> PathBuf { + tw_config::plugins::approved_path(&self.dir, id) + } + async fn version(&self) -> String { + let (_, v) = call(&self.app, "GET", "/overview", None).await; + v["config_version"].as_str().unwrap().to_string() + } + async fn plugins(&self) -> Vec { + let (st, v) = call(&self.app, "GET", "/plugins", None).await; + assert_eq!(st, StatusCode::OK, "{v}"); + v.as_array().unwrap().clone() + } + async fn plugin(&self, id: &str) -> Value { + self.plugins() + .await + .into_iter() + .find(|p| p["id"] == id) + .unwrap_or_else(|| panic!("no plugin {id}")) + } + /// 装一个,返回 id + async fn install(&self, src: &str, extra: Value) -> String { + let mut body = json!({ + "source": src, + "enabled": true, + "on_error": "reject", + "scope": { "clients": [], "models": [], "upstreams": [] }, + "settings": {}, + "base_version": self.version().await, + }); + for (k, v) in extra.as_object().unwrap() { + body[k] = v.clone(); + } + let (st, v) = call(&self.app, "POST", "/plugins", Some(body)).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let cfg = self.parsed(); + cfg.plugins.last().unwrap().id.clone() + } +} + +/// 一张床:配置文件在 `dir`(相对临时目录的一段路径)里 +fn bed_in(sub: &str) -> Bed { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(sub); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("config.yaml"); + std::fs::write(&p, BASE).unwrap(); + let db = tw_store::Db::open(&dir.join("data.db")).unwrap(); + let rec = tw_store::Recorder::new( + db, + tw_store::Blobs::new(dir.join("blobs")), + tw_pricing::shared(tw_pricing::PriceBook::builtin().unwrap()), + ); + let store = Arc::new(tokio::sync::Mutex::new(rec)); + let gw = tw_gateway::AppState::new(tw_config::try_parse(BASE).unwrap()).unwrap(); + gw.set_plugin_engine(Arc::new(FakeEngine)); + let bus = gw.bus.clone(); + let state = ControlState { + shutdown: Default::default(), + remote: Default::default(), + cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), + gateway: gw.clone(), + store: Some(store.clone()), + started: std::time::Instant::now(), + price_updater: Default::default(), + chatgpt: Default::default(), + zai: Default::default(), + }; + Bed { + _tmp: tmp, + dir, + gw, + store, + app: tw_control::router(state), + } +} + +fn bed() -> Bed { + bed_in("home") +} + +async fn call( + app: &axum::Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri); + if body.is_some() { + req = req.header("content-type", "application/json"); + } + let r = app + .clone() + .oneshot( + req.body(Body::from(body.map(|b| b.to_string()).unwrap_or_default())) + .unwrap(), + ) + .await + .unwrap(); + let st = r.status(); + let b = axum::body::to_bytes(r.into_body(), 1 << 22).await.unwrap(); + (st, serde_json::from_slice(&b).unwrap_or(Value::Null)) +} + +fn add_date() -> String { + source( + json!({"name": "附加日期", "api": 1, "description": "在系统提示里写上今天的日期", + "permissions": ["system"], "match": {"clients": ["claude-code"]}, + "settings": {"note": {"type": "string", "label": "附加内容", "default": "今天"}, + "days": {"type": "number", "label": "天数", "default": 1}}}), + &["onRequest"], + ) +} + +fn shout() -> String { + source( + json!({"name": "Shout", "api": 1, "permissions": ["reply.text"]}), + &["onReplyText"], + ) +} + +fn sha(s: &str) -> String { + tw_gateway::plugin::load::sha256_hex(s.as_bytes()) +} + +fn files_in(dir: &Path) -> Vec { + let mut out: Vec = std::fs::read_dir(dir) + .map(|rd| { + rd.flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default(); + out.sort(); + out +} + +#[tokio::test] +async fn inspecting_a_source_says_what_it_is_and_leaves_nothing_behind() { + let b = bed(); + let src = add_date(); + let (st, v) = call( + &b.app, + "POST", + "/plugins/inspect", + Some(json!({"source": src})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(v["sha256"], sha(&src)); + assert!(v["error"].is_null(), "{v}"); + let m = &v["manifest"]; + assert_eq!(m["name"], "附加日期"); + assert_eq!(m["permissions"], json!(["system"])); + assert_eq!(m["scope"]["clients"], json!(["claude-code"])); + assert_eq!( + m["hooks"], + json!({"request": true, "reply_text": false, "tool_call": false}) + ); + assert_eq!(m["settings_schema"][0]["key"], "days"); + assert_eq!(m["settings_schema"][0]["kind"], "number"); + assert_eq!(m["settings_schema"][0]["default"], json!(1.0)); + + let bad = format!("{src}// @@syntax@@\n"); + let (st, v) = call( + &b.app, + "POST", + "/plugins/inspect", + Some(json!({"source": bad})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(v["manifest"].is_null()); + assert_eq!(v["error"]["message"]["code"], "gw.plugin.syntax_at"); + assert_eq!(v["error"]["line"], 3); + assert_eq!(v["error"]["column"], 1); + + // 什么都没留下 + assert_eq!(b.config(), BASE); + assert!(files_in(&b.dir.join("plugins")).is_empty()); +} + +#[tokio::test] +async fn installing_writes_the_file_its_approved_copy_and_one_entry() { + let b = bed(); + let src = add_date(); + let id = b + .install( + &src, + json!({"scope": {"clients": ["claude-code"], "models": [], "upstreams": []}, + "settings": {"note": "明天"}}), + ) + .await; + // 名字里没有拉丁字母 + assert_eq!(id, "plugin"); + assert_eq!(std::fs::read_to_string(b.file(&id)).unwrap(), src); + assert_eq!(std::fs::read_to_string(b.approved(&id)).unwrap(), src); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for p in [b.file(&id), b.approved(&id)] { + let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "{}", p.display()); + } + let mode = std::fs::metadata(b.dir.join("plugins")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + } + + let cfg = b.parsed(); + let p = &cfg.plugins[0]; + assert_eq!(p.file, "plugins/plugin.js"); + assert_eq!(p.sha256, sha(&src)); + assert!(p.enabled); + assert_eq!(p.on_error, tw_config::PluginOnError::Reject); + assert_eq!(p.scope.clients, ["claude-code"]); + // 设置每一项都写明:给了的照写,没给的写默认值;整数写成整数 + assert_eq!(p.settings["note"], serde_yaml_ng::Value::from("明天")); + assert_eq!(p.settings["days"], serde_yaml_ng::Value::from(1)); + assert!(b.config().contains(" days: 1\n"), "{}", b.config()); + // 注释还在 + assert!(b.config().contains("# 默认那把")); + + let v = b.plugin(&id).await; + assert_eq!(v["status"], json!({"kind": "ok"})); + assert_eq!(v["name"], "附加日期"); + assert_eq!(v["description"], "在系统提示里写上今天的日期"); + assert_eq!(v["sha256"], sha(&src)); + assert_eq!(v["settings"], json!({"note": "明天", "days": 1.0})); + assert_eq!(v["stats"]["calls"], 0); + // 网关手里的那一份能跑 + let rt = b.gw.runtime(); + assert!(rt.plugins.get(&id).unwrap().ready().is_some()); +} + +#[tokio::test] +async fn an_id_is_checked_and_a_second_plugin_of_the_same_name_gets_its_own() { + let b = bed(); + let first = b.install(&shout(), json!({})).await; + let second = b.install(&shout(), json!({})).await; + assert_eq!((first.as_str(), second.as_str()), ("shout", "shout-2")); + + for (id, code) in [ + ("Bad_Id", "control.plugin.bad_id"), + ("order", "control.plugin.reserved_id"), + ("shout", "control.plugin.id_taken"), + ] { + let (st, v) = call( + &b.app, + "POST", + "/plugins", + Some( + json!({"source": shout(), "id": id, "enabled": true, "on_error": "skip", + "scope": {"clients": [], "models": [], "upstreams": []}, "settings": {}}), + ), + ) + .await; + assert!(st.is_client_error(), "{id}: {st} {v}"); + assert_eq!(v["code"], code, "{id}"); + } +} + +/// 同名的两个同时装:后一个看得见前一个,各得各的 id,文件互不覆盖 +#[tokio::test] +async fn two_plugins_of_one_name_installed_at_once_get_their_own_ids() { + let b = bed(); + let body = json!({"source": shout(), "enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, "settings": {}}); + let (one, two) = tokio::join!( + call(&b.app, "POST", "/plugins", Some(body.clone())), + call(&b.app, "POST", "/plugins", Some(body)), + ); + assert_eq!( + (one.0, two.0), + (StatusCode::OK, StatusCode::OK), + "{} {}", + one.1, + two.1 + ); + let mut ids: Vec = b.parsed().plugins.into_iter().map(|p| p.id).collect(); + ids.sort(); + assert_eq!(ids, ["shout", "shout-2"]); + assert!(b.file("shout").exists() && b.file("shout-2").exists()); +} + +/// 装之前编一遍:编不成、设置不对的都不装,**一个文件都不写** +#[tokio::test] +async fn a_plugin_that_does_not_load_or_has_wrong_settings_is_not_installed() { + let b = bed(); + let body = |src: String, settings: Value| { + json!({"source": src, "enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, "settings": settings}) + }; + let (st, v) = call( + &b.app, + "POST", + "/plugins", + Some(body(format!("{}// @@syntax@@\n", add_date()), json!({}))), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "gw.plugin.syntax_at"); + let (st, v) = call( + &b.app, + "POST", + "/plugins", + Some(body(add_date(), json!({"days": "x"}))), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "gw.plugin.setting_type"); + let (st, v) = call( + &b.app, + "POST", + "/plugins", + Some(body(add_date(), json!({"colour": "red"}))), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "gw.plugin.setting_unknown"); + assert_eq!(b.config(), BASE); + assert!(files_in(&b.dir.join("plugins")).is_empty()); +} + +/// 配置没写成(版本对不上),刚写的文件还原:不留一个和配置对不上的插件文件 +#[tokio::test] +async fn a_stale_write_puts_the_files_back() { + let b = bed(); + let (st, v) = call( + &b.app, + "POST", + "/plugins", + Some( + json!({"source": shout(), "enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, "settings": {}, + "base_version": "not-this-one"}), + ), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{v}"); + assert_eq!(b.config(), BASE); + assert!(!b.file("shout").exists()); + assert!(!b.approved("shout").exists()); + + // 换源码同理:旧的那一份原样回来 + let id = b.install(&shout(), json!({})).await; + let newer = shout().replace("Shout", "Louder"); + let (st, _) = call( + &b.app, + "PUT", + &format!("/plugins/{id}/source"), + Some(json!({"source": newer, "base_version": "not-this-one"})), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + assert_eq!(std::fs::read_to_string(b.file(&id)).unwrap(), shout()); + assert_eq!(std::fs::read_to_string(b.approved(&id)).unwrap(), shout()); +} + +#[tokio::test] +async fn replacing_the_source_rewrites_the_file_the_copy_and_the_hash_and_keeps_fitting_settings() { + let b = bed(); + let id = b + .install( + &add_date(), + json!({"settings": {"note": "明天", "days": 3}}), + ) + .await; + // 新的一版:`days` 改成了字符串,`note` 没变,多了一个 `loud` + let newer = source( + json!({"name": "附加日期", "api": 1, "permissions": ["system"], + "settings": {"note": {"type": "string", "label": "附加内容", "default": ""}, + "days": {"type": "string", "label": "天数", "default": "1"}, + "loud": {"type": "boolean", "label": "大声", "default": true}}}), + &["onRequest"], + ); + let (st, v) = call( + &b.app, + "PUT", + &format!("/plugins/{id}/source"), + Some(json!({"source": newer, "base_version": b.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(std::fs::read_to_string(b.file(&id)).unwrap(), newer); + assert_eq!(std::fs::read_to_string(b.approved(&id)).unwrap(), newer); + let cfg = b.parsed(); + let p = &cfg.plugins[0]; + assert_eq!(p.sha256, sha(&newer)); + assert_eq!(p.settings["note"], serde_yaml_ng::Value::from("明天")); + assert_eq!(p.settings["days"], serde_yaml_ng::Value::from("1")); + assert_eq!(p.settings["loud"], serde_yaml_ng::Value::from(true)); + assert_eq!(b.plugin(&id).await["status"], json!({"kind": "ok"})); + + let (st, _) = call( + &b.app, + "PUT", + "/plugins/nobody/source", + Some(json!({"source": newer})), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); +} + +/// I9:磁盘上的文件被改了,插件停用、说一声;看过改动、批准了才回来 +#[tokio::test] +async fn a_file_edited_on_disk_stops_the_plugin_until_the_change_is_approved() { + let b = bed(); + let id = b + .install(&add_date(), json!({"settings": {"days": 2}})) + .await; + let mut events = b.gw.bus.subscribe(); + + let edited = format!("{}// 加了一行\n", add_date()); + std::fs::write(b.file(&id), &edited).unwrap(); + b.gw.reload_plugins(); + + let v = b.plugin(&id).await; + assert_eq!(v["status"], json!({"kind": "changed"})); + // 批准过的那一份照样显示 + assert_eq!(v["name"], "附加日期"); + assert!(b.gw.runtime().plugins.get(&id).unwrap().ready().is_none()); + match events.try_recv().expect("no plugin_failed") { + tw_api::Event::PluginFailed { + plugin_id, + request_id, + message, + .. + } => { + assert_eq!(plugin_id, id); + assert_eq!(request_id, None); + assert_eq!(message.code, "gw.plugin.file_changed"); + } + other => panic!("{other:?}"), + } + + let (st, diff) = call(&b.app, "GET", &format!("/plugins/{id}/source"), None).await; + assert_eq!(st, StatusCode::OK, "{diff}"); + assert_eq!(diff["approved"], add_date()); + assert_eq!(diff["approved_sha256"], sha(&add_date())); + assert_eq!(diff["current"], edited); + assert_eq!(diff["current_sha256"], sha(&edited)); + + // 看过之后又被改了一次:不批 + let again = format!("{edited}// 又一行\n"); + std::fs::write(b.file(&id), &again).unwrap(); + let (st, v) = call( + &b.app, + "POST", + &format!("/plugins/{id}/approve"), + Some(json!({"sha256": sha(&edited), "base_version": b.version().await})), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{v}"); + assert_eq!(v["code"], "control.plugin.file_moved_on"); + + let (st, v) = call( + &b.app, + "POST", + &format!("/plugins/{id}/approve"), + Some(json!({"sha256": sha(&again), "base_version": b.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(b.parsed().plugins[0].sha256, sha(&again)); + assert_eq!(std::fs::read_to_string(b.approved(&id)).unwrap(), again); + // 文件本身没被动过;设置照旧 + assert_eq!(std::fs::read_to_string(b.file(&id)).unwrap(), again); + assert_eq!( + b.parsed().plugins[0].settings["days"], + serde_yaml_ng::Value::from(2) + ); + assert_eq!(b.plugin(&id).await["status"], json!({"kind": "ok"})); +} + +/// 文件被删掉也是「变了」;批准不了一个不存在的文件 +#[tokio::test] +async fn a_deleted_file_is_changed_and_cannot_be_approved() { + let b = bed(); + let id = b.install(&shout(), json!({})).await; + std::fs::remove_file(b.file(&id)).unwrap(); + b.gw.reload_plugins(); + assert_eq!(b.plugin(&id).await["status"], json!({"kind": "changed"})); + let (_, diff) = call(&b.app, "GET", &format!("/plugins/{id}/source"), None).await; + assert!( + diff["current"].is_null() && diff["current_sha256"].is_null(), + "{diff}" + ); + let (st, v) = call( + &b.app, + "POST", + &format!("/plugins/{id}/approve"), + Some(json!({"sha256": sha(&shout())})), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{v}"); + assert_eq!(v["code"], "control.plugin.file_missing"); +} + +/// 底稿也被人动过:说不出批准的是什么,就不拿它冒充 +#[tokio::test] +async fn a_tampered_approved_copy_is_not_shown_as_approved() { + let b = bed(); + let id = b.install(&shout(), json!({})).await; + std::fs::write(b.approved(&id), "something else").unwrap(); + let (_, diff) = call(&b.app, "GET", &format!("/plugins/{id}/source"), None).await; + assert_eq!(diff["approved"], ""); + assert_eq!(diff["current"], shout()); +} + +/// 目录监听:插件文件一动,几秒之内就停用,不等下一次改配置 +#[tokio::test] +async fn the_watcher_notices_an_edited_plugin_within_seconds() { + let b = bed(); + let id = b.install(&shout(), json!({})).await; + let _w = tw_control::plugins::spawn_watcher(b.gw.clone(), &b.dir.join("config.yaml")).unwrap(); + std::fs::write(b.file(&id), format!("{}// x\n", shout())).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if b.gw.runtime().plugins.get(&id).unwrap().broken().is_some() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the edited plugin kept running" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!(b.plugin(&id).await["status"], json!({"kind": "changed"})); +} + +#[tokio::test] +async fn updating_changes_the_switches_scope_and_settings_and_nothing_else() { + let b = bed(); + let id = b.install(&add_date(), json!({})).await; + let (st, v) = call( + &b.app, + "PUT", + &format!("/plugins/{id}"), + Some(json!({"enabled": false, "on_error": "skip", + "scope": {"clients": [], "models": ["claude-*"], "upstreams": ["anthropic"]}, + "settings": {"note": "后天", "days": 2.5}, + "base_version": b.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let cfg = b.parsed(); + let p = &cfg.plugins[0]; + assert!(!p.enabled); + assert_eq!(p.on_error, tw_config::PluginOnError::Skip); + assert!(p.scope.clients.is_empty()); + assert_eq!(p.scope.models, ["claude-*"]); + assert_eq!(p.settings["days"], serde_yaml_ng::Value::from(2.5)); + assert_eq!(p.sha256, sha(&add_date())); + let v = b.plugin(&id).await; + assert_eq!(v["status"], json!({"kind": "disabled"})); + assert_eq!(v["on_error"], "skip"); + assert_eq!(v["scope"]["upstreams"], json!(["anthropic"])); + + for (settings, code) in [ + (json!({"days": true}), "gw.plugin.setting_type"), + (json!({"nope": 1}), "gw.plugin.setting_unknown"), + ] { + let (st, v) = call( + &b.app, + "PUT", + &format!("/plugins/{id}"), + Some(json!({"enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, + "settings": settings})), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], code); + } + let (st, v) = call( + &b.app, + "PUT", + &format!("/plugins/{id}"), + Some(json!({"enabled": true, "on_error": "reject", + "scope": {"clients": [" "], "models": [], "upstreams": []}, + "settings": {}})), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "control.plugin.blank_pattern"); + let (st, _) = call( + &b.app, + "PUT", + "/plugins/nobody", + Some(json!({"enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, "settings": {}})), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn reordering_changes_the_run_order_and_needs_every_plugin_once() { + let b = bed(); + let a = b.install(&shout(), json!({"id": "a"})).await; + let c = b.install(&shout(), json!({"id": "c"})).await; + let d = b.install(&add_date(), json!({"id": "d"})).await; + let (st, v) = call( + &b.app, + "PUT", + "/plugins/order", + Some(json!({"ids": [d, a, c], "base_version": b.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let ids: Vec = b.parsed().plugins.into_iter().map(|p| p.id).collect(); + assert_eq!(ids, ["d", "a", "c"]); + let listed: Vec = b + .plugins() + .await + .iter() + .map(|p| p["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(listed, ["d", "a", "c"]); + // 每一项搬过去时整项都在 + assert_eq!(b.parsed().plugins[0].settings.len(), 2); + + for ids in [ + json!(["d", "a"]), + json!(["d", "a", "a"]), + json!(["d", "a", "x"]), + ] { + let (st, v) = call(&b.app, "PUT", "/plugins/order", Some(json!({"ids": ids}))).await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "control.plugin.order"); + } +} + +#[tokio::test] +async fn deleting_removes_the_entry_and_both_files() { + let b = bed(); + let id = b.install(&shout(), json!({})).await; + let (st, v) = call( + &b.app, + "DELETE", + &format!("/plugins/{id}?base_version={}", b.version().await), + None, + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(b.parsed().plugins.is_empty()); + assert!(!b.config().contains("plugins:"), "{}", b.config()); + assert!(!b.file(&id).exists() && !b.approved(&id).exists()); + assert!(b.plugins().await.is_empty()); + let (st, _) = call(&b.app, "DELETE", &format!("/plugins/{id}"), None).await; + assert_eq!(st, StatusCode::NOT_FOUND); +} + +/// 计数和日志:数据面每跑一次报一次,界面从这里取 +#[tokio::test] +async fn runs_show_up_in_the_stats_and_the_logs() { + let b = bed(); + let id = b.install(&add_date(), json!({})).await; + let active = b.gw.runtime().plugins.get(&id).unwrap().clone(); + b.gw.plugin_ran( + 7, + &active, + tw_gateway::plugin::PluginRun { + plugin_id: id.clone(), + plugin_name: active.name.clone(), + hook: tw_api::PluginHook::Request, + outcome: tw_api::PluginOutcome::Changed, + error: None, + cpu_us: 300, + detail: None, + }, + vec![tw_gateway::plugin::LogLine { + level: tw_api::PluginLogLevel::Warn, + text: "not markup".into(), + }], + ); + let v = b.plugin(&id).await; + assert_eq!(v["stats"]["calls"], 1); + assert_eq!(v["stats"]["changed"], 1); + assert_eq!(v["stats"]["avg_cpu_us"], 300); + let (st, logs) = call(&b.app, "GET", &format!("/plugins/{id}/logs"), None).await; + assert_eq!(st, StatusCode::OK); + assert_eq!(logs[0]["request_id"], 7); + assert_eq!(logs[0]["hook"], "request"); + assert_eq!(logs[0]["level"], "warn"); + assert_eq!(logs[0]["text"], "not markup"); + let (st, _) = call(&b.app, "GET", "/plugins/nobody/logs", None).await; + assert_eq!(st, StatusCode::NOT_FOUND); +} + +fn row(id: i64, at_ms: i64) -> tw_store::RequestRow { + tw_store::RequestRow { + session_log_bytes: None, + key_masked: None, + peer: None, + id, + at_ms, + client: "default".into(), + client_hint: Some("claude-code".into()), + session: None, + provider: "anthropic".into(), + model: "claude-sonnet-4-5".into(), + sent_model: "claude-sonnet-4-5".into(), + answered_model: None, + path: "/v1/messages".into(), + status: Some(200), + ttfb_ms: Some(100), + ttft_ms: None, + duration_ms: Some(200), + tokens_per_sec: None, + bytes: Some(10), + input_tokens: Some(50), + output_tokens: Some(20), + cache_read_tokens: None, + cache_write_tokens: None, + input_estimate: None, + cost_micros: Some(1_000), + cost_estimated: false, + error: None, + local: false, + cancelled: false, + routing: None, + billing: tw_api::Billing::PerToken, + cache_saved_micros: None, + price_source: None, + translated: None, + } +} + +fn run_row(request_id: i64, at_ms: i64, outcome: tw_api::PluginOutcome) -> tw_store::PluginRunRow { + tw_store::PluginRunRow { + request_id, + at_ms, + plugin_id: "add-date".into(), + plugin_name: "附加日期".into(), + hook: tw_api::PluginHook::Request, + outcome, + error: None, + cpu_us: 120, + detail: None, + } +} + +/// I10:每一次运行记在那条请求上 —— 详情里列得出来,改过的请求在列表上带徽标, +/// 改过之后的请求体和别的正文一样打着码给出来(落盘前那一道在网关的 +/// `BodyRecord::for_disk`,这里直接写进存储,看的是读出来那一道) +#[tokio::test] +async fn a_request_shows_its_plugin_runs_and_the_body_after_them() { + let b = bed(); + { + let g = b.store.lock().await; + g.db().insert(&row(1, 1_000)).unwrap(); + g.db().insert(&row(2, 2_000)).unwrap(); + g.record_plugin_run(&run_row(1, 1_000, tw_api::PluginOutcome::Changed)); + let mut reply = run_row(1, 1_500, tw_api::PluginOutcome::Error); + reply.hook = tw_api::PluginHook::Reply; + reply.error = Some(tw_types::msg!("gw.plugin.failed" => "The plugin failed.")); + g.record_plugin_run(&reply); + g.record_plugin_run(&run_row(2, 2_000, tw_api::PluginOutcome::Unchanged)); + g.record_body( + 1_000, + 1, + tw_store::Which::Request, + b"{\"system\":\"hi\"}", + 15, + ); + let after = br#"{"system":"hi, today is Friday","key":"sk-ant-api03-USERSOWNKEYAAAAAAAAAAAAAAAAAA"}"#; + g.record_body(1_000, 1, tw_store::Which::AfterPlugins, after, after.len()); + } + let (st, d) = call(&b.app, "GET", "/request/1", None).await; + assert_eq!(st, StatusCode::OK, "{d}"); + let runs = d["plugins"].as_array().unwrap(); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0]["hook"], "request"); + assert_eq!(runs[0]["outcome"], "changed"); + assert_eq!(runs[0]["cpu_us"], 120); + assert_eq!(runs[1]["hook"], "reply"); + assert_eq!(runs[1]["error"]["code"], "gw.plugin.failed"); + assert_eq!(d["row"]["plugin_changed"], true); + let after = d["request_after_plugins"]["text"].as_str().unwrap(); + assert!(after.contains("today is Friday"), "{after}"); + assert!( + !after.contains("USERSOWNKEY"), + "a secret was shown: {after}" + ); + assert_eq!(d["request_after_plugins"]["truncated"], false); + + let (_, d2) = call(&b.app, "GET", "/request/2", None).await; + assert_eq!(d2["row"]["plugin_changed"], false); + assert!(d2["request_after_plugins"].is_null()); + + let (_, list) = call(&b.app, "GET", "/history?limit=10", None).await; + let flags: Vec<(i64, bool)> = list + .as_array() + .unwrap() + .iter() + .map(|r| { + ( + r["id"].as_i64().unwrap(), + r["plugin_changed"].as_bool().unwrap(), + ) + }) + .collect(); + assert_eq!(flags, [(2, false), (1, true)]); + // 搜索翻出来的那一页也带着 + let (_, page) = call( + &b.app, + "POST", + "/history/search", + Some(json!({"limit": 10})), + ) + .await; + let flags: Vec = page["rows"] + .as_array() + .unwrap() + .iter() + .map(|r| r["plugin_changed"].as_bool().unwrap()) + .collect(); + assert_eq!(flags, [false, true]); +} + +#[tokio::test] +async fn a_trial_needs_a_known_plugin_and_a_recorded_request() { + let b = bed(); + let id = b.install(&add_date(), json!({})).await; + let (st, v) = call( + &b.app, + "POST", + &format!("/plugins/{id}/trial"), + Some(json!({"request_id": 9})), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND, "{v}"); + assert_eq!(v["code"], "control.request_not_found"); + b.store.lock().await.db().insert(&row(9, 1_000)).unwrap(); + let (st, v) = call( + &b.app, + "POST", + "/plugins/nobody/trial", + Some(json!({"request_id": 9})), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND, "{v}"); + let (st, v) = call( + &b.app, + "POST", + &format!("/plugins/{id}/trial"), + Some(json!({"request_id": 9})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(v["error"]["code"].is_string(), "{v}"); + assert!(v["logs"].as_array().unwrap().is_empty()); + + // 改过还没批准的代码不试 + std::fs::write(b.file(&id), "changed").unwrap(); + b.gw.reload_plugins(); + let (_, v) = call( + &b.app, + "POST", + &format!("/plugins/{id}/trial"), + Some(json!({"request_id": 9})), + ) + .await; + assert_eq!(v["error"]["code"], "control.plugin.trial_changed"); +} + +/// 远程 core:配置不在默认的地方,插件文件就在那份配置旁边 —— 文件由 core 自己写 +#[tokio::test] +async fn plugin_files_live_next_to_the_configuration_wherever_it_is() { + let b = bed_in("srv/thinkwatch/etc"); + let id = b.install(&shout(), json!({})).await; + let expected = b.dir.join("plugins").join(format!("{id}.js")); + assert!(expected.exists(), "{}", expected.display()); + assert!( + b.dir + .join("plugins/.approved") + .join(format!("{id}.js")) + .exists() + ); + assert_eq!(b.plugin(&id).await["status"], json!({"kind": "ok"})); + assert_eq!( + tw_control::plugins::dir_of(Path::new("config.yaml")), + PathBuf::from(".") + ); +} + +/// 一份手写的配置里插件文件改了(core 不在跑的时候):启动时读到的就是「变了」 +#[tokio::test] +async fn a_plugin_changed_while_core_was_down_starts_out_changed() { + let b = bed(); + let id = b.install(&shout(), json!({})).await; + std::fs::write(b.file(&id), "tampered").unwrap(); + // 重新起一份网关和控制面,读同一份配置 + let cfg = b.parsed(); + let gw = tw_gateway::AppState::new(cfg).unwrap(); + gw.set_plugin_engine(Arc::new(FakeEngine)); + let _mgr = ConfigManager::new(b.dir.join("config.yaml"), gw.clone(), gw.bus.clone()); + let p = gw.runtime().plugins.get(&id).unwrap().clone(); + assert_eq!(p.broken(), Some(&tw_gateway::plugin::Broken::Changed)); +} diff --git a/crates/tw-control/tests/stored_bodies.rs b/crates/tw-control/tests/stored_bodies.rs index 5932e3eb..c1861802 100644 --- a/crates/tw-control/tests/stored_bodies.rs +++ b/crates/tw-control/tests/stored_bodies.rs @@ -104,6 +104,7 @@ async fn world(mode: &str) -> World { let which = match disk.kind { tw_gateway::bodies::BodyKind::Request => tw_store::Which::Request, tw_gateway::bodies::BodyKind::Response => tw_store::Which::Response, + tw_gateway::bodies::BodyKind::AfterPlugins => tw_store::Which::AfterPlugins, }; let stored = tw_store::StoredBody { id: disk.id, diff --git a/crates/tw-gateway/src/bodies.rs b/crates/tw-gateway/src/bodies.rs index 82610968..f83e36c7 100644 --- a/crates/tw-gateway/src/bodies.rs +++ b/crates/tw-gateway/src/bodies.rs @@ -55,6 +55,10 @@ pub const RESPONSE_TAP_MAX: usize = WINDOW; pub enum BodyKind { Request, Response, + /// 插件改过之后的请求体(`Request` 存的是客户端发来的那一份)。**只有插件真的改了 + /// 才存**,挨着 `Request` 放。交来的是插件交回的那一份(占位符还没换回密钥),带着这个 + /// 请求的 [`Redaction`]:落盘前和别的正文一样换掉、打码([`BodyRecord::for_disk`]) + AfterPlugins, } /// 落盘之前怎么处理一份正文。 @@ -435,6 +439,26 @@ mod tests { assert_eq!(tw_secret::mask_body(&stored), stored); } + /// 插件改过的请求体走同一条路:插件交回的占位符原样留着(上游收到的就是它),插件 + /// 自己写进去的、认得出的值打码 + #[test] + fn the_request_after_plugins_is_stored_the_same_way() { + let request = format!(r#"{{"messages":[{{"role":"user","content":"{KEY}"}}]}}"#); + let r = enforced(&request); + let added = "ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + let after = format!( + r#"{{"system":"today is Friday, {added}","messages":[{{"role":"user","content":"<>"}}]}}"# + ); + let stored = written(record(BodyKind::AfterPlugins, &after, r)); + assert!( + stored.contains("\"content\":\"<>\""), + "{stored}" + ); + assert!(stored.contains("today is Friday"), "{stored}"); + assert!(!stored.contains(added), "{stored}"); + serde_json::from_str::(&stored).expect("存下来的还是 JSON"); + } + #[test] fn what_the_rules_miss_is_masked_by_its_shape() { // 网关自己的钥匙(`tw-`)没有一条脱敏规则认:读的时候那一道兜住它,写的时候也一样 diff --git a/crates/tw-gateway/src/plugin/engine.rs b/crates/tw-gateway/src/plugin/engine.rs index f1fd95b8..cf1304d3 100644 --- a/crates/tw-gateway/src/plugin/engine.rs +++ b/crates/tw-gateway/src/plugin/engine.rs @@ -6,9 +6,14 @@ use std::sync::Arc; +use tw_types::{Msg, msg}; + use crate::plugin::host::PluginHost; use crate::plugin::set::Scope; +/// 一个插件文件最多多大。**读文件时也按它截**:再大的文件反正编不了,不必整个读进来 +pub const MAX_SOURCE: usize = 1024 * 1024; + /// 插件文件里 `manifest` 写的东西,加上它导出了哪些钩子。**由运行时读出来、校验过**: /// 权限和钩子对得上、设置项不超过上限,这里拿到的都是合规的。 #[derive(Debug, Clone, PartialEq)] @@ -81,6 +86,42 @@ pub enum LoadError { Engine(String), } +impl LoadError { + /// 给人看的那句话,带码。语法错和 manifest 的原话是运行时的,放在 `detail` 里 + pub fn msg(&self) -> Msg { + match self { + LoadError::TooLarge => msg!( + "gw.plugin.too_large", max = MAX_SOURCE => + "The plugin file is larger than {max} bytes." + ), + LoadError::Syntax { + message, + line: Some(line), + column, + } => msg!( + "gw.plugin.syntax_at", line = line, column = column.unwrap_or(1), detail = message => + "The plugin has a syntax error at line {line}, column {column}: {detail}" + ), + LoadError::Syntax { message, .. } => msg!( + "gw.plugin.syntax", detail = message => + "The plugin has a syntax error: {detail}" + ), + LoadError::Manifest(d) => msg!( + "gw.plugin.manifest", detail = d => + "The plugin's manifest is not valid: {detail}" + ), + LoadError::UnsupportedApi(api) => msg!( + "gw.plugin.api", api = api => + "The plugin is written for plugin API {api}, and only API 1 is supported." + ), + LoadError::Engine(d) => msg!( + "gw.plugin.engine", detail = d => + "The plugin engine cannot load plugins: {detail}" + ), + } + } +} + /// 插件运行时。**一个进程一个**,所有插件共用。 pub trait Engine: Send + Sync { /// 用**正好这些字节**编一个插件(不变式 I9:跑的就是哈希过、比对过的那一份)。 diff --git a/crates/tw-gateway/src/plugin/fake.rs b/crates/tw-gateway/src/plugin/fake.rs new file mode 100644 index 00000000..57a9b834 --- /dev/null +++ b/crates/tw-gateway/src/plugin/fake.rs @@ -0,0 +1,318 @@ +//! **测试用的假引擎**:不跑 JavaScript,只照约定的写法读出 manifest 和导出了哪些钩子。 +//! +//! 管理面的测试(装、换、批准、文件变了)要一个能「编译」的引擎,而真的沙箱编译慢、 +//! 还要 wasm 工具链。假引擎认的源码长这样 —— manifest 是**一行 JSON**: +//! +//! ```text +//! export const manifest = {"name":"附加日期","api":1,"permissions":["system"]}; +//! export function onRequest(req, ctx) {} +//! ``` +//! +//! 校验照插件约定的那几条做(权限和钩子对得上、至少一个钩子、名字长度……),错了 +//! 给 [`LoadError::Manifest`];有一行写着 `@@syntax@@` 的算语法错,行号就是那一行。 + +use std::sync::Arc; + +use sha2::{Digest, Sha256}; + +use crate::plugin::engine::{Engine, Hooks, LoadError, MAX_SOURCE, Manifest, SettingSpec}; +use crate::plugin::host::PluginHost; +use crate::plugin::set::Scope; + +/// 假引擎。 +#[derive(Debug, Default, Clone, Copy)] +pub struct FakeEngine; + +/// 假引擎「编」出来的插件。 +#[derive(Debug)] +pub struct FakeHost { + manifest: Manifest, + sha256: [u8; 32], +} + +impl PluginHost for FakeHost { + fn manifest(&self) -> &Manifest { + &self.manifest + } + fn sha256(&self) -> [u8; 32] { + self.sha256 + } +} + +impl Engine for FakeEngine { + fn load(&self, source: &[u8]) -> Result, LoadError> { + if source.len() > MAX_SOURCE { + return Err(LoadError::TooLarge); + } + let text = std::str::from_utf8(source).map_err(|e| LoadError::Syntax { + message: format!("the file is not UTF-8: {e}"), + line: None, + column: None, + })?; + if let Some((i, _)) = text + .lines() + .enumerate() + .find(|(_, l)| l.contains("@@syntax@@")) + { + return Err(LoadError::Syntax { + message: "Unexpected token".into(), + line: Some(i as u32 + 1), + column: Some(1), + }); + } + let manifest = manifest_of(text)?; + Ok(Arc::new(FakeHost { + manifest, + sha256: Sha256::digest(source).into(), + })) + } +} + +/// 一份假源码:manifest(一行 JSON)加上给定的钩子。 +pub fn source(manifest: serde_json::Value, hooks: &[&str]) -> String { + let mut s = format!("export const manifest = {manifest};\n"); + for h in hooks { + s.push_str(&format!("export function {h}(x, ctx) {{}}\n")); + } + s +} + +fn bad(why: impl Into) -> LoadError { + LoadError::Manifest(why.into()) +} + +fn manifest_of(text: &str) -> Result { + const PREFIX: &str = "export const manifest = "; + let line = text + .lines() + .find_map(|l| l.trim().strip_prefix(PREFIX)) + .ok_or_else(|| bad("the plugin does not export a manifest"))?; + let json = line.trim().trim_end_matches(';'); + let m: serde_json::Value = + serde_json::from_str(json).map_err(|e| bad(format!("the manifest is not valid: {e}")))?; + + let name = m["name"] + .as_str() + .ok_or_else(|| bad("manifest.name is required"))?; + let chars = name.chars().count(); + if !(1..=64).contains(&chars) { + return Err(bad("manifest.name has to be 1 to 64 characters")); + } + let api = m["api"] + .as_u64() + .ok_or_else(|| bad("manifest.api is required"))? as u32; + if api != 1 { + return Err(LoadError::UnsupportedApi(api)); + } + let description = match &m["description"] { + serde_json::Value::Null => None, + serde_json::Value::String(d) if d.chars().count() <= 500 => Some(d.clone()), + _ => { + return Err(bad( + "manifest.description has to be a string of up to 500 characters", + )); + } + }; + + let mut permissions = Vec::new(); + for p in m["permissions"] + .as_array() + .ok_or_else(|| bad("manifest.permissions is required"))? + { + let word = p.as_str().unwrap_or_default(); + let perm = match word { + "system" => tw_api::Permission::System, + "messages" => tw_api::Permission::Messages, + "tools" => tw_api::Permission::Tools, + "params" => tw_api::Permission::Params, + "reply.text" => tw_api::Permission::ReplyText, + "reply.tool_calls" => tw_api::Permission::ReplyToolCalls, + other => return Err(bad(format!("`{other}` is not a permission"))), + }; + if !permissions.contains(&perm) { + permissions.push(perm); + } + } + if permissions.is_empty() { + return Err(bad("manifest.permissions cannot be empty")); + } + permissions.sort_by_key(|p| tw_api::Permission::ALL.iter().position(|x| x == p)); + + let reply_mode = match m["reply"].as_str() { + None | Some("block") => tw_api::ReplyMode::Block, + Some("stream") => tw_api::ReplyMode::Stream, + Some(other) => return Err(bad(format!("`{other}` is not a reply mode"))), + }; + + let hooks = Hooks { + request: text.contains("export function onRequest("), + reply_text: text.contains("export function onReplyText("), + reply_text_end: text.contains("export function onReplyTextEnd("), + tool_call: text.contains("export function onToolCall("), + }; + let request_perm = permissions.iter().any(|p| { + matches!( + p, + tw_api::Permission::System + | tw_api::Permission::Messages + | tw_api::Permission::Tools + | tw_api::Permission::Params + ) + }); + let has = |p| permissions.contains(&p); + if hooks.request != request_perm { + return Err(bad( + "onRequest and the permissions system, messages, tools and params go together", + )); + } + if hooks.reply_text != has(tw_api::Permission::ReplyText) { + return Err(bad("onReplyText and the permission reply.text go together")); + } + if hooks.tool_call != has(tw_api::Permission::ReplyToolCalls) { + return Err(bad( + "onToolCall and the permission reply.tool_calls go together", + )); + } + if hooks.reply_text_end && (reply_mode != tw_api::ReplyMode::Stream || !hooks.reply_text) { + return Err(bad( + "onReplyTextEnd is only used with reply: \"stream\" and onReplyText", + )); + } + + let list = |v: &serde_json::Value| -> Result, LoadError> { + match v { + serde_json::Value::Null => Ok(Vec::new()), + serde_json::Value::Array(a) => a + .iter() + .map(|x| { + x.as_str() + .map(str::to_string) + .ok_or_else(|| bad("a match entry has to be a string")) + }) + .collect(), + _ => Err(bad("a match list has to be a list")), + } + }; + let scope = Scope { + clients: list(&m["match"]["clients"])?, + models: list(&m["match"]["models"])?, + upstreams: list(&m["match"]["upstreams"])?, + }; + + let mut settings = Vec::new(); + if let Some(obj) = m["settings"].as_object() { + if obj.len() > 20 { + return Err(bad("a plugin has at most 20 settings")); + } + for (key, spec) in obj { + let kind = match spec["type"].as_str() { + Some("string") => tw_api::SettingKind::String, + Some("number") => tw_api::SettingKind::Number, + Some("boolean") => tw_api::SettingKind::Boolean, + _ => return Err(bad(format!("setting `{key}` has no valid type"))), + }; + let default = match (&kind, &spec["default"]) { + (tw_api::SettingKind::String, serde_json::Value::Null) => "".into(), + (tw_api::SettingKind::Number, serde_json::Value::Null) => 0.into(), + (tw_api::SettingKind::Boolean, serde_json::Value::Null) => false.into(), + (tw_api::SettingKind::String, v @ serde_json::Value::String(_)) + | (tw_api::SettingKind::Number, v @ serde_json::Value::Number(_)) + | (tw_api::SettingKind::Boolean, v @ serde_json::Value::Bool(_)) => v.clone(), + _ => { + return Err(bad(format!( + "the default of setting `{key}` is not of its type" + ))); + } + }; + settings.push(SettingSpec { + key: key.clone(), + kind, + label: spec["label"].as_str().unwrap_or(key).to_string(), + default, + }); + } + } + + Ok(Manifest { + name: name.to_string(), + api, + description, + permissions, + scope, + reply_mode, + settings, + hooks, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_good_source_loads_with_its_manifest_hooks_and_hash() { + let src = source( + json!({"name": "附加日期", "api": 1, "permissions": ["system"], + "match": {"models": ["claude-*"]}, + "settings": {"note": {"type": "string", "label": "附加内容", "default": "x"}}}), + &["onRequest"], + ); + let host = FakeEngine.load(src.as_bytes()).unwrap(); + let m = host.manifest(); + assert_eq!(m.name, "附加日期"); + assert_eq!(m.permissions, [tw_api::Permission::System]); + assert!(m.hooks.request && !m.hooks.on_reply()); + assert_eq!(m.scope.models, ["claude-*"]); + assert_eq!(m.settings[0].default, json!("x")); + let want: [u8; 32] = Sha256::digest(src.as_bytes()).into(); + assert_eq!(host.sha256(), want); + } + + #[test] + fn permissions_and_hooks_have_to_go_together() { + let only_perm = source( + json!({"name": "n", "api": 1, "permissions": ["system"]}), + &[], + ); + assert!(matches!( + FakeEngine.load(only_perm.as_bytes()), + Err(LoadError::Manifest(_)) + )); + let tool = source( + json!({"name": "n", "api": 1, "permissions": ["reply.text"]}), + &["onToolCall"], + ); + assert!(matches!( + FakeEngine.load(tool.as_bytes()), + Err(LoadError::Manifest(_)) + )); + } + + #[test] + fn a_syntax_marker_is_a_syntax_error_on_its_line() { + let src = format!( + "{}\n// @@syntax@@\n", + source( + json!({"name": "n", "api": 1, "permissions": ["system"]}), + &["onRequest"] + ) + ); + let Err(LoadError::Syntax { line, .. }) = FakeEngine.load(src.as_bytes()) else { + panic!("no syntax error"); + }; + assert_eq!(line, Some(4)); + } + + #[test] + fn another_api_version_is_refused() { + let src = source( + json!({"name": "n", "api": 2, "permissions": ["system"]}), + &["onRequest"], + ); + assert!(matches!( + FakeEngine.load(src.as_bytes()), + Err(LoadError::UnsupportedApi(2)) + )); + } +} diff --git a/crates/tw-gateway/src/plugin/load.rs b/crates/tw-gateway/src/plugin/load.rs new file mode 100644 index 00000000..49465367 --- /dev/null +++ b/crates/tw-gateway/src/plugin/load.rs @@ -0,0 +1,685 @@ +//! 加载:读文件、算哈希、和批准的比、编译。 +//! +//! **不变式 I9:跑的只能是批准过的那一份字节。**文件只读一次,哈希算的就是这一次读到 +//! 的字节,编译的也是这些字节 —— 不是先哈希一遍、再另读一遍去编。哈希和配置里的 +//! `sha256` 对不上(文件改了、没了)就是「文件变了」,不跑。 +//! +//! **一个插件出了问题,只停它自己**:读不了、编不了、设置对不上,都落在那一个插件的 +//! 状态上,配置照样换入(`Runtime::build` 从这里拿不到错误)。 +//! +//! 每次换配置都会把所有插件文件重读一遍、重算哈希 —— 这本身就是「文件变了」的一道 +//! 检查;另有一个盯着 `plugins/` 目录的监听(在控制面),文件一动就单独重载一次插件。 +//! 编译的结果按哈希缓存:同一份字节不编第二遍。 + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; + +use sha2::{Digest, Sha256}; +use tw_types::{Msg, msg}; + +use crate::plugin::engine::{Engine, LoadError, MAX_SOURCE, Manifest}; +use crate::plugin::host::PluginHost; +use crate::plugin::set::{Active, Broken, LogRing, PluginSet, Scope, State, Stats}; + +/// 一份编译结果:编好的插件,或者编不成的原因 +type Compiled = Result, LoadError>; + +/// 插件这件事里**跨重载存活**的部分:运行时、文件在哪儿、每个插件的计数和日志、 +/// 编译结果的缓存,以及运行记录交给谁存。 +pub struct Plugins { + engine: RwLock>, + /// 配置文件所在的目录。插件文件的路径相对它。**由控制面告诉我们**(它知道配置 + /// 文件在哪儿);还不知道时每个插件都加载不了 + dir: RwLock>, + tracks: Mutex>, + compiled: Mutex>, + sink: Mutex>, + /// 改插件文件和改配置是一件事的两半(写文件、写哈希)。**控制面改的时候攥着它**, + /// 目录监听重载插件之前也要拿到它 —— 不然监听可能正好落在两半之间,把一个马上就要 + /// 对上的文件当成「变了」报出去 + pub edits: tokio::sync::Mutex<()>, +} + +/// 一个插件的计数和日志。按 id 挂着,每份插件拿到同一对 +#[derive(Clone, Default)] +struct Track { + stats: Arc, + logs: Arc, +} + +/// 一次运行,交给存储层落库(`plugin_runs` 一行)。 +#[derive(Debug, Clone)] +pub struct RunRecord { + pub request_id: u64, + pub at_ms: u64, + pub run: crate::plugin::PluginRun, +} + +/// 运行记录往哪儿交。`None` 是观测层没起来:只计数、不落库。 +pub type RunSender = tokio::sync::mpsc::Sender; + +/// 通道容量。**一条记录几百字节**,比正文那条通道宽得多:每个请求上每个插件一条, +/// 而丢一条就是请求上少了一次运行的记录(不变式 I10)。满了还是丢 —— 观测不能挡住转发 +pub const RUN_CHANNEL_CAP: usize = 4096; + +impl Plugins { + pub fn new(engine: Arc) -> Self { + Self { + engine: RwLock::new(engine), + dir: RwLock::new(None), + tracks: Mutex::default(), + compiled: Mutex::default(), + sink: Mutex::default(), + edits: tokio::sync::Mutex::new(()), + } + } + + pub fn engine(&self) -> Arc { + self.engine + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// 换一个运行时。**缓存一起清掉**:同一份字节在新运行时上要重新编 + pub fn set_engine(&self, engine: Arc) { + *self.engine.write().unwrap_or_else(PoisonError::into_inner) = engine; + self.compiled + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clear(); + } + + pub fn dir(&self) -> Option { + self.dir + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// 记下配置文件所在的目录。返回它是不是变了 + pub fn set_dir(&self, dir: PathBuf) -> bool { + let mut g = self.dir.write().unwrap_or_else(PoisonError::into_inner); + if g.as_ref() == Some(&dir) { + return false; + } + *g = Some(dir); + true + } + + pub fn set_sink(&self, tx: RunSender) { + *self.sink.lock().unwrap_or_else(PoisonError::into_inner) = Some(tx); + } + + /// 交一条运行记录出去。**满了就丢,绝不等待** + pub(crate) fn offer(&self, rec: RunRecord) { + let tx = self + .sink + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(tx) = tx { + let _ = tx.try_send(rec); + } + } + + /// 编一份源码看看,**不留任何东西**:不进缓存、不碰计数。装之前给人过目用 + pub fn inspect(&self, source: &[u8]) -> Compiled { + self.engine().load(source) + } + + /// 编一份马上要装上(或批准)的源码,**结果留进缓存**:紧接着的那次重载按哈希 + /// 找到它,不在换配置的那一路上再编一遍。没装成的,下一次重载清掉 + pub fn prepare(&self, source: &[u8]) -> Compiled { + let engine = self.engine(); + self.compile(&*engine, Sha256::digest(source).into(), source) + } + + /// 照这份配置建一份插件。**不会失败**:哪个插件有问题,问题落在它自己的状态上。 + pub fn build(&self, config: &tw_config::Config) -> PluginSet { + let dir = self.dir(); + let engine = self.engine(); + let mut used = HashSet::new(); + let mut out = Vec::with_capacity(config.plugins.len()); + for p in &config.plugins { + let track = self.track(&p.id); + let (state, manifest) = match dir.as_deref() { + Some(dir) => self.load_one(dir, p, &*engine, &mut used), + None => (State::Broken(Broken::Error(not_located())), None), + }; + let (state, settings) = match (state, &manifest) { + (state, None) => (state, serde_json::Map::new()), + (state, Some(m)) => match settings_of(m, &p.settings) { + Ok(s) => (state, s), + // 设置对不上:照样显示它(manifest 在),但不跑 + Err(why) => (State::Broken(Broken::Error(why)), serde_json::Map::new()), + }, + }; + let m = manifest.as_ref(); + out.push(Arc::new(Active { + id: p.id.clone(), + name: m.map_or_else(|| p.id.clone(), |m| m.name.clone()), + enabled: p.enabled, + on_error: p.on_error.into(), + scope: scope_of(&p.scope), + permissions: m.map(|m| m.permissions.clone()).unwrap_or_default(), + reply_mode: m.map_or(tw_api::ReplyMode::Block, |m| m.reply_mode), + hooks: m.map(|m| m.hooks).unwrap_or_default(), + settings, + manifest, + state, + stats: track.stats, + logs: track.logs, + })); + } + // 只留这一份还用得着的:编译结果、计数和日志。删掉的插件,它的计数跟着走 + self.compiled + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|k, _| used.contains(k)); + self.tracks + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retain(|id, _| config.plugins.iter().any(|p| &p.id == id)); + PluginSet::new(out) + } + + fn track(&self, id: &str) -> Track { + self.tracks + .lock() + .unwrap_or_else(PoisonError::into_inner) + .entry(id.to_string()) + .or_default() + .clone() + } + + /// 一个插件:它此刻的状态,和能读出来的 manifest。 + fn load_one( + &self, + dir: &Path, + p: &tw_config::Plugin, + engine: &dyn Engine, + used: &mut HashSet<[u8; 32]>, + ) -> (State, Option) { + let path = p.path_in(dir); + let bytes = match read_capped(&path) { + Ok(b) => b, + // 文件没了也是「变了」:批准过的那一份不在原处了 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return ( + State::Broken(Broken::Changed), + self.approved_manifest(dir, p, engine, used), + ); + } + Err(e) => { + return ( + State::Broken(Broken::Error(msg!( + "gw.plugin.unreadable", file = &p.file, detail = e => + "The plugin file {file} cannot be read: {detail}" + ))), + self.approved_manifest(dir, p, engine, used), + ); + } + }; + let sha: [u8; 32] = Sha256::digest(&bytes).into(); + if hex(&sha) != p.sha256 { + return ( + State::Broken(Broken::Changed), + self.approved_manifest(dir, p, engine, used), + ); + } + used.insert(sha); + match self.compile(engine, sha, &bytes) { + Ok(host) => { + let m = host.manifest().clone(); + (State::Ready(host), Some(m)) + } + Err(e) => (State::Broken(Broken::Error(e.msg())), None), + } + } + + /// 文件变了时,批准过的那一份的 manifest —— **只拿来显示**(名字、权限、设置项), + /// 不跑。底稿也不是那一份了(被人动过、没了)就没有。 + fn approved_manifest( + &self, + dir: &Path, + p: &tw_config::Plugin, + engine: &dyn Engine, + used: &mut HashSet<[u8; 32]>, + ) -> Option { + let bytes = read_capped(&tw_config::plugins::approved_path(dir, &p.id)).ok()?; + let sha: [u8; 32] = Sha256::digest(&bytes).into(); + if hex(&sha) != p.sha256 { + return None; + } + used.insert(sha); + let host = self.compile(engine, sha, &bytes).ok()?; + Some(host.manifest().clone()) + } + + fn compile(&self, engine: &dyn Engine, sha: [u8; 32], bytes: &[u8]) -> Compiled { + let mut cache = self.compiled.lock().unwrap_or_else(PoisonError::into_inner); + cache + .entry(sha) + .or_insert_with(|| engine.load(bytes)) + .clone() + } +} + +/// 读一个文件,**最多读到上限多一个字节**:再大的插件反正编不了,哈希也一定对不上 +pub fn read_capped(path: &Path) -> std::io::Result> { + let mut buf = Vec::new(); + std::fs::File::open(path)? + .take(MAX_SOURCE as u64 + 1) + .read_to_end(&mut buf)?; + Ok(buf) +} + +/// SHA-256 的小写十六进制 +pub fn hex(sha: &[u8; 32]) -> String { + sha.iter().fold(String::with_capacity(64), |mut s, b| { + use std::fmt::Write; + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// 一份字节的 SHA-256,小写十六进制 +pub fn sha256_hex(bytes: &[u8]) -> String { + hex(&Sha256::digest(bytes).into()) +} + +fn scope_of(s: &tw_config::PluginScope) -> Scope { + Scope { + clients: s.clients.clone(), + models: s.models.clone(), + upstreams: s.upstreams.clone(), + } +} + +fn not_located() -> Msg { + msg!( + "gw.plugin.not_located" => + "The plugin files cannot be found: the gateway has not been told where its configuration \ + lives." + ) +} + +/// 交给插件的设置:manifest 的默认值,配置里写了的盖上去。**键和类型都要对得上**: +/// 插件没声明的键、类型不对的值,都是错 —— 悄悄丢掉的话,用户改的设置看着在,其实 +/// 不起作用。 +pub fn settings_of( + m: &Manifest, + configured: &BTreeMap, +) -> Result, Msg> { + if let Some(key) = configured + .keys() + .find(|k| !m.settings.iter().any(|s| &s.key == *k)) + { + return Err(msg!( + "gw.plugin.setting_unknown", key = key => + "Setting `{key}` is not one the plugin declares." + )); + } + let mut out = serde_json::Map::new(); + for spec in &m.settings { + let value = match configured.get(&spec.key) { + None => spec.default.clone(), + Some(v) => { + let v = serde_json::to_value(v).unwrap_or(serde_json::Value::Null); + if !fits(spec.kind, &v) { + return Err(msg!( + "gw.plugin.setting_type", key = &spec.key, kind = spec.kind.slug() => + "Setting `{key}` has to be a {kind}." + )); + } + v + } + }; + out.insert(spec.key.clone(), value); + } + Ok(out) +} + +/// 这个值是不是这种设置的类型 +pub fn fits(kind: tw_api::SettingKind, v: &serde_json::Value) -> bool { + matches!( + (kind, v), + (tw_api::SettingKind::String, serde_json::Value::String(_)) + | (tw_api::SettingKind::Number, serde_json::Value::Number(_)) + | (tw_api::SettingKind::Boolean, serde_json::Value::Bool(_)) + ) +} + +/// 换了一份插件之后要说一声的:**启用着的插件刚变成跑不了**(文件变了、加载出错), +/// 或者跑不了的原因变了。一直跑不了的不再说第二遍 —— 每改一次配置都重报一遍,用户 +/// 很快就学会了不看。 +pub fn newly_broken(old: &PluginSet, new: &PluginSet) -> Vec<(Arc, Msg)> { + new.all() + .iter() + .filter(|p| p.enabled) + .filter_map(|p| { + let b = p.broken()?; + let before = old + .get(&p.id) + .filter(|o| o.enabled) + .and_then(|o| o.broken()); + if before == Some(b) { + return None; + } + let why = match b { + Broken::Changed => msg!( + "gw.plugin.file_changed", plugin = &p.name => + "The file of plugin `{plugin}` changed on disk, so it no longer runs. Review \ + the change and approve it in the app." + ), + Broken::Error(m) => m.clone(), + }; + Some((p.clone(), why)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::fake::{FakeEngine, source}; + use serde_json::json; + + fn add_date() -> String { + source( + json!({"name": "附加日期", "api": 1, "permissions": ["system"], + "settings": {"note": {"type": "string", "label": "附加内容", "default": "今天"}, + "days": {"type": "number", "label": "天数", "default": 1}}}), + &["onRequest"], + ) + } + + struct Bed { + dir: tempfile::TempDir, + plugins: Plugins, + } + + impl Bed { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let plugins = Plugins::new(Arc::new(FakeEngine)); + plugins.set_dir(dir.path().to_path_buf()); + Self { dir, plugins } + } + /// 装一个:插件文件、底稿,返回配置里的那一条 + fn install(&self, id: &str, src: &str) -> tw_config::Plugin { + let file = tw_config::plugins::file_path(self.dir.path(), id); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, src).unwrap(); + let approved = tw_config::plugins::approved_path(self.dir.path(), id); + std::fs::create_dir_all(approved.parent().unwrap()).unwrap(); + std::fs::write(&approved, src).unwrap(); + tw_config::Plugin { + id: id.into(), + file: tw_config::Plugin::file_for(id), + sha256: sha256_hex(src.as_bytes()), + enabled: true, + on_error: tw_config::PluginOnError::Reject, + scope: Default::default(), + settings: Default::default(), + } + } + fn build(&self, plugins: Vec) -> PluginSet { + self.plugins.build(&tw_config::Config { + plugins, + ..Default::default() + }) + } + } + + #[test] + fn an_approved_file_loads_ready_with_its_manifest_and_default_settings() { + let bed = Bed::new(); + let set = bed.build(vec![bed.install("add-date", &add_date())]); + let p = set.get("add-date").unwrap(); + assert!(p.ready().is_some(), "{:?}", p.state); + assert_eq!(p.name, "附加日期"); + assert_eq!(p.permissions, [tw_api::Permission::System]); + assert!(p.hooks.request); + assert_eq!(p.settings["note"], json!("今天")); + assert_eq!(p.settings["days"], json!(1)); + } + + /// I9:哈希对不上就不跑 —— 改过的代码一行都不执行,显示的还是批准的那一份 + #[test] + fn a_file_that_no_longer_matches_its_hash_is_changed_and_does_not_run() { + let bed = Bed::new(); + let p = bed.install("add-date", &add_date()); + let file = tw_config::plugins::file_path(bed.dir.path(), "add-date"); + std::fs::write(&file, format!("{}// 一行改动\n", add_date())).unwrap(); + let set = bed.build(vec![p]); + let a = set.get("add-date").unwrap(); + assert_eq!(a.broken(), Some(&Broken::Changed)); + assert!(a.ready().is_none()); + // 批准过的那一份还在:名字、权限照样显示 + assert_eq!(a.name, "附加日期"); + assert_eq!(a.permissions, [tw_api::Permission::System]); + } + + #[test] + fn a_missing_file_is_changed_too() { + let bed = Bed::new(); + let p = bed.install("add-date", &add_date()); + std::fs::remove_file(tw_config::plugins::file_path(bed.dir.path(), "add-date")).unwrap(); + let set = bed.build(vec![p]); + assert_eq!( + set.get("add-date").unwrap().broken(), + Some(&Broken::Changed) + ); + } + + /// 底稿也被人动过:说不出它是什么插件,只剩 id + #[test] + fn a_tampered_approved_copy_is_not_used_even_for_display() { + let bed = Bed::new(); + let p = bed.install("add-date", &add_date()); + let other = add_date().replace("附加日期", "别的"); + std::fs::write( + tw_config::plugins::file_path(bed.dir.path(), "add-date"), + &other, + ) + .unwrap(); + std::fs::write( + tw_config::plugins::approved_path(bed.dir.path(), "add-date"), + &other, + ) + .unwrap(); + let set = bed.build(vec![p]); + let a = set.get("add-date").unwrap(); + assert_eq!(a.broken(), Some(&Broken::Changed)); + assert_eq!(a.name, "add-date"); + assert!(a.manifest.is_none()); + } + + #[test] + fn a_load_error_is_the_plugins_own_and_says_why() { + let bed = Bed::new(); + let src = format!("{}// @@syntax@@\n", add_date()); + let set = bed.build(vec![bed.install("bad", &src)]); + let Some(Broken::Error(m)) = set.get("bad").unwrap().broken() else { + panic!("a syntax error loaded"); + }; + assert_eq!(m.code, "gw.plugin.syntax_at"); + // 两行源码之后的那一行 + assert_eq!(m.arg("line"), "3"); + } + + #[test] + fn without_an_engine_every_plugin_fails_to_load() { + let bed = Bed::new(); + bed.plugins + .set_engine(Arc::new(crate::plugin::Unavailable::default())); + let set = bed.build(vec![bed.install("add-date", &add_date())]); + let Some(Broken::Error(m)) = set.get("add-date").unwrap().broken() else { + panic!("loaded without an engine"); + }; + assert_eq!(m.code, "gw.plugin.engine"); + } + + #[test] + fn without_a_directory_nothing_loads() { + let plugins = Plugins::new(Arc::new(FakeEngine)); + let set = plugins.build(&tw_config::Config { + plugins: vec![tw_config::Plugin { + id: "a".into(), + file: "plugins/a.js".into(), + sha256: "0".repeat(64), + enabled: true, + on_error: Default::default(), + scope: Default::default(), + settings: Default::default(), + }], + ..Default::default() + }); + let Some(Broken::Error(m)) = set.get("a").unwrap().broken() else { + panic!("loaded without knowing where"); + }; + assert_eq!(m.code, "gw.plugin.not_located"); + } + + #[test] + fn settings_have_to_be_declared_and_of_their_type() { + let bed = Bed::new(); + let mut p = bed.install("add-date", &add_date()); + p.settings.insert("note".into(), "明天".into()); + p.settings.insert("days".into(), 3.into()); + let set = bed.build(vec![p.clone()]); + let a = set.get("add-date").unwrap(); + assert!(a.ready().is_some(), "{:?}", a.state); + assert_eq!(a.settings["note"], json!("明天")); + assert_eq!(a.settings["days"], json!(3)); + + let mut wrong = p.clone(); + wrong.settings.insert("days".into(), "three".into()); + let set = bed.build(vec![wrong]); + let Some(Broken::Error(m)) = set.get("add-date").unwrap().broken() else { + panic!("a string ran as a number"); + }; + assert_eq!( + (m.code.as_str(), m.arg("kind")), + ("gw.plugin.setting_type", "number") + ); + + let mut unknown = p; + unknown.settings.insert("colour".into(), "red".into()); + let set = bed.build(vec![unknown]); + let Some(Broken::Error(m)) = set.get("add-date").unwrap().broken() else { + panic!("an undeclared setting ran"); + }; + assert_eq!(m.code, "gw.plugin.setting_unknown"); + } + + /// 计数和日志跨重载:改设置、批准文件不该把「跑了多少次」清零;删掉的插件跟着走 + #[test] + fn stats_survive_a_rebuild_and_leave_with_the_plugin() { + let bed = Bed::new(); + let p = bed.install("add-date", &add_date()); + let first = bed.build(vec![p.clone()]); + first.get("add-date").unwrap().stats.note( + &crate::plugin::PluginRun { + plugin_id: "add-date".into(), + plugin_name: "附加日期".into(), + hook: tw_api::PluginHook::Request, + outcome: tw_api::PluginOutcome::Changed, + error: None, + cpu_us: 10, + detail: None, + }, + 1, + ); + let again = bed.build(vec![p.clone()]); + assert_eq!(again.get("add-date").unwrap().stats.view().calls, 1); + bed.build(Vec::new()); + let back = bed.build(vec![p]); + assert_eq!(back.get("add-date").unwrap().stats.view().calls, 0); + } + + struct Counting(std::sync::atomic::AtomicUsize); + + impl Engine for Counting { + fn load(&self, s: &[u8]) -> Result, LoadError> { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + FakeEngine.load(s) + } + } + + impl Counting { + fn count(&self) -> usize { + self.0.load(std::sync::atomic::Ordering::SeqCst) + } + } + + /// 同一份字节不编第二遍 + #[test] + fn the_same_bytes_are_compiled_once() { + let bed = Bed::new(); + let engine = Arc::new(Counting(Default::default())); + bed.plugins.set_engine(engine.clone()); + let p = bed.install("add-date", &add_date()); + bed.build(vec![p.clone()]); + bed.build(vec![p]); + assert_eq!(engine.count(), 1); + } + + /// 装之前编好的那一份,装上之后的重载直接拿来用;只是看看的不留 + #[test] + fn a_prepared_source_is_not_compiled_again_but_an_inspected_one_is_not_kept() { + let bed = Bed::new(); + let engine = Arc::new(Counting(Default::default())); + bed.plugins.set_engine(engine.clone()); + let src = add_date(); + assert!(bed.plugins.inspect(src.as_bytes()).is_ok()); + assert!(bed.plugins.prepare(src.as_bytes()).is_ok()); + assert_eq!(engine.count(), 2); + bed.build(vec![bed.install("add-date", &src)]); + assert_eq!(engine.count(), 2, "the installed source was compiled again"); + } + + #[test] + fn only_a_new_breakage_is_announced() { + let bed = Bed::new(); + let p = bed.install("add-date", &add_date()); + let ok = bed.build(vec![p.clone()]); + std::fs::write( + tw_config::plugins::file_path(bed.dir.path(), "add-date"), + "changed", + ) + .unwrap(); + let changed = bed.build(vec![p.clone()]); + let said = newly_broken(&ok, &changed); + assert_eq!(said.len(), 1); + assert_eq!(said[0].1.code, "gw.plugin.file_changed"); + assert_eq!(said[0].1.arg("plugin"), "附加日期"); + // 还是变了的样子:不再说 + let still = bed.build(vec![p.clone()]); + assert!(newly_broken(&changed, &still).is_empty()); + // 停用着的不说 + let mut off = p; + off.enabled = false; + let off = bed.build(vec![off]); + assert!(newly_broken(&ok, &off).is_empty()); + } + + /// 一个读不下的大文件:只读到上限多一个字节,哈希对不上,就是变了 + #[test] + fn a_huge_file_is_read_only_up_to_the_limit() { + let bed = Bed::new(); + let p = bed.install("add-date", &add_date()); + let file = tw_config::plugins::file_path(bed.dir.path(), "add-date"); + std::fs::write(&file, vec![b'x'; MAX_SOURCE * 3]).unwrap(); + assert_eq!(read_capped(&file).unwrap().len(), MAX_SOURCE + 1); + let set = bed.build(vec![p]); + assert_eq!( + set.get("add-date").unwrap().broken(), + Some(&Broken::Changed) + ); + } +} diff --git a/crates/tw-gateway/src/plugin/mod.rs b/crates/tw-gateway/src/plugin/mod.rs index 5d67956e..c3cebafd 100644 --- a/crates/tw-gateway/src/plugin/mod.rs +++ b/crates/tw-gateway/src/plugin/mod.rs @@ -12,11 +12,16 @@ //! 顺序。 pub mod engine; +/// 测试用的假引擎(见里面的说明)。**不是给生产用的** +#[doc(hidden)] +pub mod fake; pub mod host; +pub mod load; pub mod set; -pub use engine::{Engine, Hooks, LoadError, Manifest, SettingSpec, Unavailable}; +pub use engine::{Engine, Hooks, LoadError, MAX_SOURCE, Manifest, SettingSpec, Unavailable}; pub use host::PluginHost; +pub use load::{Plugins, RUN_CHANNEL_CAP, RunRecord, RunSender}; pub use set::{Active, Broken, LogLine, LogRing, PluginRun, PluginSet, Scope, State, Stats}; use tw_types::Msg; @@ -25,8 +30,9 @@ impl crate::AppState { /// 记一次插件运行(不变式 I10:每一次运行的结果都记在请求上、界面看得到)。 /// /// 计数、日志进这个插件自己的那一份(跨重载存活,见 [`Stats`]、[`LogRing`]); - /// 出了错的再发一条 `plugin_failed` 给通知用。**数据面每跑完一个插件调一次**, - /// 跳过的(插件没加载起来、设的是出错时跳过)也调:那也是这个请求上发生过的事。 + /// 运行记录交给存储层落在那个请求上(`plugin_runs`);出了错的再发一条 + /// `plugin_failed` 给通知用。**数据面每跑完一个插件调一次**,跳过的(插件没加载 + /// 起来、设的是出错时跳过)也调:那也是这个请求上发生过的事。 pub fn plugin_ran(&self, request_id: u64, active: &Active, run: PluginRun, logs: Vec) { let at_ms = now_ms(); active.stats.note(&run, at_ms); @@ -42,9 +48,25 @@ impl crate::AppState { at_ms, }); } + self.plugins.offer(RunRecord { + request_id, + at_ms, + run, + }); + } + + /// 接上运行记录的去处。**观测层起来之后才调** —— 在那之前只计数、不落库 + pub fn set_plugin_sink(&self, tx: RunSender) { + self.plugins.set_sink(tx); } } +/// 这个进程用的插件运行时。**沙箱还没接上**:在那之前每个插件都「加载不了」, +/// 管得着的请求照它的 `on_error` 处置。 +pub fn default_engine() -> std::sync::Arc { + std::sync::Arc::new(Unavailable::default()) +} + /// 出错却没说为什么。数据面总该给一句,这里只是不让通知空着 fn unknown_failure() -> Msg { tw_types::msg!("gw.plugin.failed" => "The plugin failed.") diff --git a/crates/tw-gateway/src/state.rs b/crates/tw-gateway/src/state.rs index 58946d26..bba33e56 100644 --- a/crates/tw-gateway/src/state.rs +++ b/crates/tw-gateway/src/state.rs @@ -8,7 +8,7 @@ use crate::auth::key_eq; use crate::error::GatewayError; use crate::health::Health; use crate::outbound::{base_client_builder, client_for_provider, proxy_shape}; -use tw_types::msg; +use tw_types::{Msg, msg}; mod credentials; mod glm; @@ -55,9 +55,13 @@ impl Runtime { /// Client,等于把每个上游的连接池连同已经握好的 TLS 一起扔掉 —— /// 改一条路由规则不该让下一个请求多付一次完整的建连。只有代理相关 /// 的字段变了才必须重建,因为代理是绑在 Client 上的。 + /// + /// 插件最后建:**它不会失败**(哪个插件有问题只停它自己,见 [`crate::plugin::load`]), + /// 放在所有可能失败的步骤之后,建出来就一定换得进去。 pub fn build( config: tw_config::Config, previous: Option<&Runtime>, + plugins: &crate::plugin::Plugins, ) -> Result { let mut clients = std::collections::HashMap::new(); for p in &config.providers { @@ -102,6 +106,7 @@ impl Runtime { GatewayError::config(msg!("gw.config.security_rules", detail = e => "{detail}")) })?; let hidden = sec.hidden_text.kinds(); + let plugins = Arc::new(plugins.build(&config)); Ok(Self { engine: Arc::new(config.engine()), config: Arc::new(config), @@ -111,9 +116,24 @@ impl Runtime { tools: Arc::new(tools), content: Arc::new(content), hidden, - plugins: Default::default(), + plugins, }) } + + /// 同一份配置、换一份插件。插件文件变了时走这条:配置没变,别的都不用重建 + fn with_plugins(&self, plugins: crate::plugin::PluginSet) -> Self { + Self { + config: self.config.clone(), + engine: self.engine.clone(), + clients: self.clients.clone(), + allow: self.allow.clone(), + redact: self.redact.clone(), + tools: self.tools.clone(), + content: self.content.clone(), + hidden: self.hidden.clone(), + plugins: Arc::new(plugins), + } + } } #[derive(Clone)] @@ -215,6 +235,13 @@ pub struct AppState { pub affinity: Arc, /// 每段对话里、每一家上游拒过的别家封存的推理(见 [`crate::seal`])。**跨重载存活** pub seals: Arc, + /// 脚本插件里跨重载存活的那一半:运行时、插件文件在哪儿、计数和日志、编译缓存 + /// (见 [`crate::plugin::Plugins`])。跟着配置换的那一半在 `Runtime::plugins` + pub plugins: Arc, + /// 换运行时的那一下。**配置重载和插件重载都要换整份运行时**,各自读旧的、建新的、 + /// 存回去 —— 不排队的话,插件那一路可能拿着换配置之前的那份配置,把刚换进去的 + /// 新配置又换回去 + swap: Arc>, /// Anthropic 流里上游静默多久就补一个 `ping`(见 `relay`)。**测试会把它调短**, /// 否则一条心跳的测试要干等十五秒 pub ping_every: std::time::Duration, @@ -234,7 +261,8 @@ impl AppState { let price_assign = config.price_assign(); let models = Arc::new(crate::models::Directory::default()); models.reconcile(&config); - let rt = Runtime::build(config, None)?; + let plugins = Arc::new(crate::plugin::Plugins::new(crate::plugin::default_engine())); + let rt = Runtime::build(config, None, &plugins)?; let health = Arc::new(Health::new()); health.configure(&rt.config.failover); let state = Self { @@ -273,6 +301,8 @@ impl AppState { sessions: Default::default(), affinity: Default::default(), seals: Default::default(), + plugins, + swap: Default::default(), ping_every: crate::PING_EVERY, ping_for: crate::PING_FOR, }; @@ -378,8 +408,13 @@ impl AppState { /// 三遍了,但运行时对象仍然可能建不起来(比如代理地址 reqwest 不认), /// 而那时旧配置必须原样继续服务。 pub fn reload(&self, config: tw_config::Config) -> Result<(), GatewayError> { + let _swap = self + .swap + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let old = self.rt.load(); - let next = Runtime::build(config, Some(&old))?; + let next = Runtime::build(config, Some(&old), &self.plugins)?; + let broken = crate::plugin::load::newly_broken(&old.plugins, &next.plugins); // 比的是写法不是解析出来的地址:网卡名要问系统,而那是监听那一边的事 let (was, now) = (&old.config.listen.gateway, &next.config.listen.gateway); let relisten = was.bind != now.bind || was.port != now.port; @@ -389,6 +424,7 @@ impl AppState { .rcu(|book| book.with_config(sheets.clone(), assign.clone())); self.health.configure(&next.config.failover); self.rt.store(Arc::new(next)); + self.announce_broken(broken); // 模型汇总马上按新配置重算:删掉、停用的上游的模型必须立刻消失(列表 // 即承诺),改了范围的立刻生效。新加的、地址凭据变了的在后台补问 if self.models.reconcile(&self.config()) { @@ -403,6 +439,52 @@ impl AppState { Ok(()) } + /// 配置没变、插件文件变了:照当前这份配置把插件重新读一遍(重读文件、重算哈希), + /// 换进去。**文件和批准的不一样了就停用它**(「文件变了」),并且说一声。 + pub fn reload_plugins(&self) { + let _swap = self + .swap + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let old = self.rt.load_full(); + if old.config.plugins.is_empty() && old.plugins.is_empty() { + return; + } + let set = self.plugins.build(&old.config); + let broken = crate::plugin::load::newly_broken(&old.plugins, &set); + self.rt.store(Arc::new(old.with_plugins(set))); + self.announce_broken(broken); + } + + /// 告诉网关配置文件在哪个目录:**插件文件的路径相对它**。控制面拿到配置文件的 + /// 路径时调;目录变了就把插件重读一遍 + pub fn set_config_dir(&self, dir: std::path::PathBuf) { + if self.plugins.set_dir(dir) { + self.reload_plugins(); + } + } + + /// 换一个插件运行时(测试接假的引擎),换完把插件重读一遍 + pub fn set_plugin_engine(&self, engine: Arc) { + self.plugins.set_engine(engine); + self.reload_plugins(); + } + + /// 启用着的插件刚变成跑不了:说一声(`plugin_failed`,不挂在请求上) + fn announce_broken(&self, list: Vec<(Arc, Msg)>) { + for (p, message) in list { + tracing::warn!(plugin = %p.id, "a plugin no longer runs: {message}"); + self.bus.emit(tw_api::Event::PluginFailed { + id: self.bus.next_id(), + plugin_id: p.id.clone(), + plugin_name: p.name.clone(), + request_id: None, + message, + at_ms: crate::plugin::now_ms(), + }); + } + } + /// 密钥 → 客户端名字 + 方言。 pub(crate) fn identify( &self, diff --git a/crates/tw-gateway/tests/passthrough.rs b/crates/tw-gateway/tests/passthrough.rs index e5ebb2e1..13d06489 100644 --- a/crates/tw-gateway/tests/passthrough.rs +++ b/crates/tw-gateway/tests/passthrough.rs @@ -413,6 +413,7 @@ async fn a_rule_sends_opus_to_one_upstream_and_everything_else_to_another() { failover: Default::default(), default_route: None, default_key: None, + plugins: Vec::new(), version: 1, listen: Listen::default(), clients: vec![Client { diff --git a/crates/tw-store/src/blobs.rs b/crates/tw-store/src/blobs.rs index 95f472f1..4644a9ee 100644 --- a/crates/tw-store/src/blobs.rs +++ b/crates/tw-store/src/blobs.rs @@ -278,6 +278,8 @@ fn dir_size(p: &Path) -> u64 { pub enum Which { Request, Response, + /// 插件改过之后的请求体,挨着 `{id}.req` 放(`{id}.after-plugins`) + AfterPlugins, } impl Which { @@ -285,6 +287,7 @@ impl Which { match self { Which::Request => "req", Which::Response => "res", + Which::AfterPlugins => "after-plugins", } } } diff --git a/crates/tw-store/src/db.rs b/crates/tw-store/src/db.rs index 9f247eb3..cb3303d0 100644 --- a/crates/tw-store/src/db.rs +++ b/crates/tw-store/src/db.rs @@ -22,7 +22,9 @@ use tw_api::Msg; /// /// **一列 JSON 的样子变了也算**(比如 `routing` 多了必有的字段):旧的那些行 /// 读出来是坏的,而读的一方会把「解不开」当成「没有」。 -const SCHEMA: i64 = 23; +/// +/// 24:插件在每个请求上的运行记录(`plugin_runs`)。 +const SCHEMA: i64 = 24; /// 这一行算不出钱,**因为价目表里没有这个模型**:用量是有的,缺的是单价。 /// @@ -328,6 +330,30 @@ impl Db { ); CREATE INDEX security_events_at ON security_events (at_ms DESC); CREATE INDEX security_events_request ON security_events (request_id); + -- 脚本插件在请求上的每一次运行:请求钩子一次一行,回答钩子一个回答一行。 + -- **跑了没改、出错跳过的也记**:一个请求经过了哪些插件,要说得全 + CREATE TABLE plugin_runs ( + request_id INTEGER NOT NULL, + -- 这个请求上的第几次,从 0 起,按记下的先后 + seq INTEGER NOT NULL, + at_ms INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + -- 当时的名字。插件之后改了名,这一行说的还是当时那个 + plugin_name TEXT NOT NULL, + -- request / reply + hook TEXT NOT NULL, + -- unchanged / changed / rejected / error / skipped + outcome TEXT NOT NULL, + -- 出错、拒绝的原因:正文、码、参数,和 `requests` 的三列一样 + error TEXT, + error_code TEXT, + error_args TEXT, + cpu_us INTEGER NOT NULL, + -- 细节,JSON(回答钩子改了几处之类) + detail TEXT, + PRIMARY KEY (request_id, seq) + ); + CREATE INDEX plugin_runs_at ON plugin_runs (at_ms); PRAGMA user_version = {SCHEMA}; COMMIT;" ))?; @@ -1292,12 +1318,115 @@ impl Db { let _ = self .conn .execute("DELETE FROM security_events WHERE at_ms < ?1", [cutoff_ms]); + // 插件的运行记录同理 + let _ = self + .conn + .execute("DELETE FROM plugin_runs WHERE at_ms < ?1", [cutoff_ms]); Ok(self .conn .execute("DELETE FROM requests WHERE at_ms < ?1", [cutoff_ms])?) } } +/// 一个插件在一个请求上的一次运行,落库的样子(`plugin_runs` 一行,少了 `seq`: +/// 它在写入时按这个请求已有的行数定)。 +#[derive(Debug, Clone, PartialEq)] +pub struct PluginRunRow { + pub request_id: i64, + pub at_ms: i64, + pub plugin_id: String, + pub plugin_name: String, + pub hook: tw_api::PluginHook, + pub outcome: tw_api::PluginOutcome, + pub error: Option, + pub cpu_us: i64, + /// JSON + pub detail: Option, +} + +impl Db { + /// 记一次插件运行。**排在这个请求已有的那些后面**:先记下的先跑 + pub fn insert_plugin_run(&self, r: &PluginRunRow) -> Result<(), DbError> { + self.conn.execute( + "INSERT INTO plugin_runs + (request_id, seq, at_ms, plugin_id, plugin_name, hook, outcome, + error, error_code, error_args, cpu_us, detail) + VALUES (?1, + (SELECT COALESCE(MAX(seq) + 1, 0) FROM plugin_runs WHERE request_id = ?1), + ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + r.request_id, + r.at_ms, + r.plugin_id, + r.plugin_name, + r.hook.slug(), + r.outcome.slug(), + r.error.as_ref().map(|e| e.text.as_str()), + r.error.as_ref().map(|e| e.code.as_str()), + r.error + .as_ref() + .filter(|e| !e.args.is_empty()) + .map(|e| serde_json::to_string(&e.args).unwrap_or_default()), + r.cpu_us, + r.detail, + ], + )?; + Ok(()) + } + + /// 一个请求上的插件运行,按记下的先后。 + pub fn plugin_runs(&self, request_id: i64) -> Result, DbError> { + let mut st = self + .conn + .prepare("SELECT * FROM plugin_runs WHERE request_id = ?1 ORDER BY seq")?; + let rows = st.query_map([request_id], |r| { + Ok(PluginRunRow { + request_id: r.get("request_id")?, + at_ms: r.get("at_ms")?, + plugin_id: r.get("plugin_id")?, + plugin_name: r.get("plugin_name")?, + hook: slug_col(r, "hook", tw_api::PluginHook::from_slug)?, + outcome: slug_col(r, "outcome", tw_api::PluginOutcome::from_slug)?, + error: error_from(r)?, + cpu_us: r.get("cpu_us")?, + detail: r.get("detail")?, + }) + })?; + Ok(rows.collect::, _>>()?) + } + + /// 请求号落在 `[from, to]` 里、被插件改过的那些(流量页的徽标)。一段历史一次取完 + pub fn changed_by_plugins_between( + &self, + from: i64, + to: i64, + ) -> Result, DbError> { + let mut st = self.conn.prepare( + "SELECT DISTINCT request_id FROM plugin_runs + WHERE request_id >= ?1 AND request_id <= ?2 AND outcome = 'changed'", + )?; + let ids = st.query_map(params![from, to], |r| r.get(0))?; + Ok(ids.collect::>()?) + } + + /// 这几条请求里被插件改过的。**按号点名**:搜索翻出来的一页散在整份记录里 + pub fn changed_by_plugins( + &self, + ids: &[i64], + ) -> Result, DbError> { + if ids.is_empty() { + return Ok(Default::default()); + } + let mut st = self.conn.prepare( + "SELECT DISTINCT request_id FROM plugin_runs + WHERE request_id IN (SELECT value FROM json_each(?1)) AND outcome = 'changed'", + )?; + let ids = serde_json::to_string(ids).unwrap_or_default(); + let found = st.query_map([ids], |r| r.get(0))?; + Ok(found.collect::>()?) + } +} + /// 排好序的样本里的第 p 百分位,**最近秩法**。 /// /// 不做线性插值:延迟本来就是毫秒粒度的整数,插出一个「843.7ms」只是 @@ -2017,6 +2146,90 @@ pub(crate) mod tests { assert_eq!(db.count().unwrap(), 6); } + fn plugin_run(request_id: i64, at_ms: i64, outcome: tw_api::PluginOutcome) -> PluginRunRow { + PluginRunRow { + request_id, + at_ms, + plugin_id: format!("p{at_ms}"), + plugin_name: "插件".into(), + hook: tw_api::PluginHook::Request, + outcome, + error: None, + cpu_us: 5, + detail: None, + } + } + + /// 一个请求上的运行按记下的先后排;出错的原因带着码和参数读回来 + #[test] + fn plugin_runs_come_back_in_the_order_they_were_recorded() { + let db = Db::in_memory().unwrap(); + let mut failed = plugin_run(7, 30, tw_api::PluginOutcome::Error); + failed.hook = tw_api::PluginHook::Reply; + failed.error = Some(Msg { + code: "t.cpu".into(), + args: [("ms".to_string(), "200".to_string())].into(), + text: "over 200 ms".into(), + }); + failed.detail = Some("{\"texts\":2}".into()); + db.insert_plugin_run(&plugin_run(7, 10, tw_api::PluginOutcome::Changed)) + .unwrap(); + db.insert_plugin_run(&plugin_run(8, 15, tw_api::PluginOutcome::Unchanged)) + .unwrap(); + db.insert_plugin_run(&failed).unwrap(); + db.insert_plugin_run(&plugin_run(7, 20, tw_api::PluginOutcome::Skipped)) + .unwrap(); + let runs = db.plugin_runs(7).unwrap(); + let ids: Vec<&str> = runs.iter().map(|r| r.plugin_id.as_str()).collect(); + assert_eq!(ids, ["p10", "p30", "p20"]); + assert_eq!(runs[1], failed); + assert_eq!(runs[1].error.as_ref().unwrap().arg("ms"), "200"); + assert!(db.plugin_runs(9).unwrap().is_empty()); + } + + #[test] + fn requests_changed_by_plugins_are_found_by_range_and_by_id() { + let db = Db::in_memory().unwrap(); + db.insert_plugin_run(&plugin_run(1, 10, tw_api::PluginOutcome::Changed)) + .unwrap(); + db.insert_plugin_run(&plugin_run(1, 11, tw_api::PluginOutcome::Error)) + .unwrap(); + db.insert_plugin_run(&plugin_run(2, 20, tw_api::PluginOutcome::Unchanged)) + .unwrap(); + db.insert_plugin_run(&plugin_run(3, 30, tw_api::PluginOutcome::Changed)) + .unwrap(); + let mut got: Vec = db + .changed_by_plugins_between(1, 2) + .unwrap() + .into_iter() + .collect(); + got.sort(); + assert_eq!(got, [1]); + let mut got: Vec = db + .changed_by_plugins(&[2, 3]) + .unwrap() + .into_iter() + .collect(); + got.sort(); + assert_eq!(got, [3]); + assert!(db.changed_by_plugins(&[]).unwrap().is_empty()); + } + + /// 运行记录跟着请求一起过期 + #[test] + fn plugin_runs_are_pruned_with_the_requests() { + let db = Db::in_memory().unwrap(); + db.insert(&row(1, 100)).unwrap(); + db.insert(&row(2, 900)).unwrap(); + db.insert_plugin_run(&plugin_run(1, 100, tw_api::PluginOutcome::Changed)) + .unwrap(); + db.insert_plugin_run(&plugin_run(2, 900, tw_api::PluginOutcome::Changed)) + .unwrap(); + db.prune_before(500).unwrap(); + assert!(db.plugin_runs(1).unwrap().is_empty()); + assert_eq!(db.plugin_runs(2).unwrap().len(), 1); + } + #[test] fn a_session_aggregates_its_turns_and_keeps_the_unpriced_ones_visible() { // **「$1.23」和「$1.23,另有 4 轮没有价格」是两个不同的结论。** diff --git a/crates/tw-store/src/lib.rs b/crates/tw-store/src/lib.rs index dd1dcff7..b48c91fc 100644 --- a/crates/tw-store/src/lib.rs +++ b/crates/tw-store/src/lib.rs @@ -18,7 +18,7 @@ pub mod task; pub mod transcript; pub use blobs::{Blobs, Which}; -pub use db::{Db, DbError, Latency, RequestRow, SecurityEvent, Summary, TokenRate}; +pub use db::{Db, DbError, Latency, PluginRunRow, RequestRow, SecurityEvent, Summary, TokenRate}; pub use recorder::{Recorder, price_source}; pub use task::StoredBody; diff --git a/crates/tw-store/src/recorder.rs b/crates/tw-store/src/recorder.rs index 08130bfc..4e54e357 100644 --- a/crates/tw-store/src/recorder.rs +++ b/crates/tw-store/src/recorder.rs @@ -199,6 +199,13 @@ impl Recorder { .put_with_len(at_ms, id as i64, which, body, original_len); } + /// 记一次插件运行(`plugin_runs` 一行)。**写不进去只记一行日志** + pub fn record_plugin_run(&self, r: &crate::db::PluginRunRow) { + if let Err(e) = self.db.insert_plugin_run(r) { + tracing::debug!("the plugin run could not be recorded: {e}"); + } + } + /// 吃一个事件。 pub fn on_event(&mut self, ev: &Event) { match ev { diff --git a/crates/tw-store/src/task.rs b/crates/tw-store/src/task.rs index d890bd5a..bfd6d2fd 100644 --- a/crates/tw-store/src/task.rs +++ b/crates/tw-store/src/task.rs @@ -28,6 +28,19 @@ pub struct StoredBody { pub original_len: usize, } +/// 把插件的运行记录写进库里,**和正文同一个道理**:数据面只管交出去,写库在这条 +/// 任务上。记录和请求那一行各走各的路,落库不分先后(见 `Db::insert_plugin_run`)。 +pub fn record_plugin_runs( + recorder: Arc>, + mut runs: tokio::sync::mpsc::Receiver, +) { + tokio::spawn(async move { + while let Some(r) = runs.recv().await { + recorder.lock().await.record_plugin_run(&r); + } + }); +} + /// 起来。返回的 handle 给别的地方查历史用 —— **同一个 Recorder**, /// 不是第二个连接:两个连接会让「刚写进去的还查不到」变成可能。 /// diff --git a/crates/tw-yaml/src/edit.rs b/crates/tw-yaml/src/edit.rs index e1cc5f65..80d878f6 100644 --- a/crates/tw-yaml/src/edit.rs +++ b/crates/tw-yaml/src/edit.rs @@ -235,6 +235,78 @@ pub fn replace_item( Ok(out) } +/// 把块式列表里的项重排:新的第 i 项是原来的第 `order[i]` 项。 +/// +/// **每一项整段搬**:连同它自己的缩进、里面的注释、行尾注释。项与项之间的那些行 +/// (空行、写在两项之间、和 `-` 对齐的注释)留在原地 —— 它们说的是那个位置,不是 +/// 哪一项。 +/// +/// 改完核对三件事:项数没变;每一项搬过去之后读回来和原来那一项一模一样;列表之外 +/// 一个节点都没动。 +pub fn reorder(text: &str, seq_path: &[Step], order: &[usize]) -> Result { + let before = nodes(text)?; + let count = count_items(&before, seq_path); + let mut seen = vec![false; count]; + let permutation = order.len() == count + && order + .iter() + .all(|&i| i < count && !std::mem::replace(&mut seen[i], true)); + if !permutation { + return Err(PatchError::SelfCheck(format!( + "the new order of {} does not name each of its {count} entries once", + show(seq_path) + ))); + } + let spans = (0..count) + .map(|i| item_span(text, seq_path, i)) + .collect::, _>>()?; + if spans.windows(2).any(|w| w[0].end > w[1].start) { + return Err(PatchError::NotFound(format!( + "{} (the entries overlap)", + show(seq_path) + ))); + } + let mut out = String::with_capacity(text.len()); + let mut at = 0; + for (slot, span) in spans.iter().enumerate() { + out.push_str(&text[at..span.start]); + out.push_str(&text[spans[order[slot]].clone()]); + at = span.end; + } + out.push_str(&text[at..]); + + let after = nodes(&out).map_err(|e| { + PatchError::SelfCheck(format!( + "the configuration could not be parsed after reordering {}: {e}", + show(seq_path) + )) + })?; + if count_items(&after, seq_path) != count { + return Err(PatchError::SelfCheck(format!( + "the number of entries under {} changed while reordering", + show(seq_path) + ))); + } + fn item<'a>(all: &'a [Node], seq_path: &[Step], i: usize) -> Vec<(Vec, Shape<'a>)> { + let mut p = seq_path.to_vec(); + p.push(Step::Index(i)); + all.iter() + .filter(|n| n.path.starts_with(&p)) + .map(|n| (n.path[p.len()..].to_vec(), shape(&n.kind))) + .collect() + } + for (slot, &from) in order.iter().enumerate() { + if item(&after, seq_path, slot) != item(&before, seq_path, from) { + return Err(PatchError::SelfCheck(format!( + "entry {from} of {} did not arrive intact at position {slot}", + show(seq_path) + ))); + } + } + untouched_outside(&before, &after, seq_path)?; + Ok(out) +} + /// 这个位置上的容器是行内写法(`{…}` / `[…]`)吗。不存在或者不是容器 /// 时是 `false`。 /// @@ -539,6 +611,48 @@ mod tests { const CFG: &str = "version: 1\n# 两家上游\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com # 直连\n key: sk-a\n - name: relay\n base_url: https://relay.example\n key: sk-b\n redact: [api_keys, jwt]\n pricing:\n sheet: 旧\nroutes: []\n"; + const LIST: &str = "version: 1\nplugins:\n # 第一个装的\n - id: a\n sha256: x # 批准过\n\n - id: b\n scope:\n models: [m]\n - id: c\nafter: 1\n"; + + #[test] + fn reordering_moves_whole_entries_and_leaves_the_rest_alone() { + let out = reorder(LIST, &p(&["plugins"]), &[2, 0, 1]).unwrap(); + let v = back(&out); + let ids: Vec<&str> = v["plugins"] + .as_sequence() + .unwrap() + .iter() + .map(|x| x["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, ["c", "a", "b"]); + // 每一项连同它里面的注释一起搬走 + assert!(out.contains(" - id: a\n sha256: x # 批准过"), "{out}"); + assert_eq!(v["plugins"][2]["scope"]["models"][0].as_str(), Some("m")); + // 列表之外不动,两项之间的注释留在原处 + assert!( + out.starts_with("version: 1\nplugins:\n # 第一个装的\n - id: c\n"), + "{out}" + ); + assert!(out.ends_with("after: 1\n"), "{out}"); + } + + #[test] + fn the_same_order_changes_nothing() { + assert_eq!(reorder(LIST, &p(&["plugins"]), &[0, 1, 2]).unwrap(), LIST); + } + + #[test] + fn an_order_that_is_not_a_permutation_is_refused() { + for bad in [&[0, 1][..], &[0, 1, 1], &[0, 1, 3], &[0, 1, 2, 3]] { + assert!( + matches!( + reorder(LIST, &p(&["plugins"]), bad), + Err(PatchError::SelfCheck(_)) + ), + "{bad:?}" + ); + } + } + #[test] fn a_nested_value_is_written_under_a_key_that_did_not_exist() { let out = put( diff --git a/crates/tw-yaml/src/lib.rs b/crates/tw-yaml/src/lib.rs index 3a72f462..5dea8ee0 100644 --- a/crates/tw-yaml/src/lib.rs +++ b/crates/tw-yaml/src/lib.rs @@ -19,7 +19,7 @@ use tw_types::{Msg, msg}; mod edit; mod render; -pub use edit::{Put, is_flow_at, put, remove_key, replace_item}; +pub use edit::{Put, is_flow_at, put, remove_key, reorder, replace_item}; pub use render::{Scalar, render_scalar}; /// 到某个节点的路径。`providers[1].base_url` 写成 diff --git a/docs/config.md b/docs/config.md index 70c62abe..97774c94 100644 --- a/docs/config.md +++ b/docs/config.md @@ -157,6 +157,7 @@ means. | `routes` | list of [`routes[]`](#cfg-routes) | `[]` | Routes. Without any, requests fail over across all upstreams in the order they are declared. | | `default_route` | string | — | The route for keys that do not name one. Unset: the route named `default`, or the built-in failover when there is none. | | `default_key` | string | — | The gateway key for clients that were not given a key of their own. Unset: the key named `default`, or the first key. It cannot be disabled. | +| `plugins` | list of [`plugins[]`](#cfg-plugins) | `[]` | Script plugins, in the order they run. The app installs them; each one's code is a file next to this one. | ### `listen` @@ -995,6 +996,62 @@ routes: default_route: default ``` +### `plugins` + +Script plugins change requests before they reach an upstream and answers +before they reach the client. They run in a sandbox inside core, without +access to files, the network or the real values of secrets. The app installs +them: each plugin's code goes to `plugins/.js` next to this file, a copy +of the approved code to `plugins/.approved/.js`, and the code's SHA-256 +to `sha256`. + +A plugin runs only while its file has exactly the approved hash. When the +file changes on disk or disappears, the plugin stops within seconds and the +app shows the change for review. Until the change is approved, the requests +the plugin covers are refused (`on_error: reject`) or pass without it +(`on_error: skip`). A plugin that does not load is handled the same way. +Neither keeps the rest of the configuration from taking effect. + +Plugins run in the order of this list. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | string | **required** | Lowercase letters, digits and hyphens, 1 to 40 characters; unique. `order` and `inspect` are taken by the control plane. | +| `file` | string | **required** | The plugin's code, relative to this file's directory. It is always `plugins/.js`; the app writes it. | +| `sha256` | string | **required** | SHA-256 of the approved code, 64 lowercase hexadecimal characters. When the file no longer has this hash, the plugin stops running until the change is approved in the app. The approved code is kept in `plugins/.approved/.js`. | +| `enabled` | bool | `true` | Run the plugin. `false` keeps it installed and out of every request. | +| `on_error` | `reject` \| `skip` | `reject` | When the plugin fails on a request, or cannot run because its file changed or does not load: `reject` refuses the requests it covers; `skip` lets them through without it. | +| `scope` | object, [`plugins[].scope`](#cfg-plugins-scope) | — | Which requests the plugin handles. Filled from the plugin's own suggestion when it is installed. | +| `settings` | map of setting → string, number or bool | `{}` | Values for the settings the plugin declares. A setting left out takes the plugin's default; one the plugin does not declare, or of the wrong type, stops the plugin from loading. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `clients` | list of strings | `[]` | Client apps (`claude-code`, `codex`, …), as names or globs. `[]`: every client, including requests whose app is not recognised. | +| `models` | list of strings | `[]` | Models the client asks for, as model ids or globs (`claude-*`). `[]`: every model. | +| `upstreams` | list of strings | `[]` | Upstreams whose answers the plugin handles, by name or glob. It applies to answers only: a request is changed before an upstream is chosen. `[]`: every upstream. | + + +```yaml +plugins: + - id: add-date + file: plugins/add-date.js + sha256: 9f2b6c0e4a1d8f3b7c5e2a9d6f1b4c8e3a7d0f5b2c9e6a1d4f8b3c7e0a5d2f9b + enabled: true + on_error: reject + scope: + clients: [claude-code] + models: ["claude-*"] + settings: + note: Answer in English. +``` + ## Environment variables | Variable | Effect | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index d0e1217e..9551bf55 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -106,6 +106,7 @@ twcore config set /listen/gateway/port 8790 --int | `routes` | 对象列表,见 [`routes[]`](#cfg-routes) | `[]` | 路由。一条都不写时,请求按上游的声明顺序故障转移。 | | `default_route` | 字符串 | — | 未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。 | | `default_key` | 字符串 | — | 没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。 | +| `plugins` | 对象列表,见 [`plugins[]`](#cfg-plugins) | `[]` | 脚本插件,按运行的顺序。由应用安装,每个插件的代码是本文件旁边的一个文件。 | ### `listen` @@ -824,6 +825,52 @@ routes: default_route: default ``` +### `plugins` + +脚本插件在请求发往上游之前改写请求,在回答到达客户端之前改写回答。插件运行在 core 内部的沙箱中,无法访问文件、网络,也看不到密钥的真实值。插件由应用安装:代码写入本文件旁边的 `plugins/.js`,批准过的代码另存一份在 `plugins/.approved/.js`,代码的 SHA-256 写入 `sha256`。 + +只有文件的哈希与批准时一致,插件才会运行。磁盘上的文件被改动或删除后,插件会在几秒内停止运行,应用里会列出改动供审阅。批准之前,插件覆盖的请求会被拒绝(`on_error: reject`),或者跳过这个插件照常发出(`on_error: skip`)。加载失败的插件按同样的方式处理。两种情况都不影响配置其余部分生效。 + +插件按本列表的顺序运行。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `id` | 字符串 | **必填** | 小写字母、数字和连字符,1 到 40 个字符,不能重复。`order` 和 `inspect` 被控制面占用。 | +| `file` | 字符串 | **必填** | 插件的代码,相对本文件所在的目录。只能是 `plugins/.js`,由应用写入。 | +| `sha256` | 字符串 | **必填** | 批准过的代码的 SHA-256,64 个小写十六进制字符。文件的哈希与它不符时插件停止运行,直到在应用里批准这次改动。批准过的代码另存在 `plugins/.approved/.js`。 | +| `enabled` | 布尔 | `true` | 是否运行这个插件。`false`:插件保留,不参与任何请求。 | +| `on_error` | `reject` \| `skip` | `reject` | 插件在请求上出错,或者因文件改动、加载失败而无法运行时:`reject` 拒绝它所覆盖的请求;`skip` 跳过这个插件,请求照常。 | +| `scope` | 对象,见 [`plugins[].scope`](#cfg-plugins-scope) | — | 插件处理哪些请求。安装时按插件自己的建议填写。 | +| `settings` | 设置项 → 字符串、数字或布尔的映射 | `{}` | 插件所声明设置项的值。未写的取插件的默认值;插件未声明的设置项或类型不符的值会使插件无法加载。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `clients` | 字符串列表 | `[]` | 客户端应用(`claude-code`、`codex` 等),写名字或通配。`[]`:所有客户端,包括认不出应用的请求。 | +| `models` | 字符串列表 | `[]` | 客户端请求的模型,写模型 ID 或通配(`claude-*`)。`[]`:所有模型。 | +| `upstreams` | 字符串列表 | `[]` | 插件处理哪些上游的回答,写名字或通配。只作用于回答:请求在选定上游之前就已改写。`[]`:所有上游。 | + + +```yaml +plugins: + - id: add-date + file: plugins/add-date.js + sha256: 9f2b6c0e4a1d8f3b7c5e2a9d6f1b4c8e3a7d0f5b2c9e6a1d4f8b3c7e0a5d2f9b + enabled: true + on_error: reject + scope: + clients: [claude-code] + models: ["claude-*"] + settings: + note: 用中文回答。 +``` + ## 环境变量 | 变量 | 作用 | diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 8e866779..a7187b5d 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -286,6 +286,9 @@ REMOVED=$(diff "$TMP/config.before" "$CFG" | grep -c '^<') MODE=$(mode_of "$THINKWATCH_HOME/data.db" || echo -) [ "$MODE" = "600" ] && ok "data.db 是 0600" || bad "data.db 权限是 $MODE" +# 插件目录起来就在(盯它要它在),**只给自己看**:插件代码和它的底稿都在里面 +MODE=$(mode_of "$THINKWATCH_HOME/plugins" || echo -) +[ "$MODE" = "700" ] && ok "plugins/ 是 0700" || bad "plugins/ 权限是 $MODE,该是 700" # ---------------------------------------------------------------- 数据面 step "数据面" @@ -460,7 +463,7 @@ done for ep in /status /overview /summary /history /latency /latency/provider /storage /quota /security \ /security/events /sessions /diagnostics /config /config/history /models /in-flight /live \ - /upstreams/health; do + /upstreams/health /plugins; do C=$(get "$ep") [ "$C" = "200" ] && ok "GET $ep" || bad "GET $ep 返回 $C" done