From 208422ed17407b70cf59aecb1ab370f87228b41b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:40:42 +0800 Subject: [PATCH 1/4] Default plugins, guarded updates for tool-call plugins, multi-line plugin settings Core now offers the plugins it ships (tw_gateway::plugin::defaults: the six ids from the contract addendum, embedded with include_str!). On startup and after every configuration change the management plane walks them, under the same lock as plugin writes, and records what it offered in plugins/.defaults.json: - an id never offered is added turned off (on_error reject, scope from the manifest, settings at their defaults, the shipped hash), or only recorded when a user plugin already has that id; - an offered default whose file and approved hash are still the offered version is updated to a newer shipped version, keeping enabled, on_error, scope and still-declared settings, and turned off when the new version asks for permissions the old one did not have; - a default the user deleted is never added again, and one the user changed is left alone. All the changes of one pass go into one configuration write with the new history origin `defaults`. A failure stays with that one plugin: it is logged, announced once as plugin_failed, and retried after the next change. It never fails startup or a reload. Safe mode does not seed. UpdatePlugin, which the webview can call, now refuses to turn on a plugin that can change tool calls in replies (reply_tool_calls, or permissions that cannot be read), or to change its settings or scope, with control.plugin.needs_confirmation (403). Disabling, on_error, reordering and deleting are unchanged. The same body goes to the new UpdatePluginConfirmed (PUT /plugins/{id}/confirmed), which the desktop app sends only after a native confirmation and must not whitelist for the webview. The comparison is on effective values: unwritten settings count as their defaults and scope order does not matter. Comment-preserving config edits now write strings that contain line breaks, tabs or other control characters as one-line double-quoted YAML scalars with escapes, so any value round-trips without changing the document's structure. Line breaks are accepted where a section declares a field multi-line (plugin settings) and refused elsewhere as before. A property test writes arbitrary strings and checks that serde, the configuration loader and tw-yaml read them back exactly and that every other byte of the file stays. Co-Authored-By: Claude Opus 5.5 --- bin/twcore/src/main.rs | 15 +- crates/tw-api/msg-codes.txt | 1 + crates/tw-api/src/ep.rs | 18 +- crates/tw-api/src/lib.rs | 20 +- crates/tw-api/src/ts.rs | 9 +- crates/tw-config/src/edit.rs | 342 +++++++++- crates/tw-config/src/history.rs | 10 +- crates/tw-config/src/wire.rs | 1 + crates/tw-config/tests/written_text.rs | 400 +++++++++++ crates/tw-control/src/config.rs | 23 +- crates/tw-control/src/keys.rs | 1 + crates/tw-control/src/lib.rs | 4 +- crates/tw-control/src/plugins.rs | 114 +++- crates/tw-control/src/plugins/defaults.rs | 451 +++++++++++++ crates/tw-control/src/security.rs | 3 + crates/tw-control/tests/plugin_defaults.rs | 668 +++++++++++++++++++ crates/tw-control/tests/plugins.rs | 267 ++++++++ crates/tw-gateway/src/plugin/defaults/mod.rs | 66 ++ crates/tw-gateway/src/plugin/mod.rs | 3 + 19 files changed, 2377 insertions(+), 39 deletions(-) create mode 100644 crates/tw-config/tests/written_text.rs create mode 100644 crates/tw-control/src/plugins/defaults.rs create mode 100644 crates/tw-control/tests/plugin_defaults.rs create mode 100644 crates/tw-gateway/src/plugin/defaults/mod.rs diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index 97eba022..f906d750 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -842,6 +842,15 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> state.clone(), state.bus.clone(), )); + // 默认插件(随 core 发的那几个):没给过的装上(停用着),没动过的换成新版。 + // 启动时在控制面起来之前走一遍,界面第一次取插件就看得到它们;之后每换入一份 + // 配置再走一遍。**不挡启动**:哪个没办成只记一行、说一声。安全模式不走 —— + // 那时只有控制面,不替人往配置里写东西 + if !safe { + let seeder = tw_control::plugins::defaults::Seeder::shipped(); + seeder.seed(&manager).await; + tw_control::plugins::defaults::spawn(seeder, manager.clone()); + } // **监听要留着** —— 扔掉它就停止监听,而那个失效是静默的。 // 起不来不是致命的:手改文件不会自动生效,但界面和 CLI 照常能用, // 所以说一句就继续。 @@ -876,9 +885,9 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> chatgpt: Default::default(), zai: Default::default(), }; - // 凭据轮换要写回 config.yaml。**这是这个程序里唯一一次 - // 不是人发起的配置写入** —— 理由是服务器换发新 refresh token 的 - // 那一刻旧的就作废了,不写回等于让配置文件从那一秒起就是坏的。 + // 凭据轮换要写回 config.yaml。**不是人发起的配置写入只有两种**, + // 这是一种(另一种是上面的默认插件)—— 理由是服务器换发新 refresh + // token 的那一刻旧的就作废了,不写回等于让配置文件从那一秒起就是坏的。 tw_control::rotation::spawn(control.clone()); // 定期刷新默认价目表(`pricing.auto_update`,默认开) tw_control::pricing::spawn(control.clone()); diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index b78a5fd3..7b6ec496 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -148,6 +148,7 @@ control.plugin.blank_pattern control.plugin.file_missing control.plugin.file_moved_on control.plugin.id_taken +control.plugin.needs_confirmation control.plugin.not_found control.plugin.order control.plugin.reserved_id diff --git a/crates/tw-api/src/ep.rs b/crates/tw-api/src/ep.rs index dd6f3aa5..7dc681de 100644 --- a/crates/tw-api/src/ep.rs +++ b/crates/tw-api/src/ep.rs @@ -131,10 +131,10 @@ endpoints! { // ─────────────────────────────────────────────── 脚本插件 // - // **装、换源码、批准三个端点不给网页调**(桌面端的 `call` 白名单里没有它们): - // 这三件事要在系统的确认框里点头,那一步在桌面端的 Rust 里 —— 它自己再编一遍 - // 源码,把名字、权限和哈希摆给人看,点了头才发请求。网页里的脚本做不到这件事, - // 就做不成这三件事。 + // **装、换源码、批准、确认过的改动四个端点不给网页调**(桌面端的 `call` 白名单里 + // 没有它们):这几件事要在系统的确认框里点头,那一步在桌面端的 Rust 里 —— 它自己 + // 再编一遍源码(或者读一遍插件现在的样子),把名字、权限和要改的地方摆给人看,点了 + // 头才发请求。网页里的脚本做不到这件事,就做不成这几件事。 /// 全部插件,按运行的顺序:状态、计数 Plugins: GET "/plugins", () => Vec; /// 编一份源码看看它是什么插件。**什么都不留下** @@ -143,8 +143,16 @@ endpoints! { CreatePlugin: POST "/plugins", api::PluginCreate => api::ConfigWritten; /// 排顺序,也就是运行的顺序 ReorderPlugins: PUT "/plugins/order", api::PluginOrder => api::ConfigWritten; - /// 开关、出错时怎么办、范围、设置 + /// 开关、出错时怎么办、范围、设置。**改得了回答里工具调用的插件**(权限有 + /// `reply_tool_calls`,或者读不出它要什么权限),打开它、改它的设置或范围在这里一律 + /// 拒绝(403,`control.plugin.needs_confirmation`),要走 `UpdatePluginConfirmed`; + /// 停用、改出错时怎么办照常 UpdatePlugin: PUT "/plugins/{id}" [id], api::PluginUpdate => api::ConfigWritten; + /// 同一件事,在系统的确认框里点过头了:工具调用插件的开关、设置、范围也改得了。 + /// **网页不能调,桌面端也不许把它放进网页的白名单**:网页里注入的脚本调得到它,就能 + /// 自己打开一个改工具调用的插件、改它的设置。桌面端的 Rust 先弹系统的确认框(插件 + /// 的名字、它能做什么、这次改了什么),点了头再发 + UpdatePluginConfirmed: PUT "/plugins/{id}/confirmed" [id], api::PluginUpdate => api::ConfigWritten; /// 删掉:配置里那一条、插件文件和底稿 DeletePlugin: DELETE "/plugins/{id}" [id], api::BaseVersion => api::ConfigWritten; /// 换一份源码,批准的就是新的这一份。**网页不能调** diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index b208e9e3..cae8193e 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -376,6 +376,8 @@ slug_enum! { Rollback = "rollback", /// OAuth 凭据轮换之后写回 Rotation = "rotation", + /// core 自己:装上它自带的默认插件,或者把没动过的默认插件换成新版 + Defaults = "defaults", } } @@ -682,6 +684,13 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// 尝试链的第几跳)和 `request_after_plugins`(插件改过的请求体),[`HistoryRow`] 多了 /// `plugin_changed`。装、换源码、批准三个端点不给网页调:要在系统的确认框里点头。照 32 /// 写的界面看不到插件。 +/// +/// 33 起**改得了工具调用的插件要点过头才能打开**:`UpdatePlugin` 拒绝打开权限里有 +/// `reply_tool_calls` 的插件(读不出权限的也算)、改它的设置或范围(403, +/// `control.plugin.needs_confirmation`),这几样走新端点 `PUT /plugins/{id}/confirmed` +/// (`UpdatePluginConfirmed`,请求体同 [`PluginUpdate`])—— 它和装、换源码、批准一样 +/// 不给网页调,桌面端在系统的确认框里点了头才发。同一版起 core 自带几个默认插件,第一次 +/// 见到时装上、停用着,写配置的这一版来源是 [`ConfigOrigin::Defaults`]。 pub const CONTROL_API_VERSION: u32 = 33; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1263,7 +1272,7 @@ pub enum Event { id: u64, /// 内容版本号,和 `PATCH /config` 的 `base_version` 是同一个 version: String, - /// `ui` / `cli` / `external` / `rollback` / `rotation` + /// `ui` / `cli` / `external` / `rollback` / `rotation` / `defaults` origin: ConfigOrigin, at_ms: u64, }, @@ -1281,7 +1290,7 @@ pub enum Event { line: Option, /// 出错那一行的原文,**已脱敏** excerpt: Option, - /// 这一版是谁写的:`ui` / `cli` / `external` / `rollback` / `rotation`。 + /// 这一版是谁写的:`ui` / `cli` / `external` / `rollback` / `rotation` / `defaults`。 /// /// **界面靠它区分「用户在编辑器里写错了」和「界面自己刚写坏了」** —— /// 前者要提醒,后者是保存失败,那条路自己会报。 @@ -3125,7 +3134,7 @@ pub struct BaseVersion { pub struct ConfigVersion { pub version: String, pub at_ms: u64, - /// `ui` / `cli` / `external` / `rollback` / `rotation` + /// `ui` / `cli` / `external` / `rollback` / `rotation` / `defaults` pub origin: ConfigOrigin, pub bytes: u64, /// 这一版是现在跑着的那一版吗。 @@ -5128,6 +5137,11 @@ pub struct PluginCreate { /// 改一个插件的开关、出错时怎么办、范围、设置(`PUT /plugins/{id}`)。**整份交**: /// 交上来的就是保存之后的样子。 +/// +/// 插件改得了回答里的工具调用(权限有 [`Permission::ReplyToolCalls`],或者读不出它要 +/// 什么权限)时,打开它、改设置、改范围这条路不收(`control.plugin.needs_confirmation`), +/// 同一份请求体交给 `PUT /plugins/{id}/confirmed`:那个端点网页调不了,桌面端在系统的 +/// 确认框里点了头才发。比的是生效的值:没写进配置的设置按默认值算,范围不看顺序。 #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct PluginUpdate { diff --git a/crates/tw-api/src/ts.rs b/crates/tw-api/src/ts.rs index 08035f67..84068c7b 100644 --- a/crates/tw-api/src/ts.rs +++ b/crates/tw-api/src/ts.rs @@ -334,7 +334,7 @@ mod tests { ); } - /// 插件:设置值是那个值本身,状态按 `kind` 分派,三个要系统确认框的端点照样在表里 + /// 插件:设置值是那个值本身,状态按 `kind` 分派,四个要系统确认框的端点照样在表里 /// (网页白名单在桌面端,不在这里) #[test] fn plugins_come_through() { @@ -368,11 +368,18 @@ mod tests { " CreatePlugin: { req: PluginCreate; res: ConfigWritten };", " ReplacePluginSource: { req: PluginSourceReplace; res: ConfigWritten };", " ApprovePluginFile: { req: PluginApprove; res: ConfigWritten };", + " UpdatePluginConfirmed: { req: PluginUpdate; res: ConfigWritten };", + " UpdatePluginConfirmed: { method: \"PUT\", path: \"/plugins/{id}/confirmed\", params: [\"id\"], format: \"json\" },", " DeletePlugin: { req: BaseVersion; res: ConfigWritten };", " TrialPlugin: { req: PluginTrial; res: PluginTrialResult };", ] { assert!(ts.contains(line), "{line}"); } + // core 自己写配置(默认插件)的那一版有自己的来源 + assert_eq!( + decl_of(&ts, "ConfigOrigin"), + "export type ConfigOrigin = \"ui\" | \"cli\" | \"external\" | \"rollback\" | \"rotation\" | \"defaults\"" + ); } #[test] diff --git a/crates/tw-config/src/edit.rs b/crates/tw-config/src/edit.rs index 737edebb..cdd0bfd7 100644 --- a/crates/tw-config/src/edit.rs +++ b/crates/tw-config/src/edit.rs @@ -97,38 +97,50 @@ pub struct Section { pub what: &'static str, /// 每一项靠哪个键认:几乎都是 `name`,插件是 `id` pub key: &'static str, + /// 每一项里**可以写多行文字**的那几个键:它们底下的字符串可以带换行(写出去是 + /// 带转义的双引号,见 [`render`])。**其余的一律单行** —— 名字、地址、密钥、请求头、 + /// 模型和网段写成两行都不是原来那个东西,在这一层就拒绝([`EditError::Multiline`]) + pub multiline: &'static [&'static str], } pub const PROVIDERS: Section = Section { path: &["providers"], what: "upstream", key: "name", + multiline: &[], }; pub const PROXIES: Section = Section { path: &["proxies"], what: "proxy", key: "name", + multiline: &[], }; pub const PRICE_SHEETS: Section = Section { path: &["pricing", "sheets"], what: "price sheet", key: "name", + multiline: &[], }; pub const ROUTES: Section = Section { path: &["routes"], what: "route", key: "name", + multiline: &[], }; pub const GROUPS: Section = Section { path: &["groups"], what: "group", key: "name", + multiline: &[], }; +/// 插件的设置是插件自己声明的文字,「一行一条」的写法很常见(统一用词的对照表、 +/// 打码的正则)。id、文件、哈希、范围照旧单行 pub const PLUGINS: Section = Section { path: &["plugins"], what: "plugin", key: "id", + multiline: &["settings"], }; impl Section { @@ -186,6 +198,7 @@ pub fn upsert( name, }); } + single_lines(section, item.iter())?; let block = render_block(&Value::Mapping(item.clone()))?; let out = tw_yaml::append(text, &steps, &block)?; (out, section.items(&doc).len()) @@ -207,6 +220,7 @@ pub fn upsert( path.push(Step::Index(index)); let out = if tw_yaml::is_flow_at(text, &path)? { // 行内写法里的键删不了、嵌套值塞不进去 —— 整项换成块式 + single_lines(section, item.iter())?; let block = render_block(&Value::Mapping(item.clone()))?; tw_yaml::replace_item(text, &steps, index, &block)? } else { @@ -214,7 +228,7 @@ pub fn upsert( .as_mapping() .cloned() .unwrap_or_default(); - sync_fields(text, &path, &old_item, item)? + sync_fields(text, &path, &old_item, item, section)? }; (out, index) } @@ -280,9 +294,10 @@ pub fn reorder(text: &str, section: Section, keys: &[String]) -> Result out, - // 行内写法、或者别的块式之外的写法:整段换成重排后的样子 + // 行内写法、或者别的块式之外的写法:整段换成重排后的样子。**不再查单行**: + // 搬的是文件里已有的值 Err(tw_yaml::PatchError::NotFound(_)) => { - set(text, &steps, Some(&Value::Sequence(reordered.clone())))? + put_value(text, &steps, &Value::Sequence(reordered.clone()))? } Err(e) => return Err(e.into()), }; @@ -302,6 +317,8 @@ pub fn reorder(text: &str, section: Section, keys: &[String]) -> Result) -> Result { let exists = { let doc = parse(text)?; @@ -311,12 +328,18 @@ pub fn set(text: &str, path: &[Step], value: Option<&Value>) -> Result Ok(text.to_string()), None => Ok(tw_yaml::remove_key(text, path)?), Some(v) => { - let rendered = render(v)?; - Ok(tw_yaml::put(text, path, rendered.as_put())?) + reject_multiline(v)?; + put_value(text, path, v) } } } +/// 把一个值写到这个位置上,不查单行(调用方查过,或者搬的是文件里已有的值) +fn put_value(text: &str, path: &[Step], v: &Value) -> Result { + let rendered = render(v)?; + Ok(tw_yaml::put(text, path, rendered.as_put())?) +} + /// 一个值渲染成的文本,以及它该按单行还是按块写。 pub struct Rendered { text: String, @@ -335,10 +358,32 @@ impl Rendered { /// 渲染一个值。引号和转义交给 serde —— 它知道哪些字符串不加引号会被 /// 读成别的类型。 +/// +/// **带换行、制表符或别的控制字符的字符串除外**:serde 会把多行写成 `|-` 块标量, +/// 而块标量里缩进是内容的一部分,这一层不去冒那个险。这些字符串写成**单行的双引号**, +/// 每个这样的字符都转义([`double_quoted`])—— 值里写什么都动不了文件的结构。 +/// 做法是先在 serde 渲染的那一份里放一个占位的词,渲染完再换成双引号的写法:其余的 +/// 写法(键、嵌套、别的标量的引号)照旧由 serde 决定。 +/// +/// **这里只管写得对,不管该不该写**:哪些字段只能单行由调用方查([`Section::multiline`]、 +/// [`set`])。 pub fn render(v: &Value) -> Result { - reject_multiline(v)?; - let text = serde_yaml_ng::to_string(v).map_err(|e| EditError::Unwritable(e.to_string()))?; - let text = text.trim_end_matches('\n').to_string(); + let mut quoted = Vec::new(); + let mark = free_mark(v); + let swapped = swap_escaped(v, &mark, &mut quoted); + let text = + serde_yaml_ng::to_string(&swapped).map_err(|e| EditError::Unwritable(e.to_string()))?; + let mut text = text.trim_end_matches('\n').to_string(); + for (i, q) in quoted.iter().enumerate() { + let token = format!("{mark}{i}z"); + // 占位的词得原样、只出现一次:被加了引号、或者撞上了别的字,就不是这个值了 + if text.matches(token.as_str()).count() != 1 { + return Err(EditError::Unwritable(format!( + "the placeholder {token} did not come out of the renderer as written" + ))); + } + text = text.replacen(token.as_str(), q, 1); + } let block = match v { Value::Mapping(m) => !m.is_empty(), Value::Sequence(s) => !s.is_empty(), @@ -351,8 +396,101 @@ fn render_block(v: &Value) -> Result { Ok(render(v)?.text) } -/// **值里不许有换行。**serde 会把它写成 `|-` 块标量,而块标量里缩进是 -/// 内容的一部分 —— 这一层不去冒那个险。配置里本来也没有需要多行的字段。 +/// 这个字符要不要转义:控制字符(C0、DEL、C1,含制表符和换行)、YAML 1.1 当作换行的 +/// 那几个(NEL、LS、PS),以及 BOM 和两个非字符。**这些字符原样写进文件,要么读不回来, +/// 要么读回来变了样**(YAML 1.1 的加载器把 LS 当换行,折成一个空格) +fn escaped(c: char) -> bool { + let n = c as u32; + n < 0x20 + || (0x7f..=0x9f).contains(&n) + || matches!(n, 0x2028 | 0x2029 | 0xfeff | 0xfffe | 0xffff) +} + +/// 一个字符串写成单行的 YAML 双引号标量。`"` 和 `\` 加反斜杠,换行、回车、制表符用 +/// 各自的转义,其余要转义的写成 `\xNN` / `\uNNNN`,别的字符原样。 +fn double_quoted(s: &str) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if escaped(c) => { + let n = c as u32; + if n <= 0xff { + let _ = write!(out, "\\x{n:02x}"); + } else { + let _ = write!(out, "\\u{n:04x}"); + } + } + c => out.push(c), + } + } + out.push('"'); + out +} + +/// 占位词的前缀:`twqx`,挑一个**哪个字符串里都没有**的 `n`(连同转义之后的写法)。 +/// 占位词是前缀加序号再加 `z` —— 结尾的 `z` 让第 1 个不会是第 10 个的开头 +fn free_mark(v: &Value) -> String { + let mut all = Vec::new(); + strings(v, &mut all); + let taken = |mark: &str| { + all.iter().any(|s| { + s.contains(mark) || (s.chars().any(escaped) && double_quoted(s).contains(mark)) + }) + }; + (0u64..) + .map(|n| format!("twq{n}x")) + .find(|m| !taken(m)) + .unwrap_or_default() +} + +fn strings<'a>(v: &'a Value, out: &mut Vec<&'a str>) { + match v { + Value::String(s) => out.push(s), + Value::Mapping(m) => { + for (k, v) in m { + strings(k, out); + strings(v, out); + } + } + Value::Sequence(s) => s.iter().for_each(|v| strings(v, out)), + Value::Tagged(t) => strings(&t.value, out), + _ => {} + } +} + +/// 把要转义的字符串换成占位词(键和值都算),转义后的写法按序号收进 `quoted` +fn swap_escaped(v: &Value, mark: &str, quoted: &mut Vec) -> Value { + match v { + Value::String(s) if s.chars().any(escaped) => { + let token = format!("{mark}{}z", quoted.len()); + quoted.push(double_quoted(s)); + Value::String(token) + } + Value::Mapping(m) => Value::Mapping( + m.iter() + .map(|(k, v)| (swap_escaped(k, mark, quoted), swap_escaped(v, mark, quoted))) + .collect(), + ), + Value::Sequence(s) => { + Value::Sequence(s.iter().map(|v| swap_escaped(v, mark, quoted)).collect()) + } + Value::Tagged(t) => Value::Tagged(Box::new(serde_yaml_ng::value::TaggedValue { + tag: t.tag.clone(), + value: swap_escaped(&t.value, mark, quoted), + })), + other => other.clone(), + } +} + +/// **单行的字段里不许有换行**:名字、地址、密钥写成两行就不是原来那个东西了。 +/// 哪些字段可以多行由那一段自己说([`Section::multiline`]) fn reject_multiline(v: &Value) -> Result<(), EditError> { match v { Value::String(s) if s.contains('\n') || s.contains('\r') => Err(EditError::Multiline), @@ -366,12 +504,29 @@ fn reject_multiline(v: &Value) -> Result<(), EditError> { } } -/// 按字段把一项改成新的样子:删掉新结构里没有的键,写入新增或变了的键。 +/// 一项里要写的这些字段,除了这一段允许多行的,都得是单行 +fn single_lines<'a>( + section: Section, + fields: impl Iterator, +) -> Result<(), EditError> { + for (k, v) in fields { + reject_multiline(k)?; + let free = k.as_str().is_some_and(|k| section.multiline.contains(&k)); + if !free { + reject_multiline(v)?; + } + } + Ok(()) +} + +/// 按字段把一项改成新的样子:删掉新结构里没有的键,写入新增或变了的键。**只查要写的 +/// 那几个字段**:没变的字段原样留着,不管它是怎么写进文件的 fn sync_fields( text: &str, path: &[Step], old: &Mapping, new: &Mapping, + section: Section, ) -> Result { let mut out = text.to_string(); let key_of = |k: &Value| -> Result { @@ -379,12 +534,17 @@ fn sync_fields( .map(str::to_string) .ok_or_else(|| EditError::Unwritable(format!("the key {k:?} is not a string"))) }; + let changed: Vec<(&Value, &Value)> = new + .iter() + .filter(|(k, v)| old.get(*k) != Some(*v)) + .collect(); + single_lines(section, changed.iter().copied())?; for (k, _) in old.iter().filter(|(k, _)| !new.contains_key(*k)) { let mut p = path.to_vec(); p.push(Step::Key(key_of(k)?)); out = tw_yaml::remove_key(&out, &p)?; } - for (k, v) in new.iter().filter(|(k, v)| old.get(*k) != Some(*v)) { + for (k, v) in changed { let mut p = path.to_vec(); p.push(Step::Key(key_of(k)?)); let rendered = render(v)?; @@ -573,8 +733,9 @@ providers: assert_eq!(back, CFG); } + /// 单行的字段里有换行:拒绝。新加的、改的、按路径设的都一样 #[test] - fn a_value_with_a_newline_is_refused() { + fn a_newline_in_a_single_line_field_is_refused() { let e = upsert( CFG, PROXIES, @@ -583,6 +744,161 @@ providers: ) .unwrap_err(); assert!(matches!(e, EditError::Multiline), "{e}"); + let e = upsert( + CFG, + PROVIDERS, + Some("官方"), + &map("name: 官方\nbase_url: https://api.anthropic.com\nkey: \"sk-a\\r\"\n"), + ) + .unwrap_err(); + assert!(matches!(e, EditError::Multiline), "{e}"); + let e = set( + CFG, + &[Step::key("default_route")], + Some(&Value::String("a\nb".into())), + ) + .unwrap_err(); + assert!(matches!(e, EditError::Multiline), "{e}"); + } + + const PLUGIN: &str = " - id: p\n file: plugins/p.js\n sha256: 6f1c000000000000000000000000000000000000000000000000000000000abc\n"; + + fn plugin_item(settings: &str) -> Mapping { + map(&format!( + "id: p\nfile: plugins/p.js\nsha256: 6f1c000000000000000000000000000000000000000000000000000000000abc\nsettings:\n{settings}" + )) + } + + /// 插件的设置可以多行:写成一行双引号,换行转义,读回来一字不差 —— 新加的和改的都是 + #[test] + fn a_plugin_setting_may_span_lines_and_is_written_on_one_line() { + let terms = "登陆=登录\n帐号=账号\n"; + let out = upsert( + CFG, + PLUGINS, + None, + &plugin_item(" terms: \"登陆=登录\\n帐号=账号\\n\"\n"), + ) + .unwrap(); + assert!( + out.contains("\n terms: \"登陆=登录\\n帐号=账号\\n\"\n"), + "{out}" + ); + assert_eq!( + parse(&out).unwrap()["plugins"][0]["settings"]["terms"], + terms + ); + assert!(out.contains("# 两家上游"), "{out}"); + + let patterns = "\\bsk-[a-z]+\\b\n\"quoted\"\t#1: x\r\n---\n..."; + let mut item = plugin_item(" terms: x\n"); + item["settings"]["terms"] = Value::String(patterns.into()); + let again = upsert(&out, PLUGINS, Some("p"), &item).unwrap(); + assert_eq!( + parse(&again).unwrap()["plugins"][0]["settings"]["terms"], + patterns + ); + // 只有那一行变了 + let changed: Vec<_> = again + .lines() + .filter(|l| !out.lines().any(|o| o == *l)) + .collect(); + assert_eq!(changed.len(), 1, "{again}"); + assert!(changed[0].starts_with(" terms: \""), "{again}"); + } + + /// 插件那一项里只有设置能多行:范围里的模式、id 照旧单行 + #[test] + fn only_the_settings_of_a_plugin_may_span_lines() { + let mut item = plugin_item(" note: ok\n"); + item.insert("scope".into(), map("models: [\"a\\nb\"]\n").into()); + let e = upsert(CFG, PLUGINS, None, &item).unwrap_err(); + assert!(matches!(e, EditError::Multiline), "{e}"); + } + + /// 控制字符、制表符、YAML 1.1 当换行的那几个字符:单行字段里也能写,转义成双引号, + /// 读回来一字不差 + #[test] + fn control_characters_and_line_separators_are_escaped_everywhere() { + for s in [ + "a\tb", + "a\u{0}b", + "a\u{7}b\u{1b}", + "a\u{7f}b", + "a\u{85}b", + "a\u{9f}b", + "a\u{2028}b", + "a\u{2029}b", + "\u{feff}a", + "a\u{fffe}\u{ffff}", + "\t", + ] { + let mut item = map("name: 官方\nbase_url: https://api.anthropic.com\nkey: sk-a\n"); + item["key"] = Value::String(s.into()); + let out = upsert(CFG, PROVIDERS, Some("官方"), &item) + .unwrap_or_else(|e| panic!("{s:?}: {e}")); + assert_eq!(parse(&out).unwrap()["providers"][0]["key"], s, "{out}"); + assert!(out.contains("\n key: \""), "{s:?}: {out}"); + assert!( + !out.chars().any(|c| c != '\n' && escaped(c)), + "{s:?} was written raw: {out:?}" + ); + } + } + + /// 文件里本来就有一个多行的值(手写的块标量),这次没改它:只改的那个字段要查 + #[test] + fn an_untouched_multiline_value_written_by_hand_does_not_block_an_edit() { + let text = CFG.replace( + " key: sk-a\n", + " key: sk-a\n notes: |\n 第一行\n 第二行\n", + ); + let mut item = parse(&text).unwrap()["providers"][0] + .as_mapping() + .cloned() + .unwrap(); + item.insert("proxy".into(), "corp".into()); + let out = upsert(&text, PROVIDERS, Some("官方"), &item).unwrap(); + assert!( + out.contains(" notes: |\n 第一行\n 第二行\n"), + "{out}" + ); + assert_eq!(parse(&out).unwrap()["providers"][0]["proxy"], "corp"); + } + + /// 行内写法的插件列表重排:整段重写,多行的设置照样搬过去 + #[test] + fn reordering_a_flow_list_carries_multiline_settings_along() { + let text = format!( + "{CFG}plugins: [{{id: a, file: plugins/a.js, sha256: x, settings: {{t: \"1\\n2\"}}}}, {{id: b, file: plugins/b.js, sha256: y}}]\n" + ); + let out = reorder(&text, PLUGINS, &["b".into(), "a".into()]).unwrap(); + let v = parse(&out).unwrap(); + assert_eq!(v["plugins"][0]["id"], "b"); + assert_eq!(v["plugins"][1]["settings"]["t"], "1\n2"); + } + + /// 占位词撞上了值里本来就有的字:换一个 + #[test] + fn the_placeholder_never_matches_text_that_is_already_there() { + let v: Value = + serde_yaml_ng::from_str("a: \"twq0x0z\\n\"\nb: twq0x0z\nc: twq1x\nd: \"x\\ty\"\n") + .unwrap(); + let r = render(&v).unwrap(); + let back: Value = serde_yaml_ng::from_str(&r.text).unwrap(); + assert_eq!(back, v, "{}", r.text); + assert!(r.block); + } + + #[test] + fn a_plugin_entry_appended_to_a_config_without_plugins_starts_the_section() { + let out = upsert(CFG, PLUGINS, None, &plugin_item(" t: \"a\\nb\"\n")).unwrap(); + assert!( + out.ends_with(&format!( + "plugins:\n{PLUGIN} settings:\n t: \"a\\nb\"\n" + )), + "{out}" + ); } #[test] diff --git a/crates/tw-config/src/history.rs b/crates/tw-config/src/history.rs index b919a9cc..48015ca5 100644 --- a/crates/tw-config/src/history.rs +++ b/crates/tw-config/src/history.rs @@ -33,9 +33,12 @@ pub enum Origin { Rollback, /// token 端点换发了新的 refresh token,我们把它写回去了。 /// - /// **这是唯一一次不是人发起的写入**,所以它在历史里要能一眼认出来 - /// —— 用户看到「配置变了」时,第一个问题是「谁改的」。 + /// **不是人发起的写入**,所以它在历史里要能一眼认出来 —— 用户看到「配置 + /// 变了」时,第一个问题是「谁改的」。 Rotation, + /// core 自己装上它自带的默认插件、或者把没动过的默认插件换成新版。同样不是 + /// 人发起的,同样要一眼认得出来 + Defaults, } impl Origin { @@ -47,6 +50,7 @@ impl Origin { Origin::External => "external", Origin::Rollback => "rollback", Origin::Rotation => "rotation", + Origin::Defaults => "defaults", } } fn parse(s: &str) -> Origin { @@ -55,6 +59,7 @@ impl Origin { "cli" => Origin::Cli, "rollback" => Origin::Rollback, "rotation" => Origin::Rotation, + "defaults" => Origin::Defaults, _ => Origin::External, } } @@ -65,6 +70,7 @@ impl Origin { Origin::External => "an outside edit", Origin::Rollback => "a rollback", Origin::Rotation => "a credential rotation", + Origin::Defaults => "the default plugins", } } } diff --git a/crates/tw-config/src/wire.rs b/crates/tw-config/src/wire.rs index b1580473..d95884c1 100644 --- a/crates/tw-config/src/wire.rs +++ b/crates/tw-config/src/wire.rs @@ -134,6 +134,7 @@ impl From for tw_api::ConfigOrigin { Origin::External => Self::External, Origin::Rollback => Self::Rollback, Origin::Rotation => Self::Rotation, + Origin::Defaults => Self::Defaults, } } } diff --git a/crates/tw-config/tests/written_text.rs b/crates/tw-config/tests/written_text.rs new file mode 100644 index 00000000..8f83b4ca --- /dev/null +++ b/crates/tw-config/tests/written_text.rs @@ -0,0 +1,400 @@ +//! 写进配置的任意文字动不了文件的结构。 +//! +//! 随机造字符串(换行、回车、制表符、引号、反斜杠、`#`、`: `、`---`、`...`、首尾空白、 +//! 控制字符、YAML 1.1 当换行的那几个字符、中文、emoji、组合字符……),经按名字编辑的 +//! 那一层(`tw_config::edit`)写进一份带注释的配置,断言: +//! +//! - 读回来一字不差:serde 那条加载路径、整份配置的解析和校验、tw-yaml 的解析器,三处 +//! 读到的都是写进去的那个字符串; +//! - 被改的那一行(新加的那几行)之外,**每个字节都没动**:别的键、注释原样。 +//! +//! 生成器自己写,种子可复现(`TW_PROP_SEED`),和 tw-yaml 的 property test 同一个做法。 + +use serde_yaml_ng::{Mapping, Value}; +use tw_config::edit::{self, PLUGINS}; +use tw_yaml::{NodeKind, Step}; + +const HASH: &str = "6f1c000000000000000000000000000000000000000000000000000000000abc"; + +fn doc() -> String { + format!( + "version: 1 +# 控制面的钥匙 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 # 别动 +clients: + # 默认那把 + - name: default + key: tw-aaaa + client: codex # 给 Codex 用 +plugins: + # 第一个 + - id: first + file: plugins/first.js + sha256: {HASH} + enabled: false + settings: + note: plain # 行尾注释 + - id: target + file: plugins/target.js + sha256: {HASH} + enabled: false + settings: + note: old + keep: 1 +# 插件之后 +providers: + - name: 官方 + base_url: https://api.anthropic.com # 直连 + key: sk-a +" + ) +} + +/// xorshift64:要的是可复现,不是随机质量 +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T { + &xs[self.below(xs.len())] + } +} + +/// 单个字符:可打印的 ASCII(含 YAML 的指示符)和最容易出事的那些 +const CHARS: &[char] = &[ + 'a', 'Z', '0', '9', ' ', ' ', '"', '\'', '\\', '#', ':', '-', '.', ',', '[', ']', '{', '}', + '&', '*', '!', '|', '>', '%', '@', '`', '?', '=', '/', '\n', '\n', '\r', '\t', '\u{0}', + '\u{1}', '\u{1b}', '\u{7f}', '\u{80}', '\u{85}', '\u{9f}', '\u{a0}', '\u{2028}', '\u{2029}', + '\u{feff}', '\u{fffe}', '\u{ffff}', '\u{200b}', '\u{301}', '中', '文', '登', '😀', '𝄞', +]; + +/// 成段的写法:文档标记、键值分隔、注释、块标量的开头、锚点、标签、转义的样子…… +const PIECES: &[&str] = &[ + "---", + "...", + ": ", + " #", + "# ", + "- ", + "? ", + "|", + "|-", + ">", + "&a ", + "*a", + "!tag ", + "%YAML 1.2", + "\\n", + "\\x41", + "\"\"", + "''", + "key: value", + "\n---\n", + "\n...\n", + "\r\n", + " ", + "twq0x0z", + "true", + "null", + "~", + "0x1f", + "1e3", + "登陆=登录", + "\\bsk-[a-z]+\\b", +]; + +fn arbitrary(rng: &mut Rng) -> String { + let mut s = String::new(); + if rng.below(5) == 0 { + s.push_str(&" ".repeat(1 + rng.below(3))); + } + for _ in 0..rng.below(14) { + if rng.below(3) == 0 { + s.push_str(rng.pick(PIECES)); + } else { + s.push(*rng.pick(CHARS)); + } + } + if rng.below(5) == 0 { + s.push_str(&" ".repeat(1 + rng.below(3))); + } + s +} + +/// 一眼能想到的那些,每个都试 +const FIXED: &[&str] = &[ + "", + " ", + "\n", + "\r\n", + "\r", + "\t", + "---", + "...", + "--- a", + "a\n---\nb: c", + "a\n...\n", + "- x", + "? x", + "x: y", + "x:", + "#", + " # x", + "\"", + "'", + "\\", + "\\n", + "|\n a", + ">\n b", + "&anchor x", + "*alias", + "!!str x", + "%TAG ! x", + " leading", + "trailing ", + " both ", + "\u{2028}", + "\u{85}", + "\u{feff}", + "a\u{0}b", + "line one\nline two\n\nline four", + "登陆=登录\n帐号=账号", + "😀\n𝄞", +]; + +fn seed() -> u64 { + std::env::var("TW_PROP_SEED") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0x5eed_1234_abcd_0001) +} + +fn cases() -> Vec { + let mut rng = Rng(seed() | 1); + let mut out: Vec = FIXED.iter().map(|s| s.to_string()).collect(); + out.extend((0..1500).map(|_| arbitrary(&mut rng))); + out +} + +fn yaml_map(text: &str) -> Mapping { + serde_yaml_ng::from_str(text).unwrap() +} + +/// 改过的那一份里,这个位置上 tw-yaml 读到的标量 +fn scalar_at(text: &str, path: &[Step]) -> String { + let nodes = + tw_yaml::nodes(text).unwrap_or_else(|e| panic!("tw-yaml cannot read it: {e}\n{text}")); + let n = nodes + .iter() + .find(|n| n.path == path) + .unwrap_or_else(|| panic!("tw-yaml has no {path:?}\n{text}")); + match &n.kind { + NodeKind::Scalar { value, .. } => value.clone(), + other => panic!("{path:?} is {other:?}\n{text}"), + } +} + +fn note_path(index: usize, key: &str) -> Vec { + vec![ + Step::key("plugins"), + Step::Index(index), + Step::key("settings"), + Step::key(key), + ] +} + +/// 三条读法读到的都是它 +fn reads_back( + out: &str, + path: &[Step], + cfg_value: impl Fn(&tw_config::Config) -> Option, + s: &str, +) { + let v: Value = + serde_yaml_ng::from_str(out).unwrap_or_else(|e| panic!("{s:?}: serde: {e}\n{out}")); + let mut cur = &v; + for st in path { + cur = match st { + Step::Key(k) => &cur[k.as_str()], + Step::Index(i) => &cur[*i], + }; + } + assert_eq!(cur.as_str(), Some(s), "serde read something else\n{out}"); + let cfg = tw_config::try_parse(out) + .unwrap_or_else(|r| panic!("{s:?}: the configuration does not load: {r}\n{out}")); + assert_eq!( + cfg_value(&cfg).as_deref(), + Some(s), + "the configuration read something else\n{out}" + ); + assert_eq!( + scalar_at(out, path), + s, + "tw-yaml read something else\n{out}" + ); +} + +/// 改了一项里的一个设置:**只有那一行变了**,而且它还是一行 +#[test] +fn a_setting_written_over_an_old_one_changes_only_its_own_line() { + let base = doc(); + for s in cases() { + let mut item = yaml_map(&format!( + "id: target\nfile: plugins/target.js\nsha256: {HASH}\nenabled: false\nsettings:\n note: x\n keep: 1\n" + )); + item["settings"]["note"] = Value::String(s.clone()); + let out = edit::upsert(&base, PLUGINS, Some("target"), &item) + .unwrap_or_else(|e| panic!("{s:?}: {e}")); + reads_back( + &out, + ¬e_path(1, "note"), + |c| { + c.plugins[1] + .settings + .get("note")? + .as_str() + .map(str::to_string) + }, + &s, + ); + let before: Vec<&str> = base.lines().collect(); + let after: Vec<&str> = out.lines().collect(); + assert_eq!( + before.len(), + after.len(), + "{s:?}: lines were added or lost\n{out}" + ); + let changed: Vec = (0..before.len()) + .filter(|&i| before[i] != after[i]) + .collect(); + let line = before.iter().position(|l| *l == " note: old").unwrap(); + assert!( + changed.is_empty() || changed == [line], + "{s:?}: other lines changed: {changed:?}\n{out}" + ); + assert!(after[line].starts_with(" note: "), "{s:?}\n{out}"); + assert!(out.ends_with('\n') && base.ends_with('\n')); + } +} + +/// 新加一项:原文**一个字节不少地**留在前后两段里,中间多出来的是完整的几行 +#[test] +fn a_new_entry_is_one_insertion_of_whole_lines() { + let base = doc(); + let mut rng = Rng((seed() ^ 0xdead_beef) | 1); + for s in cases() { + let t = arbitrary(&mut rng); + let mut item = yaml_map(&format!( + "id: added\nfile: plugins/added.js\nsha256: {HASH}\nenabled: false\nsettings:\n note: x\n other: y\n" + )); + item["settings"]["note"] = Value::String(s.clone()); + item["settings"]["other"] = Value::String(t.clone()); + let out = + edit::upsert(&base, PLUGINS, None, &item).unwrap_or_else(|e| panic!("{s:?}: {e}")); + reads_back( + &out, + ¬e_path(2, "note"), + |c| { + c.plugins[2] + .settings + .get("note")? + .as_str() + .map(str::to_string) + }, + &s, + ); + reads_back( + &out, + ¬e_path(2, "other"), + |c| { + c.plugins[2] + .settings + .get("other")? + .as_str() + .map(str::to_string) + }, + &t, + ); + // 最长的公共前缀之后,剩下的原文得原样是结尾 + let common = base + .bytes() + .zip(out.bytes()) + .take_while(|(a, b)| a == b) + .count(); + let start = base[..common].rfind('\n').map_or(0, |i| i + 1); + let rest = &base[start..]; + assert!( + out.ends_with(rest), + "{s:?}: the original text was not kept around the new entry\n{out}" + ); + let inserted = &out[start..out.len() - rest.len()]; + assert_eq!( + inserted.lines().count(), + 7, + "{s:?}/{t:?}: the new entry is not seven lines\n{inserted}" + ); + } +} + +/// 单行的字段(按路径设):换行以外的字符照样写得进去、读得回来,只有那一行变了 +#[test] +fn a_single_line_field_takes_everything_but_a_line_break() { + let base = doc(); + let path = [Step::key("clients"), Step::Index(0), Step::key("client")]; + for s in cases() { + let s: String = s.chars().filter(|c| !matches!(c, '\n' | '\r')).collect(); + let out = edit::set(&base, &path, Some(&Value::String(s.clone()))) + .unwrap_or_else(|e| panic!("{s:?}: {e}")); + let v: Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!( + v["clients"][0]["client"].as_str(), + Some(s.as_str()), + "{out}" + ); + assert_eq!(scalar_at(&out, &path), s, "{out}"); + let cfg = tw_config::try_parse(&out).unwrap_or_else(|r| panic!("{s:?}: {r}\n{out}")); + assert_eq!(cfg.clients[0].client.as_deref(), Some(s.as_str())); + let before: Vec<&str> = base.lines().collect(); + let after: Vec<&str> = out.lines().collect(); + assert_eq!(before.len(), after.len(), "{s:?}\n{out}"); + let changed: Vec = (0..before.len()) + .filter(|&i| before[i] != after[i]) + .collect(); + let line = before + .iter() + .position(|l| l.starts_with(" client: ")) + .unwrap(); + assert!( + changed.is_empty() || changed == [line], + "{s:?}: other lines changed: {changed:?}\n{out}" + ); + } +} + +/// 换行进不了单行的字段;进得了的那一段(插件设置)之外的字段也不行 +#[test] +fn a_line_break_stays_out_of_single_line_fields() { + let base = doc(); + for s in ["a\nb", "a\rb", "\n"] { + let path = [Step::key("clients"), Step::Index(0), Step::key("client")]; + let e = edit::set(&base, &path, Some(&Value::String(s.into()))).unwrap_err(); + assert_eq!(e.msg().code, "config.edit.multiline", "{s:?}"); + let mut item = yaml_map(&format!( + "id: target\nfile: plugins/target.js\nsha256: {HASH}\nenabled: false\nsettings:\n note: old\n keep: 1\n" + )); + item.insert("scope".into(), Value::Mapping(yaml_map("models: [x]"))); + item["scope"]["models"][0] = Value::String(s.into()); + let e = edit::upsert(&base, PLUGINS, Some("target"), &item).unwrap_err(); + assert_eq!(e.msg().code, "config.edit.multiline", "{s:?}"); + } +} diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index 38917597..d518a896 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -25,6 +25,8 @@ pub struct ConfigManager { /// 磁盘上那份最近一次外部改动没通过校验(`Status.config_rejected`)。**是现状,不是 /// 那一刻**:半路才连上的界面按它补上那条提醒;换入成功就清掉 rejected: std::sync::Mutex>, + /// 每换入一份配置响一次([`Self::applied`])。默认插件那一路等着它 + applied: tokio::sync::Notify, } /// 一次配置改动没成的原因。 @@ -64,6 +66,10 @@ pub enum ApplyError { /// 从远程端口进来的写入改了 `listen.control` 这一节。 #[error("{}", self.msg())] RemoteControlLocked, + /// 这条路上做不了、要在系统的确认框里点过头的改动(改得了工具调用的插件:打开它、 + /// 改设置、改范围)。**和 `Invalid` 分开**:请求本身没写错,换那条确认过的路就做得成 + #[error("{0}")] + NeedsConfirmation(Msg), } impl ApplyError { @@ -76,7 +82,8 @@ impl ApplyError { ApplyError::Build(m) | ApplyError::BadPath(m) | ApplyError::Invalid(m) - | ApplyError::InUse(m) => m.clone(), + | ApplyError::InUse(m) + | ApplyError::NeedsConfirmation(m) => m.clone(), ApplyError::Stale { base, current } => msg!( "control.config_stale", base = base, current = current => "version mismatch: this edit is based on {base}, and the current version is \ @@ -109,9 +116,21 @@ impl ConfigManager { bus, seen: Mutex::new(seen), rejected: std::sync::Mutex::new(None), + applied: tokio::sync::Notify::new(), } } + /// 数据面。管理面里要碰插件文件、编插件的那几处从这里拿 + pub fn gateway(&self) -> &tw_gateway::AppState { + &self.gateway + } + + /// 等下一次换入配置(哪一条路进来的都算)。**只给一个等的人**(默认插件那一路): + /// 没人在等时响过的那一次记着,下一次等马上返回;连响几次只算一次 + pub async fn applied(&self) { + self.applied.notified().await; + } + /// 磁盘上那份配置此刻是不是没通过校验、旧的还在服务 pub fn rejected(&self) -> Option { self.rejected.lock().ok().and_then(|g| g.clone()) @@ -207,6 +226,7 @@ impl ConfigManager { let _ = tw_config::history::snapshot(&self.path, text, origin); let version = store::version_of(text); tracing::info!(%version, origin = origin.slug(), "the configuration is in effect"); + self.applied.notify_one(); self.bus.emit(tw_api::Event::ConfigReloaded { id: self.bus.next_id(), version: version.clone(), @@ -589,6 +609,7 @@ mod msg_codes { ApplyError::InUse(inner.clone()), ApplyError::BadPath(inner.clone()), ApplyError::Build(inner.clone()), + ApplyError::NeedsConfirmation(inner.clone()), ] { assert_eq!(e.msg(), inner); } diff --git a/crates/tw-control/src/keys.rs b/crates/tw-control/src/keys.rs index b4a7ed70..6d241eb0 100644 --- a/crates/tw-control/src/keys.rs +++ b/crates/tw-control/src/keys.rs @@ -43,6 +43,7 @@ pub(crate) const CLIENTS: edit::Section = edit::Section { path: &["clients"], what: "gateway key", key: "name", + multiline: &[], }; fn not_found(name: &str) -> ApplyError { diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index 6663c661..d08f353f 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -1452,7 +1452,9 @@ pub(crate) fn apply_fail(e: ApplyError) -> Fail { } ApplyError::Edit(EditError::NotFound { .. }) => StatusCode::NOT_FOUND, // 不是请求写错了,是这条路上不许改 - ApplyError::ControlKeyLocked | ApplyError::RemoteControlLocked => StatusCode::FORBIDDEN, + ApplyError::ControlKeyLocked + | ApplyError::RemoteControlLocked + | ApplyError::NeedsConfirmation(_) => StatusCode::FORBIDDEN, ApplyError::Rejected(_) | ApplyError::Build(_) | ApplyError::BadPath(_) diff --git a/crates/tw-control/src/plugins.rs b/crates/tw-control/src/plugins.rs index a1248146..da670f4f 100644 --- a/crates/tw-control/src/plugins.rs +++ b/crates/tw-control/src/plugins.rs @@ -8,13 +8,19 @@ //! 还原 —— 不留下一个和配置对不上的插件文件。整个过程攥着 `Plugins::edits`,目录 //! 监听不会落在两半之间。 //! -//! # 三个端点网页调不了 +//! # 四个端点网页调不了 //! //! 装(`CreatePlugin`)、换源码(`ReplacePluginSource`)、批准改过的文件 //! (`ApprovePluginFile`)**不在桌面端网页的 `call` 白名单里**(不变式 I12):这三件事 //! 要在系统的确认框里点头,那一步在桌面端的 Rust 里,它自己再编一遍源码,把名字、 //! 权限和哈希摆给人看。所以这里不假设调用方看过什么:源码在这里再编一遍,批准时 //! 磁盘上的文件得正好是调用方看过的那一份(哈希核对)。 +//! +//! 第四个是**确认过的改动**(`UpdatePluginConfirmed`)。改得了回答里工具调用的插件 +//! (`reply_tool_calls`)决定客户端执行什么:网页里注入的脚本要是能打开它、改它的设置 +//! 或范围,就能借它改客户端要跑的命令。所以 `UpdatePlugin`(网页调得到)对这种插件只做 +//! 停用、改出错时怎么办,打开、改设置、改范围要走确认过的那一条。**读不出权限的插件按 +//! 改得了算**:它此刻跑不了,可一旦又跑得了(运行时恢复了),网页替它打开的开关就生效了。 use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -33,6 +39,8 @@ use tw_types::{Msg, msg}; use crate::contract::RouterExt; use crate::{ApplyError, ControlState, Fail, apply_fail, fail, internal}; +pub mod defaults; + pub fn router() -> axum::Router { axum::Router::new() .at(ep::Plugins, list) @@ -40,6 +48,7 @@ pub fn router() -> axum::Router { .at(ep::CreatePlugin, create) .at(ep::ReorderPlugins, reorder) .at(ep::UpdatePlugin, update) + .at(ep::UpdatePluginConfirmed, update_confirmed) .at(ep::DeletePlugin, delete) .at(ep::ReplacePluginSource, replace_source) .at(ep::PluginSourceDiff, source_diff) @@ -619,20 +628,51 @@ async fn create( Ok(Json(tw_api::ConfigWritten { version })) } +/// 网页调得到的那一条:改得了工具调用的插件只能停用、改出错时怎么办(见模块说明) async fn update( State(s): State, UrlPath(id): UrlPath, Json(req): Json, +) -> Result, Fail> { + save(&s, &id, req, false).await +} + +/// 同一件事,桌面端在系统的确认框里点过头了:工具调用插件的开关、设置、范围也改得了。 +/// **网页不能调**(不在桌面端网页的白名单里) +async fn update_confirmed( + State(s): State, + UrlPath(id): UrlPath, + Json(req): Json, +) -> Result, Fail> { + save(&s, &id, req, true).await +} + +/// 改开关、出错时怎么办、范围、设置。`confirmed`:点过头了([`update_confirmed`]) +async fn save( + s: &ControlState, + id: &str, + req: tw_api::PluginUpdate, + confirmed: bool, ) -> Result, Fail> { check_scope(&req.scope)?; + // **攥着写插件的那把锁**:读到的权限和写下去的配置说的是同一份插件 —— 换源码、 + // 批准也攥着它,落不到两者之间 + let _edit = s.gateway.plugins.edits.lock().await; // 设置对着它此刻的 manifest 查。读不出 manifest(文件变了、底稿也没了)就照交来的 // 写:加载时还会再查一遍 - let manifest = s - .gateway - .runtime() - .plugins - .get(&id) - .and_then(|a| a.manifest.clone()); + let (approved, manifest, name) = { + let rt = s.gateway.runtime(); + let approved = rt + .config + .plugins + .iter() + .find(|p| p.id == id) + .map(|p| p.sha256.clone()); + let a = rt.plugins.get(id); + let manifest = a.and_then(|a| a.manifest.clone()); + let name = a.map_or_else(|| id.to_string(), |a| a.name.clone()); + (approved, manifest, name) + }; let settings = match &manifest { Some(m) => settings_for(m, &req.settings)?, None => req.settings.clone(), @@ -644,22 +684,76 @@ async fn update( .plugins .iter() .find(|p| p.id == id) - .ok_or_else(|| missing(&id))?; + .ok_or_else(|| missing(id))?; + // manifest 得是配置里批准的那一份的;对不上(配置刚被别处改了)就是读不出 + let known = manifest + .as_ref() + .filter(|_| approved.as_deref() == Some(p.sha256.as_str())); + if !confirmed && steers_tool_calls(known) && changes_what_it_does(p, &req, known) { + return Err(ApplyError::NeedsConfirmation(msg!( + "control.plugin.needs_confirmation", plugin = &name => + "Turning on plugin `{plugin}`, or changing its settings or scope, has to be \ + confirmed in the app, because the plugin may change the tool calls in replies." + ))); + } let item = entry( - &id, + id, &p.sha256, req.enabled, req.on_error, &req.scope, &settings, ); - Ok(edit::upsert(text, edit::PLUGINS, Some(&id), &item)?) + Ok(edit::upsert(text, edit::PLUGINS, Some(id), &item)?) }) .await .map_err(apply_fail)?; Ok(Json(tw_api::ConfigWritten { version })) } +/// 改得了回答里的工具调用:权限里有 `reply_tool_calls`,**或者读不出它要什么权限** +fn steers_tool_calls(m: Option<&Manifest>) -> bool { + m.is_none_or(|m| m.permissions.contains(&tw_api::Permission::ReplyToolCalls)) +} + +/// 这次改动里有没有要点头的:打开它、改设置、改范围。停用、改出错时怎么办都不算。 +/// **比的是生效的样子**:配置里没写的设置按默认值算,范围不看顺序和重复 +fn changes_what_it_does( + p: &tw_config::Plugin, + req: &tw_api::PluginUpdate, + m: Option<&Manifest>, +) -> bool { + let turns_on = req.enabled && !p.enabled; + let norm = |v: &[String]| { + let mut v: Vec = v.iter().map(|x| x.trim().to_string()).collect(); + v.sort_unstable(); + v.dedup(); + v + }; + let scope = norm(&p.scope.clients) != norm(&req.scope.clients) + || norm(&p.scope.models) != norm(&req.scope.models) + || norm(&p.scope.upstreams) != norm(&req.scope.upstreams); + let now: BTreeMap = p + .settings + .iter() + .filter_map(|(k, v)| Some((k.clone(), from_yaml(v)?))) + .collect(); + let effective = |given: &BTreeMap| { + let all = tw_gateway::plugin::load::settings_of(m?, given).ok()?; + Some( + all.iter() + .filter_map(|(k, v)| Some((k.clone(), from_json(v)?))) + .collect::>(), + ) + }; + let settings = match (effective(&now), effective(&req.settings)) { + (Some(a), Some(b)) => a != b, + // 算不出生效的样子(读不出 manifest、配置里的设置本来就不对):照写的比 + _ => now != req.settings, + }; + turns_on || scope || settings +} + async fn replace_source( State(s): State, UrlPath(id): UrlPath, diff --git a/crates/tw-control/src/plugins/defaults.rs b/crates/tw-control/src/plugins/defaults.rs new file mode 100644 index 00000000..02314db7 --- /dev/null +++ b/crates/tw-control/src/plugins/defaults.rs @@ -0,0 +1,451 @@ +//! 默认插件(随 core 发的那几个,清单在 [`tw_gateway::plugin::defaults`]):第一次见到时 +//! 装上,**停用着**;出了新版、而用户没动过它时,换成新版。 +//! +//! # 给过什么记在哪儿 +//! +//! 插件目录里的 `.defaults.json`:`{ "offered": { "": "<给出去的那一版的 SHA-256>" } }`。 +//! 每一次(启动时、每换入一份配置之后)对着它和配置走一遍: +//! +//! - **没给过的**:配置里已经有这个 id(用户自己的插件)就只记一笔「给过了」;否则写 +//! 插件文件和底稿,配置里加一条 —— 停用、出错时拒绝、范围照 manifest、设置都是默认值、 +//! 哈希是发出去的那份字节的 —— 再记下来; +//! - **给过、配置里还在、文件和批准的都还是给出去的那一份,而 core 带的已经是新版**: +//! 换文件、底稿和配置里的哈希;开关、出错时怎么办、范围和还声明着的设置照旧,新声明的 +//! 设置取默认值;**新版要了旧版没要的权限就停用**;记下新版; +//! - **给过、配置里没有了**:用户删的。**不再加回去**; +//! - **给过、文件被用户改过**(或者批准的已经是别的一份):不动。 +//! +//! 文件和配置都已经是新版、只是上次没来得及记下来的(写记录那一步失败了),补记一笔。 +//! +//! # 和别的写入怎么排 +//! +//! 整个过程攥着 `Plugins::edits`,和控制面写插件文件、目录监听是同一把锁;配置照别的 +//! 按资源写入一样走 `ConfigManager::transform`(核版本、先校验再写、保留注释、存历史, +//! 来源记成 `defaults`),**这一次要加、要换的一次写进去**:一版配置、一条历史。配置在 +//! 这中间被别处改了(版本对不上),刚写的文件还原,等那一次换入之后再走一遍。 +//! +//! # 不挡启动、不挡换配置 +//! +//! 哪一步不成只落在那一个插件上:记一行日志、发一条 `plugin_failed`(同一个问题只说 +//! 一次),这次不记「给过了」,下一次换入配置再试。换配置本身在这之前已经成了 —— +//! 这一路是换完之后才走的([`spawn`])。 + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; + +use axum::Json; +use serde::{Deserialize, Serialize}; +use tw_config::edit; +use tw_config::history::Origin; +use tw_gateway::plugin::Manifest; +use tw_gateway::plugin::load::{read_capped, sha256_hex}; +use tw_types::Msg; + +use crate::{ApplyError, ConfigManager}; + +/// 记着给过哪些默认插件的文件,在插件目录里。点开头:它不是插件 +pub const RECORD: &str = ".defaults.json"; + +/// `.defaults.json` 在哪儿。`dir` 是配置文件所在的目录 +pub fn record_path(dir: &Path) -> PathBuf { + tw_config::plugins::dir_in(dir).join(RECORD) +} + +/// `.defaults.json` 的内容 +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + /// id → 给出去的那一版的 SHA-256 + offered: BTreeMap, +} + +/// 随 core 发的一个插件 +struct Shipped { + id: String, + source: String, + /// 源码字节的 SHA-256:给出去的就是这一版 + sha256: String, +} + +/// 一次走下来做了什么。 +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Seeded { + /// 加进配置的(停用着) + pub added: Vec, + /// 换成了新版的 + pub updated: Vec, + /// 换成新版时停用了的:开着,而新版要了旧版没要的权限。也在 `updated` 里 + pub disabled: Vec, + /// 只记了一笔「给过了」的:用户自己的插件占着这个 id,或者新版已经装上了 + pub marked: Vec, + /// 这次没办成的,和原因。下一次换入配置再试 + pub failed: Vec<(String, Msg)>, +} + +impl Seeded { + fn did_something(&self) -> bool { + !(self.added.is_empty() && self.updated.is_empty() && self.marked.is_empty()) + } +} + +/// 补齐默认插件的那一路。**跨多次走存活**:同一个问题只说一次。 +pub struct Seeder { + shipped: Vec, + told: Mutex, +} + +/// 说过的问题 +#[derive(Default)] +struct Told { + /// 按插件 id + plugins: HashMap, + /// 记录文件读不出来的原因 + record: Option, +} + +/// 这一次要写进配置的一项 +struct Change { + /// 在 `Seeder::shipped` 里的位置 + at: usize, + /// 配置里的那一条 + item: serde_yaml_ng::Mapping, + /// 新加的是 None;换新版的是配置里批准的那个哈希(换之前的那一版) + replaces: Option, + /// 开着、换成新版时停用了 + disabled: bool, +} + +impl Seeder { + /// 一组 (id, 源码)。**测试拿自己的插件走这一条**,生产用 [`Seeder::shipped`] + pub fn new<'a>(shipped: impl IntoIterator) -> Self { + Self { + shipped: shipped + .into_iter() + .map(|(id, source)| Shipped { + id: id.to_string(), + sha256: sha256_hex(source.as_bytes()), + source: source.to_string(), + }) + .collect(), + told: Mutex::default(), + } + } + + /// 随 core 发的那几个 + pub fn shipped() -> Self { + Self::new(tw_gateway::plugin::defaults::ALL.iter().copied()) + } + + /// 走一遍(见模块说明)。**不会失败**:没办成的落在那一个插件上,下一次再试。 + pub async fn seed(&self, mgr: &ConfigManager) -> Seeded { + let gw = mgr.gateway(); + let _edit = gw.plugins.edits.lock().await; + let mut out = Seeded::default(); + let mut names: HashMap = HashMap::new(); + let dir = super::dir_of(mgr.path()); + let record_file = record_path(&dir); + let mut record = match read_record(&record_file) { + Ok(r) => r, + // 记录读不出来:分不清哪些是用户删掉的,宁可一个都不加 + Err(why) => { + let mut told = self.told.lock().unwrap_or_else(PoisonError::into_inner); + if told.record.as_ref() != Some(&why) { + tracing::warn!( + file = %record_file.display(), + "the record of the default plugins cannot be read, so none are added or \ + updated until it is fixed or removed: {why}" + ); + told.record = Some(why); + } + return out; + } + }; + let Ok(cur) = mgr.current() else { + return out; + }; + // 磁盘上那份此刻读不了(正在被人改):等它下一次换入成功 + let Ok(cfg) = tw_config::try_parse(&cur.text) else { + return out; + }; + let before = record.clone(); + + let mut plan: Vec = Vec::new(); + for (at, s) in self.shipped.iter().enumerate() { + let entry = cfg.plugins.iter().find(|p| p.id == s.id); + let offered = record.offered.get(&s.id).cloned(); + match (offered, entry) { + // 用户自己的插件占着这个 id:不动它,记下给过了 + (None, Some(_)) => { + record.offered.insert(s.id.clone(), s.sha256.clone()); + out.marked.push(s.id.clone()); + } + (None, None) => match compile(mgr, s.source.as_bytes(), true).await { + Ok(m) => { + names.insert(s.id.clone(), m.name.clone()); + plan.push(Change { + at, + item: super::entry( + &s.id, + &s.sha256, + false, + tw_api::OnError::Reject, + &scope_of(&m), + &super::reconcile(&m, &BTreeMap::new()), + ), + replaces: None, + disabled: false, + }); + } + Err(why) => out.failed.push((s.id.clone(), why)), + }, + // 用户删掉的:不再加回去 + (Some(_), None) => {} + (Some(o), Some(_)) if o == s.sha256 => {} + (Some(o), Some(p)) => { + let file = tw_config::plugins::file_path(&dir, &s.id); + let bytes = match read_capped(&file) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + out.failed + .push((s.id.clone(), unreadable(&file.display().to_string(), e))); + continue; + } + }; + let on_disk = bytes.as_deref().map(sha256_hex); + if on_disk.as_deref() == Some(o.as_str()) && p.sha256 == o { + // 没动过:换成新版 + let new = match compile(mgr, s.source.as_bytes(), true).await { + Ok(m) => m, + Err(why) => { + out.failed.push((s.id.clone(), why)); + continue; + } + }; + // 旧版要过哪些权限。读不出来就当新版多要了 —— 宁可停用 + let old = match bytes { + Some(b) => compile(mgr, &b, false).await.ok(), + None => None, + }; + let more = old.as_ref().is_none_or(|old| { + new.permissions.iter().any(|x| !old.permissions.contains(x)) + }); + names.insert(s.id.clone(), new.name.clone()); + plan.push(Change { + at, + item: super::entry( + &s.id, + &s.sha256, + p.enabled && !more, + p.on_error.into(), + &super::scope_view(&p.scope), + &super::reconcile(&new, &p.settings), + ), + replaces: Some(o), + disabled: p.enabled && more, + }); + } else if on_disk.as_deref() == Some(s.sha256.as_str()) && p.sha256 == s.sha256 + { + // 新版已经装上了,只是上次没记下来 + record.offered.insert(s.id.clone(), s.sha256.clone()); + out.marked.push(s.id.clone()); + } + // 否则是用户改过的:不动 + } + } + } + + // 一项一项先在这份原文上试写一遍:写不进去的(值写不成 YAML、配置校验不过) + // 只去掉那一项,不连累别的 + plan.retain(|c| { + let current = c.replaces.as_ref().map(|_| self.shipped[c.at].id.as_str()); + let tried = edit::upsert(&cur.text, edit::PLUGINS, current, &c.item) + .map_err(|e| e.msg()) + .and_then(|t| tw_config::try_parse(&t).map(|_| ()).map_err(|r| r.msg())); + match tried { + Ok(()) => true, + Err(why) => { + out.failed.push((self.shipped[c.at].id.clone(), why)); + false + } + } + }); + + // 先写文件(插件文件和底稿),写不成的那一项去掉 + let mut undo = Vec::new(); + plan.retain(|c| { + let s = &self.shipped[c.at]; + let src = s.source.as_bytes(); + let files = [ + (tw_config::plugins::file_path(&dir, &s.id), src), + (tw_config::plugins::approved_path(&dir, &s.id), src), + ]; + match super::write_files(&dir, &files) { + Ok(u) => { + undo.push(u); + true + } + Err((_, Json(why))) => { + out.failed.push((s.id.clone(), why)); + false + } + } + }); + + // 再写配置。**照走这一遍时读到的那一版写**:中间被别处改了就整个作罢、文件还原, + // 那一次换入之后会再走一遍 + if !plan.is_empty() { + let base = cur.version(); + let written = mgr + .transform(Some(base.as_str()), Origin::Defaults, |text, _| { + let mut text = text.to_string(); + for c in &plan { + let current = c.replaces.as_ref().map(|_| self.shipped[c.at].id.as_str()); + text = edit::upsert(&text, edit::PLUGINS, current, &c.item)?; + } + Ok(text) + }) + .await; + match written { + Ok(_) => { + for c in &plan { + let s = &self.shipped[c.at]; + record.offered.insert(s.id.clone(), s.sha256.clone()); + if c.replaces.is_some() { + out.updated.push(s.id.clone()); + } else { + out.added.push(s.id.clone()); + } + if c.disabled { + out.disabled.push(s.id.clone()); + } + } + } + Err(e) => { + for u in undo { + u.restore(); + } + match e { + // 别处刚写了配置:那一次换入会再叫这一路 + ApplyError::Stale { .. } + | ApplyError::Store(tw_config::StoreError::Conflict { .. }) => { + tracing::debug!( + "the configuration changed while default plugins were being added; trying again after it" + ); + } + e => { + let why = e.msg(); + for c in &plan { + out.failed + .push((self.shipped[c.at].id.clone(), why.clone())); + } + } + } + } + } + } + + if record != before + && let Err(e) = write_record(&dir, &record) + { + // 下一次走的时候按配置和文件补记得上:这里只说一声 + tracing::warn!(file = %record_file.display(), "the record of the default plugins could not be written: {e}"); + } + self.tell(mgr, &out, &names); + out + } + + /// 把这一次的结果说出去:做了什么记一行日志;没办成的每个插件发一条 `plugin_failed`, + /// **同一个问题只说一次**,办成了就忘掉它 + fn tell(&self, mgr: &ConfigManager, out: &Seeded, names: &HashMap) { + if out.did_something() { + tracing::info!( + added = ?out.added, + updated = ?out.updated, + disabled = ?out.disabled, + marked = ?out.marked, + "default plugins offered" + ); + } + let mut told = self.told.lock().unwrap_or_else(PoisonError::into_inner); + for id in out.added.iter().chain(&out.updated).chain(&out.marked) { + told.plugins.remove(id); + } + // 记录文件读得出来了 + told.record = None; + let bus = &mgr.gateway().bus; + for (id, why) in &out.failed { + if told.plugins.get(id) == Some(why) { + continue; + } + told.plugins.insert(id.clone(), why.clone()); + let name = names.get(id).cloned().unwrap_or_else(|| id.clone()); + tracing::warn!(plugin = %id, "a default plugin could not be set up: {why}"); + bus.emit(tw_api::Event::PluginFailed { + id: bus.next_id(), + plugin_id: id.clone(), + plugin_name: name, + request_id: None, + message: why.clone(), + at_ms: crate::config::now_ms(), + }); + } + } +} + +/// 启动时走过一遍之后,**每换入一份配置再走一遍**(哪一条路进来的都算,它自己写的那一次 +/// 也算 —— 再走一遍什么都不做)。返回的任务不用留着:跟着进程走 +pub fn spawn(seeder: Seeder, mgr: Arc) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + loop { + mgr.applied().await; + seeder.seed(&mgr).await; + } + }) +} + +fn scope_of(m: &Manifest) -> tw_api::PluginScope { + tw_api::PluginScope { + clients: m.scope.clients.clone(), + models: m.scope.models.clone(), + upstreams: m.scope.upstreams.clone(), + } +} + +/// 编一遍读出 manifest。**放到阻塞线程上**。`keep`:结果留进缓存(马上要装上的那一份, +/// 紧接着的重载不再编),否则什么都不留 +async fn compile(mgr: &ConfigManager, source: &[u8], keep: bool) -> Result { + let plugins = mgr.gateway().plugins.clone(); + let source = source.to_vec(); + tokio::task::spawn_blocking(move || { + let compiled = if keep { + plugins.prepare(&source) + } else { + plugins.inspect(&source) + }; + compiled.map(|h| h.manifest().clone()).map_err(|e| e.msg()) + }) + .await + .unwrap_or_else(|e| Err(crate::internal(e).1.0)) +} + +fn unreadable(file: &str, e: std::io::Error) -> Msg { + super::unreadable(file, e).1.0 +} + +fn read_record(path: &Path) -> Result { + match std::fs::read(path) { + Ok(b) => serde_json::from_slice(&b).map_err(|e| e.to_string()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Record::default()), + Err(e) => Err(e.to_string()), + } +} + +/// 写记录:和插件文件一样只给自己(目录 0700、文件 0600),原子替换 +fn write_record(dir: &Path, record: &Record) -> std::io::Result<()> { + tw_config::private_dir::create(&tw_config::plugins::dir_in(dir))?; + let mut bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?; + bytes.push(b'\n'); + super::write_private(&record_path(dir), &bytes) +} diff --git a/crates/tw-control/src/security.rs b/crates/tw-control/src/security.rs index 550fae99..3d5548f3 100644 --- a/crates/tw-control/src/security.rs +++ b/crates/tw-control/src/security.rs @@ -79,16 +79,19 @@ impl GuardExt for Guard { path: &["security", "redact", "custom"], what: "redaction rule", key: "name", + multiline: &[], }), Guard::InspectTools => Ok(edit::Section { path: &["security", "inspect_tools", "custom"], what: "tool-call rule", key: "name", + multiline: &[], }), Guard::Content => Ok(edit::Section { path: &["security", "content", "custom"], what: "content rule", key: "name", + multiline: &[], }), Guard::HiddenText | Guard::OutputLimit => Err(fail( StatusCode::BAD_REQUEST, diff --git a/crates/tw-control/tests/plugin_defaults.rs b/crates/tw-control/tests/plugin_defaults.rs new file mode 100644 index 00000000..3c66d170 --- /dev/null +++ b/crates/tw-control/tests/plugin_defaults.rs @@ -0,0 +1,668 @@ +//! 默认插件:第一次装上(停用着)、用户删了不再加回、用户改了不动、出了新版换上、 +//! 新版多要了权限就停用、用户自己的插件占着那个 id 不动、配置不在默认位置也一样, +//! 以及每换入一份配置再走一遍。 +//! +//! 断言落在磁盘上:插件文件和底稿、配置里那一条、`plugins/.defaults.json`。规则用自己 +//! 造的几个插件测(假引擎);随 core 发的那一份清单另用真的沙箱整个走一遍。 + +use std::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::plugins::defaults::{Seeded, Seeder, record_path}; +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, + mgr: 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 entry(&self, id: &str) -> Option { + self.parsed().plugins.into_iter().find(|p| p.id == id) + } + 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) + } + fn read(&self, path: PathBuf) -> String { + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) + } + /// `.defaults.json` 里记着的:id → 哈希 + fn offered(&self) -> Value { + let text = std::fs::read_to_string(record_path(&self.dir)).unwrap(); + let v: Value = serde_json::from_str(&text).unwrap(); + v["offered"].clone() + } + async fn version(&self) -> String { + let (_, v) = call(&self.app, "GET", "/overview", None).await; + v["config_version"].as_str().unwrap().to_string() + } + async fn plugin(&self, id: &str) -> Value { + let (st, v) = call(&self.app, "GET", "/plugins", None).await; + assert_eq!(st, StatusCode::OK, "{v}"); + v.as_array() + .unwrap() + .iter() + .find(|p| p["id"] == id) + .cloned() + .unwrap_or_else(|| panic!("no plugin {id}: {v}")) + } + /// 开着、出错时跳过、范围和设置都改过 —— 用户用过一阵子的样子 + async fn customize(&self, id: &str, settings: Value) { + let (st, v) = call( + &self.app, + "PUT", + &format!("/plugins/{id}/confirmed"), + Some(json!({"enabled": true, "on_error": "skip", + "scope": {"clients": [], "models": ["deepseek-chat"], "upstreams": []}, + "settings": settings, "base_version": self.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + } +} + +fn bed_in(sub: &str, fake: bool) -> 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 gw = tw_gateway::AppState::new(tw_config::try_parse(BASE).unwrap()).unwrap(); + if fake { + gw.set_plugin_engine(Arc::new(FakeEngine)); + } + let mgr = Arc::new(ConfigManager::new(p, gw.clone(), gw.bus.clone())); + let state = ControlState { + shutdown: Default::default(), + remote: Default::default(), + cfg: mgr.clone(), + gateway: gw.clone(), + store: None, + started: std::time::Instant::now(), + price_updater: Default::default(), + chatgpt: Default::default(), + zai: Default::default(), + }; + Bed { + _tmp: tmp, + dir, + gw, + mgr, + app: tw_control::router(state), + } +} + +fn bed() -> Bed { + bed_in("home", true) +} + +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 sha(s: &str) -> String { + tw_gateway::plugin::load::sha256_hex(s.as_bytes()) +} + +/// 第一版:改系统指令,两项设置,其中一项默认值有两行;只管 deepseek 开头的模型 +fn alpha() -> String { + source( + json!({"name": "Alpha", "api": 1, "description": "first", + "permissions": ["system"], "match": {"models": ["deepseek*"]}, + "settings": {"lang": {"type": "string", "label": "语言", "default": "简体中文"}, + "terms": {"type": "string", "label": "对照表", "default": "登陆=登录\n帐号=账号"}}}), + &["onRequest"], + ) +} + +/// 第二版:权限不变;`terms` 不要了,多了一个 `count` +fn alpha_v2() -> String { + source( + json!({"name": "Alpha", "api": 1, "description": "second", + "permissions": ["system"], "match": {"models": ["deepseek*"]}, + "settings": {"lang": {"type": "string", "label": "语言", "default": "English"}, + "count": {"type": "number", "label": "次数", "default": 3}}}), + &["onRequest"], + ) +} + +/// 第三版:多要了 `messages` +fn alpha_v3() -> String { + source( + json!({"name": "Alpha", "api": 1, "description": "third", + "permissions": ["system", "messages"], "match": {"models": ["deepseek*"]}, + "settings": {"lang": {"type": "string", "label": "语言", "default": "简体中文"}}}), + &["onRequest"], + ) +} + +fn beta() -> String { + source( + json!({"name": "Beta", "api": 1, "permissions": ["reply.text"]}), + &["onReplyText"], + ) +} + +fn seeder(list: &[(&str, &str)]) -> Seeder { + Seeder::new(list.iter().copied()) +} + +fn ids(v: &[String]) -> Vec<&str> { + v.iter().map(String::as_str).collect() +} + +/// 第一次:文件、底稿、配置里一条(停用、出错时拒绝、范围照 manifest、设置都是默认值), +/// 记录里记着给出去的哈希。再走一遍什么都不做 +#[tokio::test] +async fn the_first_run_adds_every_default_turned_off() { + let b = bed(); + let (a, c) = (alpha(), beta()); + let s = seeder(&[("alpha", &a), ("beta", &c)]); + let done = s.seed(&b.mgr).await; + assert_eq!(ids(&done.added), ["alpha", "beta"], "{done:?}"); + assert!(done.failed.is_empty(), "{done:?}"); + + assert_eq!(b.read(b.file("alpha")), a); + assert_eq!(b.read(b.approved("alpha")), a); + assert_eq!(b.read(b.file("beta")), c); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for p in [b.file("alpha"), b.approved("alpha"), record_path(&b.dir)] { + let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "{}", p.display()); + } + } + let p = b.entry("alpha").unwrap(); + assert!(!p.enabled); + assert_eq!(p.on_error, tw_config::PluginOnError::Reject); + assert_eq!(p.scope.models, ["deepseek*"]); + assert_eq!(p.sha256, sha(&a)); + assert_eq!(p.settings["lang"], serde_yaml_ng::Value::from("简体中文")); + // 两行的默认值照样写进去:一行双引号,读回来一字不差 + assert_eq!( + p.settings["terms"], + serde_yaml_ng::Value::from("登陆=登录\n帐号=账号") + ); + assert!( + b.config() + .contains(" terms: \"登陆=登录\\n帐号=账号\"\n"), + "{}", + b.config() + ); + assert!(b.config().contains("# 默认那把"), "{}", b.config()); + assert_eq!(b.offered(), json!({"alpha": sha(&a), "beta": sha(&c)})); + + // 和用户装的插件在同一张单子上,停用着,能跑 + let v = b.plugin("alpha").await; + assert_eq!(v["status"], json!({"kind": "disabled"})); + assert_eq!(v["name"], "Alpha"); + assert!( + b.gw.runtime() + .plugins + .get("alpha") + .unwrap() + .ready() + .is_some() + ); + + // 写配置的这一版来源是 defaults,一版、一条历史 + let (_, history) = call(&b.app, "GET", "/config/history", None).await; + let now = history + .as_array() + .unwrap() + .iter() + .find(|v| v["current"] == true) + .cloned() + .unwrap_or_else(|| panic!("{history}")); + assert_eq!(now["origin"], "defaults", "{history}"); + + let text = b.config(); + let again = s.seed(&b.mgr).await; + assert_eq!(again, Seeded::default()); + assert_eq!(b.config(), text); +} + +/// 用户删掉的默认插件不再回来:这一次不回来,重启之后(新的一路)也不回来 +#[tokio::test] +async fn a_default_the_user_deleted_never_comes_back() { + let b = bed(); + let a = alpha(); + seeder(&[("alpha", &a)]).seed(&b.mgr).await; + let (st, v) = call( + &b.app, + "DELETE", + &format!("/plugins/alpha?base_version={}", b.version().await), + None, + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + for list in [vec![("alpha", a.as_str())], vec![("alpha", &*alpha_v2())]] { + let done = seeder(&list).seed(&b.mgr).await; + assert_eq!(done, Seeded::default()); + assert!(b.entry("alpha").is_none(), "{}", b.config()); + assert!(!b.file("alpha").exists() && !b.approved("alpha").exists()); + assert_eq!(b.offered(), json!({"alpha": sha(&a)})); + } +} + +/// 用户改过文件(批准了也好、没批准也好、换了源码也好):新版来了也不动它。 +/// 同一次里没动过的那一个照样换成新版 +#[tokio::test] +async fn a_default_the_user_changed_is_left_alone() { + let b = bed(); + let a = alpha(); + let c = beta(); + let d = beta().replace("Beta", "Gamma"); + let e = beta().replace("Beta", "Delta"); + seeder(&[("alpha", &a), ("beta", &c), ("gamma", &d), ("delta", &e)]) + .seed(&b.mgr) + .await; + + // alpha:磁盘上的文件被改了,没批准 + let edited = format!("{a}// 用户加的一行\n"); + std::fs::write(b.file("alpha"), &edited).unwrap(); + // beta:改了、也批准了 + let approved = format!("{c}// 批准过的改动\n"); + std::fs::write(b.file("beta"), &approved).unwrap(); + b.gw.reload_plugins(); + let (st, v) = call( + &b.app, + "POST", + "/plugins/beta/approve", + Some(json!({"sha256": sha(&approved)})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + // gamma:换了一份源码 + let replaced = source( + json!({"name": "Mine", "api": 1, "permissions": ["reply.text"]}), + &["onReplyText"], + ); + let (st, v) = call( + &b.app, + "PUT", + "/plugins/gamma/source", + Some(json!({"source": replaced})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let entries = |b: &Bed| ["alpha", "beta", "gamma"].map(|id| b.entry(id).unwrap()); + let before = entries(&b); + + let newer = beta().replace("\"api\":1", "\"api\":1,\"description\":\"v2\""); + assert_ne!(newer, c); + let delta = newer.replace("Beta", "Delta"); + let done = seeder(&[ + ("alpha", &alpha_v2()), + ("beta", &newer), + ("gamma", &newer.replace("Beta", "Gamma")), + ("delta", &delta), + ]) + .seed(&b.mgr) + .await; + assert_eq!(ids(&done.updated), ["delta"], "{done:?}"); + assert!(done.added.is_empty() && done.marked.is_empty() && done.failed.is_empty()); + assert_eq!(entries(&b), before); + assert_eq!(b.read(b.file("alpha")), edited); + assert_eq!(b.read(b.approved("alpha")), a); + assert_eq!(b.read(b.file("beta")), approved); + assert_eq!(b.read(b.file("gamma")), replaced); + assert_eq!(b.read(b.file("delta")), delta); + assert_eq!( + b.offered(), + json!({"alpha": sha(&a), "beta": sha(&c), "gamma": sha(&d), "delta": sha(&delta)}) + ); +} + +/// 出了新版、用户没动过:文件、底稿、哈希换成新版;开关、出错时怎么办、范围、还声明着的 +/// 设置照旧,新声明的设置取默认值,不再声明的去掉 +#[tokio::test] +async fn a_new_version_replaces_an_untouched_default_and_keeps_its_settings() { + let b = bed(); + let a = alpha(); + seeder(&[("alpha", &a)]).seed(&b.mgr).await; + b.customize("alpha", json!({"lang": "日本語", "terms": "a=b"})) + .await; + + let v2 = alpha_v2(); + let done = seeder(&[("alpha", &v2)]).seed(&b.mgr).await; + assert_eq!(ids(&done.updated), ["alpha"], "{done:?}"); + assert!( + done.disabled.is_empty() && done.failed.is_empty(), + "{done:?}" + ); + assert_eq!(b.read(b.file("alpha")), v2); + assert_eq!(b.read(b.approved("alpha")), v2); + let p = b.entry("alpha").unwrap(); + assert_eq!(p.sha256, sha(&v2)); + assert!(p.enabled); + assert_eq!(p.on_error, tw_config::PluginOnError::Skip); + assert_eq!(p.scope.models, ["deepseek-chat"]); + assert_eq!(p.settings["lang"], serde_yaml_ng::Value::from("日本語")); + assert_eq!(p.settings["count"], serde_yaml_ng::Value::from(3)); + assert!(!p.settings.contains_key("terms"), "{:?}", p.settings); + assert_eq!(b.offered(), json!({"alpha": sha(&v2)})); + let v = b.plugin("alpha").await; + assert_eq!(v["status"], json!({"kind": "ok"})); + assert_eq!(v["description"], "second"); +} + +/// 新版已经换上了、记录却没写成(写记录那一步失败了):补记一笔,别的什么都不动 —— +/// 不会把已经换上的新版当成「用户改过的」 +#[tokio::test] +async fn a_lost_record_of_an_update_is_written_again_and_nothing_else_moves() { + let b = bed(); + let a = alpha(); + seeder(&[("alpha", &a)]).seed(&b.mgr).await; + b.customize("alpha", json!({"lang": "日本語"})).await; + let v2 = alpha_v2(); + seeder(&[("alpha", &v2)]).seed(&b.mgr).await; + // 记录退回到更新之前 + std::fs::write( + record_path(&b.dir), + json!({"offered": {"alpha": sha(&a)}}).to_string(), + ) + .unwrap(); + let before = b.config(); + let done = seeder(&[("alpha", &v2)]).seed(&b.mgr).await; + assert_eq!(ids(&done.marked), ["alpha"], "{done:?}"); + assert!( + done.updated.is_empty() && done.failed.is_empty(), + "{done:?}" + ); + assert_eq!(b.config(), before); + assert_eq!(b.offered(), json!({"alpha": sha(&v2)})); + // 再出一版时照常更新 + let v4 = alpha_v2().replace("second", "fourth"); + let done = seeder(&[("alpha", &v4)]).seed(&b.mgr).await; + assert_eq!(ids(&done.updated), ["alpha"], "{done:?}"); + assert!(b.entry("alpha").unwrap().enabled); +} + +/// 新版要了旧版没要的权限:换上,但停用 —— 用户没答应过的权限不该悄悄开着 +#[tokio::test] +async fn a_new_version_that_wants_more_permissions_comes_back_turned_off() { + let b = bed(); + let a = alpha(); + seeder(&[("alpha", &a)]).seed(&b.mgr).await; + b.customize("alpha", json!({"lang": "日本語"})).await; + + let v3 = alpha_v3(); + let done = seeder(&[("alpha", &v3)]).seed(&b.mgr).await; + assert_eq!(ids(&done.updated), ["alpha"], "{done:?}"); + assert_eq!(ids(&done.disabled), ["alpha"], "{done:?}"); + let p = b.entry("alpha").unwrap(); + assert_eq!(p.sha256, sha(&v3)); + assert!(!p.enabled); + assert_eq!(p.on_error, tw_config::PluginOnError::Skip); + assert_eq!(p.scope.models, ["deepseek-chat"]); + assert_eq!(p.settings["lang"], serde_yaml_ng::Value::from("日本語")); + let v = b.plugin("alpha").await; + assert_eq!(v["status"], json!({"kind": "disabled"})); + assert_eq!(v["permissions"], json!(["system", "messages"])); +} + +/// 用户自己的插件正好用了一个默认插件的 id:只记一笔「给过了」,它的文件和配置都不动, +/// 之后出了新版也不动 +#[tokio::test] +async fn a_user_plugin_that_has_a_default_id_is_untouched() { + let b = bed(); + let mine = source( + json!({"name": "My own", "api": 1, "permissions": ["reply.text"]}), + &["onReplyText"], + ); + let (st, v) = call( + &b.app, + "POST", + "/plugins", + Some( + json!({"source": mine, "id": "alpha", "enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, "settings": {}}), + ), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let before = b.config(); + + let a = alpha(); + let done = seeder(&[("alpha", &a)]).seed(&b.mgr).await; + assert_eq!(ids(&done.marked), ["alpha"], "{done:?}"); + assert!(done.added.is_empty() && done.updated.is_empty(), "{done:?}"); + assert_eq!(b.config(), before); + assert_eq!(b.read(b.file("alpha")), mine); + assert_eq!(b.read(b.approved("alpha")), mine); + assert_eq!(b.offered(), json!({"alpha": sha(&a)})); + + let done = seeder(&[("alpha", &alpha_v2())]).seed(&b.mgr).await; + assert_eq!(done, Seeded::default()); + assert_eq!(b.config(), before); + assert_eq!(b.read(b.file("alpha")), mine); +} + +/// 远程 core:配置不在默认的地方,默认插件和记录就在那份配置旁边 +#[tokio::test] +async fn defaults_live_next_to_the_configuration_wherever_it_is() { + let b = bed_in("srv/thinkwatch/etc", true); + let a = alpha(); + let done = seeder(&[("alpha", &a)]).seed(&b.mgr).await; + assert_eq!(ids(&done.added), ["alpha"], "{done:?}"); + for p in [ + b.dir.join("plugins/alpha.js"), + b.dir.join("plugins/.approved/alpha.js"), + b.dir.join("plugins/.defaults.json"), + ] { + assert!(p.exists(), "{}", p.display()); + } + assert_eq!( + b.plugin("alpha").await["status"], + json!({"kind": "disabled"}) + ); +} + +/// 启动之后每换入一份配置再走一遍:这一次别处写了配置,默认插件跟着补上 +#[tokio::test] +async fn every_configuration_change_runs_it_again() { + let b = bed(); + let a = alpha(); + let _task = tw_control::plugins::defaults::spawn(seeder(&[("alpha", &a)]), b.mgr.clone()); + let (st, v) = call( + &b.app, + "PUT", + "/config", + Some(json!({"text": BASE.replace("# 默认那把", "# 改了一个注释"), + "base_version": b.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while b.entry("alpha").is_none() { + assert!( + std::time::Instant::now() < deadline, + "the default was not added after a configuration change" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert!(b.config().contains("# 改了一个注释"), "{}", b.config()); +} + +/// 一个编不成的默认插件:别的照样装上;它自己这次不记「给过了」,说一声,同一个问题 +/// 只说一次 +#[tokio::test] +async fn a_default_that_does_not_load_is_skipped_and_reported_once() { + let b = bed(); + let mut events = b.gw.bus.subscribe(); + let broken = format!("{}// @@syntax@@\n", beta()); + let a = alpha(); + let s = seeder(&[("broken", &broken), ("alpha", &a)]); + let done = s.seed(&b.mgr).await; + assert_eq!(ids(&done.added), ["alpha"], "{done:?}"); + assert_eq!(done.failed.len(), 1, "{done:?}"); + assert_eq!(done.failed[0].0, "broken"); + assert_eq!(done.failed[0].1.code, "gw.plugin.syntax_at"); + assert!(b.entry("broken").is_none()); + assert!(!b.file("broken").exists()); + assert_eq!(b.offered(), json!({"alpha": sha(&a)})); + + let mut failed = Vec::new(); + while let Ok(ev) = events.try_recv() { + if let tw_api::Event::PluginFailed { + plugin_id, + request_id, + message, + .. + } = ev + { + failed.push((plugin_id, request_id, message.code)); + } + } + assert_eq!( + failed, + [( + "broken".to_string(), + None, + "gw.plugin.syntax_at".to_string() + )] + ); + let again = s.seed(&b.mgr).await; + assert_eq!(again.failed.len(), 1); + while let Ok(ev) = events.try_recv() { + assert!( + !matches!(ev, tw_api::Event::PluginFailed { .. }), + "the same problem was announced twice: {ev:?}" + ); + } +} + +/// 记录读不出来:分不清哪些是用户删掉的,一个都不加、什么都不改 +#[tokio::test] +async fn an_unreadable_record_adds_nothing() { + let b = bed(); + std::fs::create_dir_all(b.dir.join("plugins")).unwrap(); + std::fs::write(record_path(&b.dir), "{ not json").unwrap(); + let done = seeder(&[("alpha", &alpha())]).seed(&b.mgr).await; + assert_eq!(done, Seeded::default()); + assert_eq!(b.config(), BASE); + assert_eq!( + std::fs::read_to_string(record_path(&b.dir)).unwrap(), + "{ not json" + ); +} + +/// 随 core 发的那一份清单,真的沙箱:六个全装上、都停用着、都能跑;两行的默认设置 +/// 写得进配置 +#[tokio::test] +async fn the_shipped_defaults_go_in_turned_off_through_the_real_sandbox() { + let b = bed_in("real", false); + let done = Seeder::shipped().seed(&b.mgr).await; + let want: Vec<&str> = tw_gateway::plugin::defaults::ALL + .iter() + .map(|(id, _)| *id) + .collect(); + assert_eq!(ids(&done.added), want, "{done:?}"); + assert!(done.failed.is_empty(), "{done:?}"); + for (id, src) in tw_gateway::plugin::defaults::ALL { + let p = b.entry(id).unwrap(); + assert!(!p.enabled, "{id}"); + assert_eq!(p.sha256, sha(src), "{id}"); + let v = b.plugin(id).await; + assert_eq!(v["status"], json!({"kind": "disabled"}), "{id}: {v}"); + assert!( + b.gw.runtime().plugins.get(id).unwrap().ready().is_some(), + "{id}" + ); + } + let terms = b.entry("term-unify").unwrap(); + assert!( + terms.settings["terms"].as_str().unwrap().contains('\n'), + "{:?}", + terms.settings + ); + assert_eq!( + b.entry("deepseek-flags").unwrap().scope.models, + ["deepseek*"] + ); + assert_eq!(Seeder::shipped().seed(&b.mgr).await, Seeded::default()); +} + +/// `deepseek-flags` 改得了回答里的工具调用:网页那条路打不开它,确认过的那条打得开 +#[tokio::test] +async fn deepseek_flags_turns_on_only_with_a_confirmation() { + let b = bed_in("real", false); + Seeder::shipped().seed(&b.mgr).await; + let body = |base: String| { + json!({"enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": ["deepseek*"], "upstreams": []}, + "settings": {}, "base_version": base}) + }; + let (st, v) = call( + &b.app, + "PUT", + "/plugins/deepseek-flags", + Some(body(b.version().await)), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); + assert_eq!(v["code"], "control.plugin.needs_confirmation"); + assert!(!b.entry("deepseek-flags").unwrap().enabled); + + let (st, v) = call( + &b.app, + "PUT", + "/plugins/deepseek-flags/confirmed", + Some(body(b.version().await)), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(b.entry("deepseek-flags").unwrap().enabled); + assert_eq!( + b.plugin("deepseek-flags").await["status"], + json!({"kind": "ok"}) + ); +} diff --git a/crates/tw-control/tests/plugins.rs b/crates/tw-control/tests/plugins.rs index ea445a54..01408813 100644 --- a/crates/tw-control/tests/plugins.rs +++ b/crates/tw-control/tests/plugins.rs @@ -1063,6 +1063,273 @@ async fn a_trial_runs_the_plugin_on_the_recorded_request_and_answer() { ); } +/// 改得了回答里工具调用的插件:一份设置、一份范围 +fn rewrite_calls() -> String { + source( + json!({"name": "改工具调用", "api": 1, "permissions": ["reply.tool_calls"], + "match": {"models": ["claude-*", "gpt-*"]}, + "settings": {"mode": {"type": "string", "label": "方式", "default": "a"}, + "depth": {"type": "number", "label": "层数", "default": 2}}}), + &["onToolCall"], + ) +} + +/// 一份 `PluginUpdate`:开关、出错时怎么办、模型范围、设置 +fn update_body(enabled: bool, on_error: &str, models: Value, settings: Value) -> Value { + json!({"enabled": enabled, "on_error": on_error, + "scope": {"clients": [], "models": models, "upstreams": []}, + "settings": settings}) +} + +async fn put(b: &Bed, path: &str, mut body: Value) -> (StatusCode, Value) { + body["base_version"] = json!(b.version().await); + call(&b.app, "PUT", path, Some(body)).await +} + +/// 网页那条路改不了工具调用插件做什么:打开它、改设置、改范围都要点过头。停用、改出错时 +/// 怎么办、排顺序、删照常;确认过的那条路什么都改得了 +#[tokio::test] +async fn a_tool_call_plugin_is_turned_on_or_steered_only_after_a_confirmation() { + let b = bed(); + let id = b + .install( + &rewrite_calls(), + json!({"enabled": false, + "scope": {"clients": [], "models": ["claude-*", "gpt-*"], "upstreams": []}}), + ) + .await; + let other = b.install(&shout(), json!({})).await; + let at = format!("/plugins/{id}"); + let before = b.config(); + let as_is = || { + update_body( + false, + "reject", + json!(["claude-*", "gpt-*"]), + json!({"mode": "a", "depth": 2}), + ) + }; + + for (what, body) in [ + ( + "turning it on", + update_body( + true, + "reject", + json!(["claude-*", "gpt-*"]), + json!({"mode": "a", "depth": 2}), + ), + ), + ( + "a setting", + update_body( + false, + "reject", + json!(["claude-*", "gpt-*"]), + json!({"mode": "b", "depth": 2}), + ), + ), + ( + "a number setting", + update_body( + false, + "reject", + json!(["claude-*", "gpt-*"]), + json!({"mode": "a", "depth": 3}), + ), + ), + ( + "the scope", + update_body( + false, + "reject", + json!(["*"]), + json!({"mode": "a", "depth": 2}), + ), + ), + ( + "the scope by removing an entry", + update_body( + false, + "reject", + json!(["claude-*"]), + json!({"mode": "a", "depth": 2}), + ), + ), + ] { + let (st, v) = put(&b, &at, body).await; + assert_eq!(st, StatusCode::FORBIDDEN, "{what}: {v}"); + assert_eq!( + v["code"], "control.plugin.needs_confirmation", + "{what}: {v}" + ); + assert_eq!(v["args"]["plugin"], "改工具调用", "{what}: {v}"); + assert_eq!(b.config(), before, "{what}"); + } + + // 什么都没变、只是交回原样(顺序不同、没给的设置按默认值算):照收 + let (st, v) = put(&b, &at, as_is()).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let (st, v) = put( + &b, + &at, + update_body(false, "reject", json!(["gpt-*", "claude-*"]), json!({})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + // 出错时怎么办照改 + let (st, v) = put( + &b, + &at, + update_body( + false, + "skip", + json!(["claude-*", "gpt-*"]), + json!({"mode": "a"}), + ), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!( + b.parsed().plugins[0].on_error, + tw_config::PluginOnError::Skip + ); + + // 确认过的那条路:打开、改设置、改范围一次改完 + let (st, v) = put( + &b, + &format!("{at}/confirmed"), + update_body( + true, + "skip", + json!(["claude-*"]), + json!({"mode": "b", "depth": 5}), + ), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let p = b.parsed().plugins[0].clone(); + assert!(p.enabled); + assert_eq!(p.scope.models, ["claude-*"]); + assert_eq!(p.settings["mode"], serde_yaml_ng::Value::from("b")); + assert_eq!(p.settings["depth"], serde_yaml_ng::Value::from(5)); + assert_eq!(b.plugin(&id).await["status"], json!({"kind": "ok"})); + + // 开着的时候:改设置照样要点头;只改出错时怎么办不用 + let (st, v) = put( + &b, + &at, + update_body( + true, + "skip", + json!(["claude-*"]), + json!({"mode": "c", "depth": 5}), + ), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); + let (st, v) = put( + &b, + &at, + update_body( + true, + "reject", + json!(["claude-*"]), + json!({"mode": "b", "depth": 5}), + ), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + // 停用照常 + let (st, v) = put( + &b, + &at, + update_body( + false, + "reject", + json!(["claude-*"]), + json!({"mode": "b", "depth": 5}), + ), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(!b.parsed().plugins[0].enabled); + + // 排顺序、删照常 + let (st, v) = put(&b, "/plugins/order", json!({"ids": [other, id]})).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let (st, v) = call( + &b.app, + "DELETE", + &format!("{at}?base_version={}", b.version().await), + None, + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(b.parsed().plugins.iter().all(|p| p.id != id)); + + // 确认过的那条路也要插件在 + let (st, _) = put(&b, "/plugins/nobody/confirmed", as_is()).await; + assert_eq!(st, StatusCode::NOT_FOUND); +} + +/// 没有工具调用权限的插件:网页那条路照常打开、改设置、改范围 +#[tokio::test] +async fn a_plugin_without_tool_calls_is_changed_without_a_confirmation() { + let b = bed(); + let id = b.install(&add_date(), json!({"enabled": false})).await; + let (st, v) = put( + &b, + &format!("/plugins/{id}"), + update_body( + true, + "reject", + json!(["gpt-*"]), + json!({"note": "明天", "days": 4}), + ), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(b.parsed().plugins[0].enabled); +} + +/// 读不出权限的插件(文件和底稿都被动过)按改得了工具调用算:它此刻跑不了,可一旦又跑得了, +/// 网页替它打开的开关就生效了 +#[tokio::test] +async fn a_plugin_whose_permissions_cannot_be_read_needs_a_confirmation_too() { + let b = bed(); + let id = b.install(&shout(), json!({"enabled": false})).await; + std::fs::write(b.file(&id), "tampered").unwrap(); + std::fs::write(b.approved(&id), "tampered too").unwrap(); + b.gw.reload_plugins(); + assert!(b.gw.runtime().plugins.get(&id).unwrap().manifest.is_none()); + let (st, v) = put( + &b, + &format!("/plugins/{id}"), + update_body(true, "reject", json!([]), json!({})), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); + assert_eq!(v["code"], "control.plugin.needs_confirmation"); + assert_eq!(v["args"]["plugin"], id); + // 改出错时怎么办照常 + let (st, v) = put( + &b, + &format!("/plugins/{id}"), + update_body(false, "skip", json!([]), json!({})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + let (st, v) = put( + &b, + &format!("/plugins/{id}/confirmed"), + update_body(true, "skip", json!([]), json!({})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(b.parsed().plugins[0].enabled); +} + /// 远程 core:配置不在默认的地方,插件文件就在那份配置旁边 —— 文件由 core 自己写 #[tokio::test] async fn plugin_files_live_next_to_the_configuration_wherever_it_is() { diff --git a/crates/tw-gateway/src/plugin/defaults/mod.rs b/crates/tw-gateway/src/plugin/defaults/mod.rs new file mode 100644 index 00000000..7f7add4a --- /dev/null +++ b/crates/tw-gateway/src/plugin/defaults/mod.rs @@ -0,0 +1,66 @@ +//! 随 core 一起发的插件(默认插件)。 +//! +//! 它们和用户自己装的插件**在同一张单子上**:没有单独的分组、没有特别的标记,**装上时 +//! 一律停用**,用户像对别的插件一样自己打开。源码就是这个目录里的 `.js`,编进二进制 +//! —— 远程 core 也带着它们。 +//! +//! 这里只有清单。什么时候装上、什么时候换成新版、用户删了或改了之后怎么办,是管理面的 +//! 事(`tw_control::plugins::defaults`),记在插件目录的 `.defaults.json` 里。 +//! +//! **加一个就在 [`ALL`] 里加一行。id 一经发出就不再改**:用户删掉的默认插件按 id 记着, +//! 改了 id 等于又塞给他一个删过的插件。 + +/// 全部默认插件:(id, 源码)。**第一次装上时按这个顺序排进配置** +pub const ALL: &[(&str, &str)] = &[ + ("reply-language", include_str!("reply-language.js")), + ("current-date", include_str!("current-date.js")), + ("term-unify", include_str!("term-unify.js")), + ("reply-redact", include_str!("reply-redact.js")), + ("wsl-paths", include_str!("wsl-paths.js")), + ("deepseek-flags", include_str!("deepseek-flags.js")), +]; + +#[cfg(test)] +mod tests { + use super::*; + + /// 清单就是约定的那几个,一个不多、一个不少 + #[test] + fn the_list_is_the_agreed_set() { + let ids: Vec<&str> = ALL.iter().map(|(id, _)| *id).collect(); + assert_eq!( + ids, + [ + "reply-language", + "current-date", + "term-unify", + "reply-redact", + "wsl-paths", + "deepseek-flags", + ] + ); + } + + /// 每个 id 都装得进配置:写法对、不是控制面占用的词、不重复 + #[test] + fn every_id_can_be_a_plugin_id() { + let mut seen = std::collections::HashSet::new(); + for (id, _) in ALL { + assert!(tw_config::plugins::valid_id(id), "{id}"); + assert!(!tw_config::plugins::RESERVED_IDS.contains(id), "{id}"); + assert!(seen.insert(*id), "{id} is listed twice"); + } + } + + /// 每一个都在真的沙箱里编得成:装不上的默认插件只会在日志里留一行 + #[test] + fn every_default_compiles_in_the_real_sandbox() { + let engine = crate::plugin::default_engine(); + for (id, source) in ALL { + assert!(source.len() <= crate::plugin::MAX_SOURCE, "{id}"); + if let Err(e) = engine.load(source.as_bytes()) { + panic!("{id} does not load: {}", e.msg()); + } + } + } +} diff --git a/crates/tw-gateway/src/plugin/mod.rs b/crates/tw-gateway/src/plugin/mod.rs index c69edec0..2634e54c 100644 --- a/crates/tw-gateway/src/plugin/mod.rs +++ b/crates/tw-gateway/src/plugin/mod.rs @@ -26,8 +26,11 @@ //! 这两道防护看的就是插件改过的那一版。 //! - [`pool`]:插件调用都是阻塞的、吃 CPU 的,放在专用线程池上跑,不占 tokio 的线程。 //! - [`trial`]:对着存下来的请求和回答试跑一个插件。 +//! +//! [`defaults`] 是随 core 一起发的那几个插件(清单和源码)。 pub mod bridge; +pub mod defaults; pub mod engine; /// 测试用的假引擎(见里面的说明)。**不是给生产用的** #[doc(hidden)] From 98b2a3ec29f1430e42e3b7aa5bc7bead30cbd94f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:23:39 +0800 Subject: [PATCH 2/4] Keep three default plugins; disabled plugins do not start the sandbox The shipped defaults are now reply-language, wsl-paths and deepseek-flags. current-date, term-unify and reply-redact are removed with their tests. With no plugin enabled, core no longer starts the plugin runtime: the sandbox costs about 7 MB of resident memory, and every configuration now carries disabled defaults. Disabled plugins still have their files read and hashed, so a changed file is still reported, but they are not compiled. They are dormant hosts that cannot run any hook, and the list shows them from plugins/.manifests.json, a display-only cache keyed by the approved SHA-256 and versioned by core, sandbox and format. A cache that does not match is ignored and the plugin is listed by id and status. Once any plugin is enabled, every plugin is compiled as before and the cache is refreshed. Seeding uses manifests precomputed from the real sandbox (src/plugin/defaults/manifests.json, regenerated with UPDATE_DEFAULT_MANIFESTS=1 and checked by a test), so installing the disabled defaults compiles nothing. Security decisions never use the cache. UpdatePlugin compiles the approved bytes before it decides on a request that turns a plugin on or changes its settings or scope, so a tampered cache cannot hide reply_tool_calls from the confirmation. A trial of a dormant plugin compiles it first. The smoke test now expects serve to append the default plugins and checks that they are all off. Co-Authored-By: Claude Opus 5.5 --- crates/tw-control/src/plugins.rs | 115 +++++- crates/tw-control/src/plugins/defaults.rs | 70 +++- crates/tw-control/tests/plugin_defaults.rs | 285 ++++++++++++-- crates/tw-control/tests/plugins.rs | 57 ++- .../src/plugin/defaults/current-date.js | 34 -- .../src/plugin/defaults/manifests.json | 94 +++++ crates/tw-gateway/src/plugin/defaults/mod.rs | 85 +++- .../src/plugin/defaults/reply-redact.js | 63 --- .../src/plugin/defaults/term-unify.js | 91 ----- crates/tw-gateway/src/plugin/host.rs | 7 + crates/tw-gateway/src/plugin/load.rs | 257 ++++++++++++- crates/tw-gateway/src/plugin/manifests.rs | 362 ++++++++++++++++++ crates/tw-gateway/src/plugin/mod.rs | 4 +- crates/tw-gateway/tests/plugins_defaults.rs | 173 +-------- scripts/smoke.sh | 26 +- 15 files changed, 1289 insertions(+), 434 deletions(-) delete mode 100644 crates/tw-gateway/src/plugin/defaults/current-date.js create mode 100644 crates/tw-gateway/src/plugin/defaults/manifests.json delete mode 100644 crates/tw-gateway/src/plugin/defaults/reply-redact.js delete mode 100644 crates/tw-gateway/src/plugin/defaults/term-unify.js create mode 100644 crates/tw-gateway/src/plugin/manifests.rs diff --git a/crates/tw-control/src/plugins.rs b/crates/tw-control/src/plugins.rs index da670f4f..6bab3e0f 100644 --- a/crates/tw-control/src/plugins.rs +++ b/crates/tw-control/src/plugins.rs @@ -658,21 +658,25 @@ async fn save( // **攥着写插件的那把锁**:读到的权限和写下去的配置说的是同一份插件 —— 换源码、 // 批准也攥着它,落不到两者之间 let _edit = s.gateway.plugins.edits.lock().await; - // 设置对着它此刻的 manifest 查。读不出 manifest(文件变了、底稿也没了)就照交来的 - // 写:加载时还会再查一遍 - let (approved, manifest, name) = { + let (current, shown) = { let rt = s.gateway.runtime(); - let approved = rt - .config - .plugins - .iter() - .find(|p| p.id == id) - .map(|p| p.sha256.clone()); - let a = rt.plugins.get(id); - let manifest = a.and_then(|a| a.manifest.clone()); - let name = a.map_or_else(|| id.to_string(), |a| a.name.clone()); - (approved, manifest, name) + let current = rt.config.plugins.iter().find(|p| p.id == id).cloned(); + let shown = rt.plugins.get(id).map(|a| a.name.clone()); + (current, shown) + }; + // 打开它、改设置、改范围(照写的比):要按它的权限判断、按它的设置项核对,就**真的 + // 编一遍**(停用着的插件这时才起运行时),不认显示用的缓存。只是停用、改出错时怎么办 + // 的不用编。编不成、读不到批准的那份字节就当读不出权限:网页这条路拒绝 + let approved = current.as_ref().map(|p| p.sha256.clone()); + let manifest = match ¤t { + Some(p) if changes_what_it_does(p, &req, None) => compiled_manifest(s, id, &p.sha256).await, + _ => None, }; + let name = manifest + .as_ref() + .map(|m| m.name.clone()) + .or(shown) + .unwrap_or_else(|| id.to_string()); let settings = match &manifest { Some(m) => settings_for(m, &req.settings)?, None => req.settings.clone(), @@ -711,6 +715,39 @@ async fn save( Ok(Json(tw_api::ConfigWritten { version })) } +/// 这个插件**真的编出来**的 manifest。开着的插件手里就有;休眠的(停用着、运行时没起)、 +/// 加载出错的,把批准的那份字节编一遍。**安全上的判断只认它**,不认显示用的缓存。读不到 +/// 批准的那份字节、编不成是 None +async fn compiled_manifest(s: &ControlState, id: &str, sha256: &str) -> Option { + let held = s + .gateway + .runtime() + .plugins + .get(id) + .and_then(|a| a.ready().cloned()); + if let Some(h) = held + && !h.dormant() + && tw_gateway::plugin::load::hex(&h.sha256()) == sha256 + { + return Some(h.manifest().clone()); + } + let bytes = approved_bytes(s, id, sha256)?; + load(s, bytes, true).await.ok()?.ok() +} + +/// 批准的那份字节:磁盘上的插件文件,文件变了时退回底稿。**哈希都得和配置里的一样**, +/// 都对不上就没有 +fn approved_bytes(s: &ControlState, id: &str, sha256: &str) -> Option> { + let dir = config_dir(s); + [ + tw_config::plugins::file_path(&dir, id), + tw_config::plugins::approved_path(&dir, id), + ] + .iter() + .filter_map(|p| read_capped(p).ok()) + .find(|b| sha256_hex(b) == sha256) +} + /// 改得了回答里的工具调用:权限里有 `reply_tool_calls`,**或者读不出它要什么权限** fn steers_tool_calls(m: Option<&Manifest>) -> bool { m.is_none_or(|m| m.permissions.contains(&tw_api::Permission::ReplyToolCalls)) @@ -952,6 +989,15 @@ async fn trial( let reply = g.blobs().get(row.at_ms, row.id, tw_store::Which::Response); (row, request, reply) }; + // 休眠的插件(停用着、运行时没起):真的编一遍再试,设置按编出来的 manifest 重新对 + let active = if active.ready().is_some_and(|h| h.dormant()) { + match awaken(&s, &active).await { + Ok(a) => std::sync::Arc::new(a), + Err(why) => return Ok(Json(refused(why))), + } + } else { + active + }; // 跑不了的插件不试:改过的代码不跑(I9),加载不了的也跑不了 let host = match &active.state { tw_gateway::plugin::State::Ready(h) => h.clone(), @@ -971,6 +1017,49 @@ async fn trial( )) } +/// 把一个休眠的插件真的编出来(试跑之前):批准的那份字节编出来的宿主,设置按它的 +/// manifest 重新对过。读不到批准的那份字节、编不成、设置对不上就是试不了的原因 +async fn awaken(s: &ControlState, a: &Active) -> Result { + let entry = s + .gateway + .runtime() + .config + .plugins + .iter() + .find(|p| p.id == a.id) + .cloned() + .ok_or_else(|| not_found(&a.id).1.0)?; + let bytes = approved_bytes(s, &a.id, &entry.sha256).ok_or_else(|| { + msg!( + "control.plugin.trial_changed", plugin = &a.name => + "The file of plugin `{plugin}` changed and has not been approved, so it cannot \ + be tried." + ) + })?; + let plugins = s.gateway.plugins.clone(); + let host = tokio::task::spawn_blocking(move || plugins.prepare(&bytes)) + .await + .map_err(|e| internal(e).1.0)? + .map_err(|e| e.msg())?; + let m = host.manifest().clone(); + let settings = tw_gateway::plugin::load::settings_of(&m, &entry.settings)?; + Ok(Active { + id: a.id.clone(), + name: m.name.clone(), + enabled: a.enabled, + on_error: a.on_error, + scope: a.scope.clone(), + permissions: m.permissions.clone(), + reply_mode: m.reply_mode, + hooks: m.hooks, + settings, + manifest: Some(m), + state: tw_gateway::plugin::State::Ready(host), + stats: a.stats.clone(), + logs: a.logs.clone(), + }) +} + fn refused(why: Msg) -> tw_api::PluginTrialResult { tw_api::PluginTrialResult { request: None, diff --git a/crates/tw-control/src/plugins/defaults.rs b/crates/tw-control/src/plugins/defaults.rs index 02314db7..b68cb000 100644 --- a/crates/tw-control/src/plugins/defaults.rs +++ b/crates/tw-control/src/plugins/defaults.rs @@ -24,6 +24,13 @@ //! 来源记成 `defaults`),**这一次要加、要换的一次写进去**:一版配置、一条历史。配置在 //! 这中间被别处改了(版本对不上),刚写的文件还原,等那一次换入之后再走一遍。 //! +//! # 不起运行时 +//! +//! 装上的默认插件都停用着,而沙箱一起来就是几 MB 常驻内存:**装它们不编**。范围、设置的 +//! 默认值从它们预先算好的 manifest 里读([`tw_gateway::plugin::defaults::manifest`]), +//! 顺手记进显示用的缓存,装上之后它们休眠着,列表照样说得出它们是什么。只有一种情况要 +//! 真的编:换新版的那个默认插件开着 —— 那时运行时本来就起着,新旧两版的权限按编出来的比。 +//! //! # 不挡启动、不挡换配置 //! //! 哪一步不成只落在那一个插件上:记一行日志、发一条 `plugin_failed`(同一个问题只说 @@ -66,6 +73,8 @@ struct Shipped { source: String, /// 源码字节的 SHA-256:给出去的就是这一版 sha256: String, + /// 预先算好的 manifest(随 core 发的才有)。没有就真的编一遍 + manifest: Option, } /// 一次走下来做了什么。 @@ -117,24 +126,45 @@ struct Change { } impl Seeder { - /// 一组 (id, 源码)。**测试拿自己的插件走这一条**,生产用 [`Seeder::shipped`] + /// 一组 (id, 源码),manifest 要真的编出来。**测试拿自己的插件走这一条**,生产用 + /// [`Seeder::shipped`] pub fn new<'a>(shipped: impl IntoIterator) -> Self { + Self::with(shipped.into_iter().map(|(id, source)| (id, source, None))) + } + + /// 随 core 发的那几个,带着预先算好的 manifest:**装它们不起运行时** + pub fn shipped() -> Self { + Self::with( + tw_gateway::plugin::defaults::ALL + .iter() + .map(|(id, source)| (*id, *source, tw_gateway::plugin::defaults::manifest(id))), + ) + } + + fn with<'a>(shipped: impl Iterator)>) -> Self { Self { shipped: shipped - .into_iter() - .map(|(id, source)| Shipped { + .map(|(id, source, manifest)| Shipped { id: id.to_string(), sha256: sha256_hex(source.as_bytes()), source: source.to_string(), + manifest, }) .collect(), told: Mutex::default(), } } - /// 随 core 发的那几个 - pub fn shipped() -> Self { - Self::new(tw_gateway::plugin::defaults::ALL.iter().copied()) + /// 发出去的那一版的 manifest:预先算好的就拿来用(不编),记进显示用的缓存;没有就 + /// 真的编一遍(编的时候自己会记) + async fn shipped_manifest(&self, mgr: &ConfigManager, s: &Shipped) -> Result { + match &s.manifest { + Some(m) => { + mgr.gateway().plugins.remember(&s.sha256, m); + Ok(m.clone()) + } + None => compile(mgr, s.source.as_bytes(), true).await, + } } /// 走一遍(见模块说明)。**不会失败**:没办成的落在那一个插件上,下一次再试。 @@ -180,7 +210,7 @@ impl Seeder { record.offered.insert(s.id.clone(), s.sha256.clone()); out.marked.push(s.id.clone()); } - (None, None) => match compile(mgr, s.source.as_bytes(), true).await { + (None, None) => match self.shipped_manifest(mgr, s).await { Ok(m) => { names.insert(s.id.clone(), m.name.clone()); plan.push(Change { @@ -215,8 +245,14 @@ impl Seeder { }; let on_disk = bytes.as_deref().map(sha256_hex); if on_disk.as_deref() == Some(o.as_str()) && p.sha256 == o { - // 没动过:换成新版 - let new = match compile(mgr, s.source.as_bytes(), true).await { + // 没动过:换成新版。**开着的新旧两版都真的编**,权限按编出来的比(运行时 + // 反正起着);停用着的不编,换上之后照样停用着,用不着比 + let new = if p.enabled { + compile(mgr, s.source.as_bytes(), true).await + } else { + self.shipped_manifest(mgr, s).await + }; + let new = match new { Ok(m) => m, Err(why) => { out.failed.push((s.id.clone(), why)); @@ -224,13 +260,17 @@ impl Seeder { } }; // 旧版要过哪些权限。读不出来就当新版多要了 —— 宁可停用 - let old = match bytes { - Some(b) => compile(mgr, &b, false).await.ok(), - None => None, + let more = if p.enabled { + let old = match bytes { + Some(b) => compile(mgr, &b, false).await.ok(), + None => None, + }; + old.as_ref().is_none_or(|old| { + new.permissions.iter().any(|x| !old.permissions.contains(x)) + }) + } else { + false }; - let more = old.as_ref().is_none_or(|old| { - new.permissions.iter().any(|x| !old.permissions.contains(x)) - }); names.insert(s.id.clone(), new.name.clone()); plan.push(Change { at, diff --git a/crates/tw-control/tests/plugin_defaults.rs b/crates/tw-control/tests/plugin_defaults.rs index 3c66d170..866cb295 100644 --- a/crates/tw-control/tests/plugin_defaults.rs +++ b/crates/tw-control/tests/plugin_defaults.rs @@ -4,9 +4,13 @@ //! //! 断言落在磁盘上:插件文件和底稿、配置里那一条、`plugins/.defaults.json`。规则用自己 //! 造的几个插件测(假引擎);随 core 发的那一份清单另用真的沙箱整个走一遍。 +//! +//! 还有**不起运行时**这一条:装默认插件、列出停用的插件都不编(数着引擎编了几次), +//! 显示用的 manifest 缓存被人改了也骗不过「打开工具调用插件要点头」那道关。 use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use axum::body::Body; use axum::http::{Request, StatusCode}; @@ -15,6 +19,36 @@ use tower::ServiceExt; use tw_control::plugins::defaults::{Seeded, Seeder, record_path}; use tw_control::{ConfigManager, ControlState}; use tw_gateway::plugin::fake::{FakeEngine, source}; +use tw_gateway::plugin::{Engine, LoadError, PluginHost}; + +/// 数着编了几次的引擎,编的事交给里面那一个 +struct Counting { + inner: Arc, + n: AtomicUsize, +} + +impl Counting { + fn new(inner: Arc) -> Arc { + Arc::new(Self { + inner, + n: AtomicUsize::new(0), + }) + } + fn count(&self) -> usize { + self.n.load(Ordering::SeqCst) + } +} + +impl Engine for Counting { + fn load(&self, source: &[u8]) -> Result, LoadError> { + self.n.fetch_add(1, Ordering::SeqCst); + self.inner.load(source) + } +} + +fn real() -> Arc { + Arc::new(tw_gateway::plugin::sandbox::Sandbox) +} const BASE: &str = "version: 1 listen: @@ -27,7 +61,8 @@ clients: "; struct Bed { - _tmp: tempfile::TempDir, + /// 「重启」出来的那一份不拿着目录:它和原来那一份共用 + _tmp: Option, dir: PathBuf, gw: tw_gateway::AppState, mgr: Arc, @@ -89,15 +124,32 @@ impl Bed { } fn bed_in(sub: &str, fake: bool) -> Bed { + let engine: Arc = if fake { Arc::new(FakeEngine) } else { real() }; + bed_with(sub, engine) +} + +fn bed_with(sub: &str, engine: Arc) -> 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 gw = tw_gateway::AppState::new(tw_config::try_parse(BASE).unwrap()).unwrap(); - if fake { - gw.set_plugin_engine(Arc::new(FakeEngine)); + std::fs::write(dir.join("config.yaml"), BASE).unwrap(); + let mut b = open(dir, engine); + b._tmp = Some(tmp); + b +} + +impl Bed { + /// 「重启」:同一个目录上起一份新的网关和控制面(编译结果、显示用的缓存都从头来) + fn restart(&self, engine: Arc) -> Bed { + open(self.dir.clone(), engine) } +} + +fn open(dir: PathBuf, engine: Arc) -> Bed { + let p = dir.join("config.yaml"); + let text = std::fs::read_to_string(&p).unwrap(); + let gw = tw_gateway::AppState::new(tw_config::try_parse(&text).unwrap()).unwrap(); + gw.set_plugin_engine(engine); let mgr = Arc::new(ConfigManager::new(p, gw.clone(), gw.bus.clone())); let state = ControlState { shutdown: Default::default(), @@ -111,7 +163,7 @@ fn bed_in(sub: &str, fake: bool) -> Bed { zai: Default::default(), }; Bed { - _tmp: tmp, + _tmp: None, dir, gw, mgr, @@ -595,40 +647,225 @@ async fn an_unreadable_record_adds_nothing() { ); } -/// 随 core 发的那一份清单,真的沙箱:六个全装上、都停用着、都能跑;两行的默认设置 -/// 写得进配置 +/// 随 core 发的那一份清单,真的沙箱:每个都装上、都停用着;**一个都不编**(不起运行时), +/// 列表照样说得出它们是什么 —— 重启之后也一样 #[tokio::test] -async fn the_shipped_defaults_go_in_turned_off_through_the_real_sandbox() { - let b = bed_in("real", false); +async fn the_shipped_defaults_go_in_turned_off_without_starting_the_sandbox() { + let engine = Counting::new(real()); + let b = bed_with("real", engine.clone()); let done = Seeder::shipped().seed(&b.mgr).await; - let want: Vec<&str> = tw_gateway::plugin::defaults::ALL - .iter() - .map(|(id, _)| *id) - .collect(); + let all = tw_gateway::plugin::defaults::ALL; + let want: Vec<&str> = all.iter().map(|(id, _)| *id).collect(); assert_eq!(ids(&done.added), want, "{done:?}"); assert!(done.failed.is_empty(), "{done:?}"); - for (id, src) in tw_gateway::plugin::defaults::ALL { + for (id, src) in all { let p = b.entry(id).unwrap(); assert!(!p.enabled, "{id}"); assert_eq!(p.sha256, sha(src), "{id}"); let v = b.plugin(id).await; assert_eq!(v["status"], json!({"kind": "disabled"}), "{id}: {v}"); + let m = tw_gateway::plugin::defaults::manifest(id).unwrap(); + assert_eq!(v["name"], m.name.as_str(), "{id}"); assert!( - b.gw.runtime().plugins.get(id).unwrap().ready().is_some(), - "{id}" + !v["permissions"].as_array().unwrap().is_empty(), + "{id}: {v}" ); + let a = b.gw.runtime().plugins.get(id).unwrap().clone(); + assert!(a.ready().unwrap().dormant(), "{id}"); } - let terms = b.entry("term-unify").unwrap(); - assert!( - terms.settings["terms"].as_str().unwrap().contains('\n'), - "{:?}", - terms.settings - ); assert_eq!( b.entry("deepseek-flags").unwrap().scope.models, ["deepseek*"] ); + assert_eq!( + b.plugin("reply-language").await["settings"], + json!({"language": "简体中文"}) + ); + assert_eq!(engine.count(), 0, "seeding or listing started the sandbox"); assert_eq!(Seeder::shipped().seed(&b.mgr).await, Seeded::default()); + + // 重启:显示用的缓存里读得出来,照样不编 + let engine = Counting::new(real()); + let again = b.restart(engine.clone()); + assert_eq!(Seeder::shipped().seed(&again.mgr).await, Seeded::default()); + let v = again.plugin("wsl-paths").await; + assert_eq!(v["name"], "WSL 路径转换"); + assert_eq!(v["permissions"], json!(["messages", "reply_tool_calls"])); + assert_eq!(engine.count(), 0); +} + +/// 打开一个默认插件:这时才真的编(起运行时),编出来就能跑 +#[tokio::test] +async fn enabling_a_default_compiles_it_and_then_it_runs() { + let engine = Counting::new(real()); + let b = bed_with("real", engine.clone()); + Seeder::shipped().seed(&b.mgr).await; + assert_eq!(engine.count(), 0); + let (st, v) = call( + &b.app, + "PUT", + "/plugins/reply-language", + Some(json!({"enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, + "settings": {"language": "English"}, "base_version": b.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(engine.count() > 0, "turning it on did not compile it"); + assert_eq!( + b.plugin("reply-language").await["status"], + json!({"kind": "ok"}) + ); + let a = + b.gw.runtime() + .plugins + .get("reply-language") + .unwrap() + .clone(); + let host = a.ready().unwrap().clone(); + assert!(!host.dormant()); + let out = tokio::task::spawn_blocking(move || { + host.on_request( + json!({"format": "anthropic", "model": "claude-sonnet-4-5", "system": "你是助手。"}), + json!({"client": null, "model": "claude-sonnet-4-5", + "requested_model": "claude-sonnet-4-5", "format": "anthropic", + "upstream": "anthropic", "settings": {"language": "English"}}), + ) + }) + .await + .unwrap(); + match out.result { + Ok(tw_gateway::plugin::RequestOutcome::Changed(v)) => assert_eq!( + v["system"], + "你是助手。\n\nAlways respond in English, unless the user explicitly asks for another \ + language." + ), + other => panic!("{other:?}"), + } +} + +/// 一个改得了工具调用的插件,装上时停用着 +async fn install_calls(b: &Bed) -> String { + let src = source( + json!({"name": "改工具调用", "api": 1, "permissions": ["reply.tool_calls"], + "settings": {"mode": {"type": "string", "label": "方式", "default": "a"}}}), + &["onToolCall"], + ); + let (st, v) = call( + &b.app, + "POST", + "/plugins", + Some( + json!({"source": src, "id": "calls", "enabled": false, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, "settings": {}}), + ), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + src +} + +fn cache_file(b: &Bed) -> PathBuf { + b.dir.join("plugins").join(".manifests.json") +} + +/// 显示用的缓存被人改了(藏起了 reply_tool_calls):列表上是改过的样子,可网页那条路照样 +/// 打不开它、改不了它的设置 —— 判断用的是真的编出来的 manifest +#[tokio::test] +async fn a_tampered_manifest_cache_cannot_hide_tool_calls_from_the_confirmation() { + let b = bed(); + install_calls(&b).await; + let text = std::fs::read_to_string(cache_file(&b)).unwrap(); + assert!(text.contains("\"reply_tool_calls\""), "{text}"); + std::fs::write( + cache_file(&b), + text.replace("\"reply_tool_calls\"", "\"system\""), + ) + .unwrap(); + + let engine = Counting::new(Arc::new(FakeEngine)); + let again = b.restart(engine.clone()); + let v = again.plugin("calls").await; + assert_eq!(v["permissions"], json!(["system"]), "{v}"); + assert_eq!(engine.count(), 0); + // 只改出错时怎么办:用不着判断,也就不编 + let (st, v) = call( + &again.app, + "PUT", + "/plugins/calls", + Some(json!({"enabled": false, "on_error": "skip", + "scope": {"clients": [], "models": [], "upstreams": []}, + "settings": {"mode": "a"}, "base_version": again.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert_eq!(engine.count(), 0, "an on_error change started the sandbox"); + for (what, enabled, mode) in [("turning it on", true, "a"), ("a setting", false, "b")] { + let (st, v) = call( + &again.app, + "PUT", + "/plugins/calls", + Some(json!({"enabled": enabled, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, + "settings": {"mode": mode}, "base_version": again.version().await})), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN, "{what}: {v}"); + assert_eq!(v["code"], "control.plugin.needs_confirmation", "{what}"); + } + assert!( + engine.count() > 0, + "the decision was not made on a compiled manifest" + ); + let p = again.entry("calls").unwrap(); + assert!(!p.enabled); + assert_eq!(p.settings["mode"], serde_yaml_ng::Value::from("a")); + let (st, v) = call( + &again.app, + "PUT", + "/plugins/calls/confirmed", + Some(json!({"enabled": true, "on_error": "reject", + "scope": {"clients": [], "models": [], "upstreams": []}, + "settings": {"mode": "b"}, "base_version": again.version().await})), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + // 编过一遍之后,列表上也是真的那一份了 + assert_eq!( + again.plugin("calls").await["permissions"], + json!(["reply_tool_calls"]) + ); +} + +/// 缓存对不上(版本不对、是别的字节的):不认,插件只按 id 和状态列出来,也不为此编 +#[tokio::test] +async fn a_cache_entry_that_does_not_match_is_ignored() { + let b = bed(); + let src = install_calls(&b).await; + let stored: Value = + serde_json::from_str(&std::fs::read_to_string(cache_file(&b)).unwrap()).unwrap(); + let sha_now = sha(&src); + assert!(stored["manifests"][&sha_now].is_object(), "{stored}"); + + let mut old_version = stored.clone(); + old_version["version"] = json!("0.0.0/old/1"); + let mut other_bytes = stored.clone(); + let entry = other_bytes["manifests"][&sha_now].clone(); + other_bytes["manifests"] = json!({ sha_now.replace(|c: char| c != '0', "0"): entry }); + for (what, file) in [ + ("another version", old_version.to_string()), + ("other bytes", other_bytes.to_string()), + ("a broken file", "{ not json".to_string()), + ] { + std::fs::write(cache_file(&b), file).unwrap(); + let engine = Counting::new(Arc::new(FakeEngine)); + let again = b.restart(engine.clone()); + let v = again.plugin("calls").await; + assert_eq!(v["name"], "calls", "{what}: {v}"); + assert_eq!(v["permissions"], json!([]), "{what}: {v}"); + assert_eq!(v["status"], json!({"kind": "disabled"}), "{what}: {v}"); + assert_eq!(engine.count(), 0, "{what}"); + } } /// `deepseek-flags` 改得了回答里的工具调用:网页那条路打不开它,确认过的那条打得开 diff --git a/crates/tw-control/tests/plugins.rs b/crates/tw-control/tests/plugins.rs index 01408813..0d3d2f5c 100644 --- a/crates/tw-control/tests/plugins.rs +++ b/crates/tw-control/tests/plugins.rs @@ -1063,6 +1063,51 @@ async fn a_trial_runs_the_plugin_on_the_recorded_request_and_answer() { ); } +/// 休眠的插件(停用着、运行时没起,只有显示用的 manifest)也试得了:试之前真的编一遍 +#[tokio::test] +async fn a_dormant_plugin_is_compiled_for_a_trial() { + let b = bed(); + b.gw.set_plugin_engine(Arc::new(Running)); + let src = source( + json!({"name": "Both", "api": 1, "permissions": ["system", "reply.text"]}), + &["onRequest", "onReplyText"], + ); + let id = b.install(&src, json!({"enabled": false})).await; + // 换一个运行时,编过的都清掉:一个插件都没开,它就休眠了 + b.gw.set_plugin_engine(Arc::new(Running)); + let a = b.gw.runtime().plugins.get(&id).unwrap().clone(); + assert!(a.ready().unwrap().dormant()); + let request = json!({ + "model": "claude-sonnet-4-5", "max_tokens": 64, + "system": "Be brief.", + "messages": [{"role": "user", "content": "hi"}] + }) + .to_string(); + { + let g = b.store.lock().await; + g.db().insert(&row(9, 1_000)).unwrap(); + g.record_body( + 1_000, + 9, + tw_store::Which::Request, + request.as_bytes(), + request.len(), + ); + } + 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"].is_null(), "{v}"); + assert_eq!(v["request"]["outcome"], "changed", "{v}"); + let after = v["request"]["after"].as_str().unwrap(); + assert!(after.contains("Be brief. Today is Friday."), "{after}"); +} + /// 改得了回答里工具调用的插件:一份设置、一份范围 fn rewrite_calls() -> String { source( @@ -1293,8 +1338,9 @@ async fn a_plugin_without_tool_calls_is_changed_without_a_confirmation() { assert!(b.parsed().plugins[0].enabled); } -/// 读不出权限的插件(文件和底稿都被动过)按改得了工具调用算:它此刻跑不了,可一旦又跑得了, -/// 网页替它打开的开关就生效了 +/// 批准的那份字节读不回来(文件和底稿都被动过):真的权限编不出来,按改得了工具调用算 +/// —— 它此刻跑不了,可一旦又跑得了,网页替它打开的开关就生效了。列表上显示的是之前编过 +/// 的那一份(只拿来显示),判断不认它 #[tokio::test] async fn a_plugin_whose_permissions_cannot_be_read_needs_a_confirmation_too() { let b = bed(); @@ -1302,7 +1348,10 @@ async fn a_plugin_whose_permissions_cannot_be_read_needs_a_confirmation_too() { std::fs::write(b.file(&id), "tampered").unwrap(); std::fs::write(b.approved(&id), "tampered too").unwrap(); b.gw.reload_plugins(); - assert!(b.gw.runtime().plugins.get(&id).unwrap().manifest.is_none()); + assert_eq!( + b.gw.runtime().plugins.get(&id).unwrap().broken(), + Some(&tw_gateway::plugin::Broken::Changed) + ); let (st, v) = put( &b, &format!("/plugins/{id}"), @@ -1311,7 +1360,7 @@ async fn a_plugin_whose_permissions_cannot_be_read_needs_a_confirmation_too() { .await; assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); assert_eq!(v["code"], "control.plugin.needs_confirmation"); - assert_eq!(v["args"]["plugin"], id); + assert_eq!(v["args"]["plugin"], "Shout"); // 改出错时怎么办照常 let (st, v) = put( &b, diff --git a/crates/tw-gateway/src/plugin/defaults/current-date.js b/crates/tw-gateway/src/plugin/defaults/current-date.js deleted file mode 100644 index 41be7989..00000000 --- a/crates/tw-gateway/src/plugin/defaults/current-date.js +++ /dev/null @@ -1,34 +0,0 @@ -// 附加当前日期 -// -// 在系统提示词末尾附上今天的日期。模型本身不知道今天是哪一天,问到截止日期、版本新旧 -// 这类问题时,容易按训练数据所在的年份回答。 -// -// 沙箱里的时钟是 UTC,所以「今天」按设置里的时区算,默认 UTC+8(北京时间)。 -// -// 日期每天变一次,系统提示词随之变化,上游的提示词缓存每天重建一次;同一天里每一轮 -// 附上的内容相同,缓存照常命中。 -// -// 权限:system,只读写系统提示词。 -// 设置:时区,相对 UTC 的小时数,-12 到 14,可以带小数(例如 5.5)。 - -export const manifest = { - name: "附加当前日期", - api: 1, - description: "在系统提示词末尾附上今天的日期,按设置的时区计算。", - permissions: ["system"], - settings: { - utc_offset: { type: "number", label: "时区(相对 UTC 的小时数)", default: 8 }, - }, -}; - -export function onRequest(req, ctx) { - const offset = Number(ctx.settings.utc_offset ?? 8); - if (!Number.isFinite(offset) || offset < -12 || offset > 14) { - throw new Error(`设置「时区」要在 -12 到 14 之间,当前是 ${ctx.settings.utc_offset}`); - } - const date = new Date(Date.now() + offset * 3600 * 1000).toISOString().slice(0, 10); - const zone = `UTC${offset < 0 ? "-" : "+"}${Math.abs(offset)}`; - const line = `Today's date: ${date} (${zone}).`; - req.system = req.system ? `${req.system}\n\n${line}` : line; - return req; -} diff --git a/crates/tw-gateway/src/plugin/defaults/manifests.json b/crates/tw-gateway/src/plugin/defaults/manifests.json new file mode 100644 index 00000000..fc8ab02f --- /dev/null +++ b/crates/tw-gateway/src/plugin/defaults/manifests.json @@ -0,0 +1,94 @@ +{ + "deepseek-flags": { + "manifest": { + "api": 1, + "description": "DeepSeek 接口会拒收含特定地区旗帜表情的请求,含有它们的会话因此无法继续。请求发出前把这些表情换成 ASCII 占位文字,回答里再换回原样。默认对发往 deepseek 开头的模型的请求生效。", + "hooks": { + "reply_text": true, + "reply_text_end": true, + "request": true, + "tool_call": true + }, + "name": "DeepSeek:替换会被拒收的旗帜表情", + "permissions": [ + "system", + "messages", + "reply_text", + "reply_tool_calls" + ], + "reply_mode": "stream", + "scope": { + "clients": [], + "models": [ + "deepseek*" + ], + "upstreams": [] + }, + "settings": [] + }, + "sha256": "75012e294a8e019db17c88652bd291fa2f6c03a0f625bd097bac553b153fb450" + }, + "reply-language": { + "manifest": { + "api": 1, + "description": "在系统提示词末尾要求模型用指定的语言回答。", + "hooks": { + "reply_text": false, + "reply_text_end": false, + "request": true, + "tool_call": false + }, + "name": "指定回答语言", + "permissions": [ + "system" + ], + "reply_mode": "block", + "scope": { + "clients": [], + "models": [], + "upstreams": [] + }, + "settings": [ + { + "default": "简体中文", + "key": "language", + "kind": "string", + "label": "回答语言" + } + ] + }, + "sha256": "930de8249548edea683c7ce04970c592adb6c8d7d50eb1b72c8d5fb0609baede" + }, + "wsl-paths": { + "manifest": { + "api": 1, + "description": "把工具调用参数里的盘符路径统一成客户端那一侧的写法(WSL 的 /mnt/c/… 或 Windows 的 C:\\…),回答和对话历史里的都改。", + "hooks": { + "reply_text": false, + "reply_text_end": false, + "request": true, + "tool_call": true + }, + "name": "WSL 路径转换", + "permissions": [ + "messages", + "reply_tool_calls" + ], + "reply_mode": "block", + "scope": { + "clients": [], + "models": [], + "upstreams": [] + }, + "settings": [ + { + "default": false, + "key": "windows_client", + "kind": "boolean", + "label": "客户端运行在 Windows 上(关闭时按 WSL 处理)" + } + ] + }, + "sha256": "83e1328d38fda8847a84f4a1791aaa84923da5b7dcfdf9b4f87e2b624ccf39e6" + } +} diff --git a/crates/tw-gateway/src/plugin/defaults/mod.rs b/crates/tw-gateway/src/plugin/defaults/mod.rs index 7f7add4a..409a1dba 100644 --- a/crates/tw-gateway/src/plugin/defaults/mod.rs +++ b/crates/tw-gateway/src/plugin/defaults/mod.rs @@ -9,17 +9,38 @@ //! //! **加一个就在 [`ALL`] 里加一行。id 一经发出就不再改**:用户删掉的默认插件按 id 记着, //! 改了 id 等于又塞给他一个删过的插件。 +//! +//! **装上它们不起运行时**:它们装上时都停用着,而沙箱一起来就是几 MB 常驻内存。装上要的 +//! 范围、设置的默认值,显示要的名字和权限,都从 `manifests.json` 里读 —— 那是测试照真的 +//! 沙箱把每一个编一遍生成的([`manifest`])。**改了哪个 `.js` 就重新生成一次**: +//! `UPDATE_DEFAULT_MANIFESTS=1 cargo test -p tw-gateway --lib plugin::defaults`,不然测试 +//! 不过;生成的那一份对不上源码时,管理面退回到真的编一遍。 /// 全部默认插件:(id, 源码)。**第一次装上时按这个顺序排进配置** pub const ALL: &[(&str, &str)] = &[ ("reply-language", include_str!("reply-language.js")), - ("current-date", include_str!("current-date.js")), - ("term-unify", include_str!("term-unify.js")), - ("reply-redact", include_str!("reply-redact.js")), ("wsl-paths", include_str!("wsl-paths.js")), ("deepseek-flags", include_str!("deepseek-flags.js")), ]; +/// 每个默认插件预先算好的 manifest:id → `{ sha256, manifest }`,`sha256` 是生成时那份 +/// 源码的 +const MANIFESTS: &str = include_str!("manifests.json"); + +/// 这个默认插件发出去的那一版的 manifest(预先算好的,见模块说明)。**和现在这份源码 +/// 对不上**(改了 `.js` 没重新生成)、不是默认插件,都是 None +pub fn manifest(id: &str) -> Option { + static PARSED: std::sync::OnceLock> = + std::sync::OnceLock::new(); + let all = PARSED.get_or_init(|| serde_json::from_str(MANIFESTS).unwrap_or_default()); + let (_, source) = ALL.iter().find(|(x, _)| *x == id)?; + let entry = all.get(id)?; + if entry["sha256"].as_str()? != crate::plugin::load::sha256_hex(source.as_bytes()) { + return None; + } + crate::plugin::manifests::from_json(&entry["manifest"]) +} + #[cfg(test)] mod tests { use super::*; @@ -28,17 +49,7 @@ mod tests { #[test] fn the_list_is_the_agreed_set() { let ids: Vec<&str> = ALL.iter().map(|(id, _)| *id).collect(); - assert_eq!( - ids, - [ - "reply-language", - "current-date", - "term-unify", - "reply-redact", - "wsl-paths", - "deepseek-flags", - ] - ); + assert_eq!(ids, ["reply-language", "wsl-paths", "deepseek-flags"]); } /// 每个 id 都装得进配置:写法对、不是控制面占用的词、不重复 @@ -52,6 +63,52 @@ mod tests { } } + /// 预先算好的 manifest 就是真的沙箱编出来的那一份。改了 `.js`: + /// `UPDATE_DEFAULT_MANIFESTS=1 cargo test -p tw-gateway --lib plugin::defaults` + #[test] + fn the_precomputed_manifests_are_what_the_sandbox_reads() { + let engine = crate::plugin::default_engine(); + let mut want = serde_json::Map::new(); + let mut compiled = Vec::new(); + for (id, source) in ALL { + let host = engine + .load(source.as_bytes()) + .unwrap_or_else(|e| panic!("{id} does not load: {}", e.msg())); + want.insert( + id.to_string(), + serde_json::json!({ + "sha256": crate::plugin::load::sha256_hex(source.as_bytes()), + "manifest": crate::plugin::manifests::to_json(host.manifest()), + }), + ); + compiled.push((*id, host.manifest().clone())); + } + let text = format!( + "{}\n", + serde_json::to_string_pretty(&serde_json::Value::Object(want)).unwrap() + ); + if std::env::var_os("UPDATE_DEFAULT_MANIFESTS").is_some() { + std::fs::write( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/plugin/defaults/manifests.json" + ), + &text, + ) + .unwrap(); + return; + } + assert!( + MANIFESTS == text, + "src/plugin/defaults/manifests.json is out of date: run \ + UPDATE_DEFAULT_MANIFESTS=1 cargo test -p tw-gateway --lib plugin::defaults" + ); + for (id, m) in compiled { + assert_eq!(manifest(id), Some(m), "{id}"); + } + assert_eq!(manifest("not-a-default"), None); + } + /// 每一个都在真的沙箱里编得成:装不上的默认插件只会在日志里留一行 #[test] fn every_default_compiles_in_the_real_sandbox() { diff --git a/crates/tw-gateway/src/plugin/defaults/reply-redact.js b/crates/tw-gateway/src/plugin/defaults/reply-redact.js deleted file mode 100644 index 75a0df78..00000000 --- a/crates/tw-gateway/src/plugin/defaults/reply-redact.js +++ /dev/null @@ -1,63 +0,0 @@ -// 回答内容打码 -// -// 回答里出现符合格式的内容时,换成一段固定文字,例如内部主机名、工单号、员工编号。 -// 只改发给客户端的回答,不改请求。出厂没有任何格式,要在设置里写上才会生效。 -// -// 整段模式:一段文字到齐之后才处理,符合格式的内容不会被流式输出切成两半而漏掉。 -// 代价是这段文字要等到齐了才出现在客户端里。 -// -// 权限:reply.text,只改回答里的文字。 -// 设置:格式,每行一个正则表达式(最多 50 个);替换文字(最多 100 个字符);是否区分大小写。 - -export const manifest = { - name: "回答内容打码", - api: 1, - description: "把回答里符合格式的内容换成固定文字。格式在设置里每行写一个正则表达式。", - permissions: ["reply.text"], - settings: { - patterns: { type: "string", label: "格式(每行一个正则表达式)", default: "" }, - replacement: { type: "string", label: "替换为", default: "[已隐藏]" }, - ignore_case: { type: "boolean", label: "不区分大小写", default: true }, - }, -}; - -const MAX_PATTERNS = 50; -const MAX_REPLACEMENT = 100; - -let compiled = null; - -function patterns(ctx) { - if (compiled !== null) return compiled; - const flags = ctx.settings.ignore_case === false ? "gu" : "giu"; - const list = []; - const lines = String(ctx.settings.patterns ?? "").split(/\r?\n/); - lines.forEach((line, n) => { - if (line.trim() === "") return; - try { - list.push(new RegExp(line.trim(), flags)); - } catch (e) { - throw new Error(`格式第 ${n + 1} 行不是有效的正则表达式:${e.message}`); - } - }); - if (list.length > MAX_PATTERNS) { - throw new Error(`格式最多 ${MAX_PATTERNS} 行`); - } - compiled = list; - return compiled; -} - -export function onReplyText(text, ctx) { - const list = patterns(ctx); - if (list.length === 0) return undefined; - const replacement = String(ctx.settings.replacement ?? ""); - if (replacement.length > MAX_REPLACEMENT) { - throw new Error(`替换文字最多 ${MAX_REPLACEMENT} 个字符`); - } - let out = text; - for (const re of list) { - // 用函数而不是字符串作替换:替换文字里的 $& 之类原样输出。匹配到空串的不算命中, - // 否则「a*」这样的格式会在每个字之间插一遍替换文字 - out = out.replace(re, (m) => (m === "" ? "" : replacement)); - } - return out === text ? undefined : out; -} diff --git a/crates/tw-gateway/src/plugin/defaults/term-unify.js b/crates/tw-gateway/src/plugin/defaults/term-unify.js deleted file mode 100644 index c07427eb..00000000 --- a/crates/tw-gateway/src/plugin/defaults/term-unify.js +++ /dev/null @@ -1,91 +0,0 @@ -// 统一用词 -// -// 把回答里的用词换成统一的写法,例如「登陆」换成「登录」。 -// -// 逐段模式:回答照常随流输出。一段文字的末尾恰好是某个原词的开头时(例如收到「请先登」), -// 这几个字先扣住,等下一段到了再判断;这段文字结束时扣住的全部放出。原词有重叠时, -// 长的优先。 -// -// 权限:reply.text,只改回答里的文字。 -// 设置:替换表,每行一条「原词=新词」。最多 100 条,每个词最多 64 个字符;空行忽略。 - -export const manifest = { - name: "统一用词", - api: 1, - description: "把回答里的用词换成统一的写法,替换表在设置里每行写一条「原词=新词」。", - permissions: ["reply.text"], - reply: "stream", - settings: { - terms: { - type: "string", - label: "替换表(每行一条:原词=新词)", - default: "登陆=登录\n帐号=账号", - }, - }, -}; - -const MAX_TERMS = 100; -const MAX_LENGTH = 64; - -// 同一个回答里的几次调用共用一个实例,所以模块里的变量在这个回答里一直有效; -// 回答结束后实例丢弃,下一个回答从头开始 -let table = null; -let held = ""; - -function terms(ctx) { - if (table !== null) return table; - const list = []; - for (const line of String(ctx.settings.terms ?? "").split(/\r?\n/)) { - if (line.trim() === "") continue; - const at = line.indexOf("="); - const from = at < 0 ? "" : line.slice(0, at).trim(); - const to = at < 0 ? "" : line.slice(at + 1).trim(); - if (from === "") { - throw new Error(`替换表里的「${line.trim()}」不是「原词=新词」的写法`); - } - if (from.length > MAX_LENGTH || to.length > MAX_LENGTH) { - throw new Error(`替换表里的词最多 ${MAX_LENGTH} 个字符`); - } - list.push([from, to]); - } - if (list.length > MAX_TERMS) { - throw new Error(`替换表最多 ${MAX_TERMS} 条`); - } - // 长的原词优先:「登陆页」和「登陆」都在表里时,先认「登陆页」 - list.sort((a, b) => b[0].length - a[0].length); - table = list; - return table; -} - -// 从头扫到尾。剩下的部分还可能是某个(更长的)原词的开头时先停下,留到下一段再判断 —— -// 「登陆」已经对上、而「登陆页」还差一个字时也要等;否则命中原词就换,长的优先 -function convert(text, ctx, last) { - const list = terms(ctx); - let out = ""; - let i = 0; - scan: while (i < text.length) { - const rest = text.slice(i); - if (!last && list.some(([from]) => from.length > rest.length && from.startsWith(rest))) { - break; - } - for (const [from, to] of list) { - if (text.startsWith(from, i)) { - out += to; - i += from.length; - continue scan; - } - } - out += text[i]; - i += 1; - } - held = text.slice(i); - return out; -} - -export function onReplyText(text, ctx) { - return convert(held + text, ctx, false); -} - -export function onReplyTextEnd(ctx) { - return convert(held, ctx, true); -} diff --git a/crates/tw-gateway/src/plugin/host.rs b/crates/tw-gateway/src/plugin/host.rs index ba587c1e..2386b153 100644 --- a/crates/tw-gateway/src/plugin/host.rs +++ b/crates/tw-gateway/src/plugin/host.rs @@ -18,6 +18,13 @@ pub trait PluginHost: Send + Sync { /// 编出它的那一份字节的 SHA-256(不变式 I9 比对的就是它) fn sha256(&self) -> [u8; 32]; + /// **休眠的插件**:停用着、运行时没起,还没真的编过(见 [`crate::plugin::load`])。 + /// 它跑不了任何钩子,`manifest()` 是缓存里的那一份(或者只有名字的占位),**只拿来 + /// 显示** —— 要跑它(试跑)、要按它的权限做判断,先真的编一遍 + fn dormant(&self) -> bool { + false + } + /// 请求钩子:每次调用一个新实例(不变式 I3)。 /// /// **没有实现的宿主跑不了钩子**(只拿来加载、展示的那些):报一个沙箱错误,按插件 diff --git a/crates/tw-gateway/src/plugin/load.rs b/crates/tw-gateway/src/plugin/load.rs index 42200ed7..f81bf2c5 100644 --- a/crates/tw-gateway/src/plugin/load.rs +++ b/crates/tw-gateway/src/plugin/load.rs @@ -10,6 +10,18 @@ //! 每次换配置都会把所有插件文件重读一遍、重算哈希 —— 这本身就是「文件变了」的一道 //! 检查;另有一个盯着 `plugins/` 目录的监听(在控制面),文件一动就单独重载一次插件。 //! 编译的结果按哈希缓存:同一份字节不编第二遍。 +//! +//! **一个插件都没打开时不起运行时**:沙箱一起来就是几 MB 常驻内存,而 core 自带的默认 +//! 插件装上时都停用着 —— 一个插件都没打开的用户不该为它付这个钱。这时停用的插件照样读 +//! 文件、算哈希,但不编:它们是「休眠」的([`PluginHost::dormant`],跑不了任何钩子), +//! 列表上显示的 manifest 来自缓存([`super::manifests`],只拿来显示),缓存里没有就只有 +//! id。有一个插件开着,运行时反正要起,所有插件照常编,缓存跟着补齐。 +//! +//! **一个插件都没打开时不起运行时**:沙箱一起来就是几 MB 常驻内存,而 core 自带的默认 +//! 插件装上时都停用着 —— 一个插件都没打开的用户不该为它付这个钱。这时停用的插件照样读 +//! 文件、算哈希,但不编:它们是「休眠」的([`PluginHost::dormant`],跑不了任何钩子), +//! 列表上显示的 manifest 来自缓存([`super::manifests`],只拿来显示),缓存里没有就只有 +//! id。有一个插件开着,运行时反正要起,所有插件照常编,缓存跟着补齐。 use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::Read; @@ -21,6 +33,7 @@ use tw_types::{Msg, msg}; use crate::plugin::engine::{Engine, LoadError, MAX_SOURCE, Manifest}; use crate::plugin::host::PluginHost; +use crate::plugin::manifests; use crate::plugin::set::{Active, Broken, LogRing, PluginSet, Scope, State, Stats}; /// 一份编译结果:编好的插件,或者编不成的原因 @@ -35,6 +48,8 @@ pub struct Plugins { dir: RwLock>, tracks: Mutex>, compiled: Mutex>, + /// 编过的 manifest 的缓存([`manifests`]),**只拿来显示**休眠的插件 + shown: Mutex, sink: Mutex>, /// 改插件文件和改配置是一件事的两半(写文件、写哈希)。**控制面改的时候攥着它**, /// 目录监听重载插件之前也要拿到它 —— 不然监听可能正好落在两半之间,把一个马上就要 @@ -71,6 +86,7 @@ impl Plugins { dir: RwLock::new(None), tracks: Mutex::default(), compiled: Mutex::default(), + shown: Mutex::default(), sink: Mutex::default(), edits: tokio::sync::Mutex::new(()), } @@ -137,16 +153,39 @@ impl Plugins { self.compile(&*engine, Sha256::digest(source).into(), source) } + /// 记下一个编出来的 manifest,给以后显示休眠的插件用([`manifests`])。配置里的插件编 + /// 过之后 [`Self::build`] 自己会记;**默认插件那一路不编**(它带着预先算好的 + /// manifest),装上之前从这里记一笔 + pub fn remember(&self, sha256: &str, m: &Manifest) { + if let Some(dir) = self.dir() { + self.shown + .lock() + .unwrap_or_else(PoisonError::into_inner) + .put(&dir, sha256, m); + } + } + /// 照这份配置建一份插件。**不会失败**:哪个插件有问题,问题落在它自己的状态上。 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()); + // 有一个开着,运行时反正要起:全都编。一个都没开:停用的只读文件、不编(休眠) + let awake = config.plugins.iter().any(|p| p.enabled); 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), + Some(dir) if awake || p.enabled => { + let (state, m) = self.load_one(dir, p, &*engine, &mut used); + // 编出来的记一笔:之后(比如下一次启动)它停用着、运行时没起时,列表 + // 照样说得出它是什么 + if let Some(m) = &m { + self.remember(&p.sha256, m); + } + (state, m) + } + Some(dir) => self.dormant_one(dir, p, &mut used), None => (State::Broken(Broken::Error(not_located())), None), }; let (state, settings) = match (state, &manifest) { @@ -174,11 +213,20 @@ impl Plugins { logs: track.logs, })); } - // 只留这一份还用得着的:编译结果、计数和日志。删掉的插件,它的计数跟着走 + // 只留这一份还用得着的:编译结果、计数和日志、显示用的 manifest。删掉的插件, + // 它的计数跟着走 self.compiled .lock() .unwrap_or_else(PoisonError::into_inner) .retain(|k, _| used.contains(k)); + if let Some(dir) = dir.as_deref() { + let approved: HashSet = + config.plugins.iter().map(|p| p.sha256.clone()).collect(); + self.shown + .lock() + .unwrap_or_else(PoisonError::into_inner) + .keep(dir, &approved); + } self.tracks .lock() .unwrap_or_else(PoisonError::into_inner) @@ -240,6 +288,65 @@ impl Plugins { } } + /// 停用着、运行时没起的一个插件:**不编**。文件照样读、哈希照样比(「文件变了」照样 + /// 查得出来);这个进程里编过的直接拿来用,没编过的是休眠的,显示用的 manifest 从缓存 + /// 里拿,缓存里没有就只有 id + fn dormant_one( + &self, + dir: &Path, + p: &tw_config::Plugin, + used: &mut HashSet<[u8; 32]>, + ) -> (State, Option) { + let approved = unhex(&p.sha256); + let compiled = approved.and_then(|sha| { + let cache = self.compiled.lock().unwrap_or_else(PoisonError::into_inner); + match cache.get(&sha) { + Some(Ok(host)) => Some((sha, host.clone())), + _ => None, + } + }); + let shown = match &compiled { + Some((sha, host)) => { + used.insert(*sha); + self.remember(&p.sha256, host.manifest()); + Some(host.manifest().clone()) + } + None => self + .shown + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(dir, &p.sha256), + }; + 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), shown); + } + Err(e) => { + return ( + State::Broken(Broken::Error(msg!( + "gw.plugin.unreadable", file = &p.file, detail = e => + "The plugin file {file} cannot be read: {detail}" + ))), + shown, + ); + } + }; + let sha: [u8; 32] = Sha256::digest(&bytes).into(); + if hex(&sha) != p.sha256 { + return (State::Broken(Broken::Changed), shown); + } + if let Some((_, host)) = compiled { + return (State::Ready(host), shown); + } + let host = Dormant { + manifest: shown.clone().unwrap_or_else(|| placeholder(&p.id)), + sha256: sha, + }; + (State::Ready(Arc::new(host)), shown) + } + /// 文件变了时,批准过的那一份的 manifest —— **只拿来显示**(名字、权限、设置项), /// 不跑。底稿也不是那一份了(被人动过、没了)就没有。 fn approved_manifest( @@ -268,6 +375,52 @@ impl Plugins { } } +/// 停用着、这个进程里还没编过的插件(见 [`Plugins::build`])。**跑不了任何钩子**( +/// [`PluginHost`] 的默认实现一律报错):要它跑之前,调用方先真的编一遍。手里的 manifest +/// 是缓存里的那一份或者只有名字的占位,**只拿来显示** +struct Dormant { + manifest: Manifest, + sha256: [u8; 32], +} + +impl PluginHost for Dormant { + fn manifest(&self) -> &Manifest { + &self.manifest + } + fn sha256(&self) -> [u8; 32] { + self.sha256 + } + fn dormant(&self) -> bool { + true + } +} + +/// 缓存里没有它的 manifest 时的占位:名字就是 id,什么权限、钩子都没有 +fn placeholder(id: &str) -> Manifest { + Manifest { + name: id.to_string(), + api: 1, + description: None, + permissions: Vec::new(), + scope: Scope::default(), + reply_mode: tw_api::ReplyMode::Block, + settings: Vec::new(), + hooks: Default::default(), + } +} + +/// 小写十六进制的 SHA-256 读回字节。写法不对是 None +fn unhex(s: &str) -> Option<[u8; 32]> { + if !tw_config::plugins::valid_sha256(s) { + return None; + } + let mut out = [0u8; 32]; + for (i, b) in out.iter_mut().enumerate() { + *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?; + } + Some(out) +} + /// 读一个文件,**最多读到上限多一个字节**:再大的插件反正编不了,哈希也一定对不上 pub fn read_capped(path: &Path) -> std::io::Result> { let mut buf = Vec::new(); @@ -673,6 +826,106 @@ mod tests { assert!(newly_broken(&ok, &off).is_empty()); } + fn off(mut p: tw_config::Plugin) -> tw_config::Plugin { + p.enabled = false; + p + } + + /// 一个插件都没开:停用的不编(不起运行时),休眠着;缓存里没有就只有 id + #[test] + fn with_nothing_enabled_a_disabled_plugin_is_not_compiled() { + let bed = Bed::new(); + let engine = Arc::new(Counting(Default::default())); + bed.plugins.set_engine(engine.clone()); + let set = bed.build(vec![off(bed.install("add-date", &add_date()))]); + assert_eq!(engine.count(), 0); + let a = set.get("add-date").unwrap(); + let host = a.ready().expect("a dormant plugin is not broken"); + assert!(host.dormant()); + assert_eq!(a.name, "add-date"); + assert!(a.manifest.is_none() && a.permissions.is_empty()); + // 跑不了:钩子一律报错 + assert!(host.on_request(json!({}), json!({})).result.is_err()); + } + + /// 编过一次的记在缓存里:下一个进程里它停用着,列表照样说得出它是什么,而且不编 + #[test] + fn a_dormant_plugin_shows_the_manifest_remembered_from_an_earlier_compile() { + let bed = Bed::new(); + let p = bed.install("add-date", &add_date()); + bed.build(vec![p.clone()]); + let next = Plugins::new(Arc::new(Counting(Default::default()))); + next.set_dir(bed.dir.path().to_path_buf()); + let set = next.build(&tw_config::Config { + plugins: vec![off(p)], + ..Default::default() + }); + let a = set.get("add-date").unwrap(); + assert!(a.ready().unwrap().dormant()); + assert_eq!(a.name, "附加日期"); + assert_eq!(a.permissions, [tw_api::Permission::System]); + assert_eq!(a.settings["note"], json!("今天")); + } + + /// 有一个开着,运行时反正要起:全都编,停用的也编(缓存跟着补齐) + #[test] + fn once_one_plugin_is_enabled_every_plugin_is_compiled() { + let bed = Bed::new(); + let engine = Arc::new(Counting(Default::default())); + bed.plugins.set_engine(engine.clone()); + let other = add_date().replace("附加日期", "另一个"); + let set = bed.build(vec![ + off(bed.install("add-date", &add_date())), + bed.install("other", &other), + ]); + assert_eq!(engine.count(), 2); + let a = set.get("add-date").unwrap(); + assert!(!a.ready().unwrap().dormant()); + assert_eq!(a.name, "附加日期"); + } + + /// 休眠的插件文件变了:照样是「变了」(只算哈希,不编),显示的是缓存里批准的那一份 + #[test] + fn a_dormant_plugin_whose_file_changed_is_changed_without_compiling() { + let bed = Bed::new(); + let p = bed.install("add-date", &add_date()); + bed.plugins.remember(&p.sha256, &add_date_manifest()); + let engine = Arc::new(Counting(Default::default())); + bed.plugins.set_engine(engine.clone()); + std::fs::write( + tw_config::plugins::file_path(bed.dir.path(), "add-date"), + "changed", + ) + .unwrap(); + let set = bed.build(vec![off(p)]); + let a = set.get("add-date").unwrap(); + assert_eq!(a.broken(), Some(&Broken::Changed)); + assert_eq!(a.name, "附加日期"); + assert_eq!(engine.count(), 0); + } + + /// 缓存里是另一份字节的(插件换过源码):不拿来冒充 + #[test] + fn a_cached_manifest_of_other_bytes_is_not_used() { + let bed = Bed::new(); + let old = bed.install("add-date", &add_date()); + bed.plugins.remember(&old.sha256, &add_date_manifest()); + let newer = add_date().replace("附加日期", "新的一版"); + let p = bed.install("add-date", &newer); + let set = bed.build(vec![off(p)]); + let a = set.get("add-date").unwrap(); + assert_eq!(a.name, "add-date"); + assert!(a.manifest.is_none()); + } + + fn add_date_manifest() -> Manifest { + FakeEngine + .load(add_date().as_bytes()) + .unwrap() + .manifest() + .clone() + } + /// 一个读不下的大文件:只读到上限多一个字节,哈希对不上,就是变了 #[test] fn a_huge_file_is_read_only_up_to_the_limit() { diff --git a/crates/tw-gateway/src/plugin/manifests.rs b/crates/tw-gateway/src/plugin/manifests.rs new file mode 100644 index 00000000..38b607ce --- /dev/null +++ b/crates/tw-gateway/src/plugin/manifests.rs @@ -0,0 +1,362 @@ +//! 编过的插件的 manifest,存在插件目录的 `.manifests.json` 里,**只拿来显示**。 +//! +//! 停用着的插件不为它起运行时(见 [`super::load::Plugins::build`]):沙箱一起来就是几 MB +//! 常驻内存,而一个插件都没打开的用户不该为它付这个钱。插件页上要的名字、说明、权限和 +//! 设置项就从这里读。 +//! +//! - **键是批准的那份字节的 SHA-256**;整份文件带着运行时和这份格式的版本([`version`]), +//! 版本对不上(core 升级、沙箱换了)就整份不认,等下一次运行时起来时重建;读不出来、 +//! 写坏了也一样当作没有。没有可用的那一条时,插件只按 id 和状态列出来,**不为了列个 +//! 名字去起运行时**。 +//! - 只有 core 写它,和插件文件一样只给自己(0600)。 +//! - **安全上的判断一律不用它**:打开插件、改改得了工具调用的插件的设置和范围、试跑之前, +//! 都先真的编一遍、看编出来的 manifest。它是用户目录里的一个文件,被人改了只是显示不对。 + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::plugin::engine::{Hooks, Manifest, SettingSpec}; +use crate::plugin::set::Scope; + +/// 文件名,在插件目录里。点开头:它不是插件 +pub const FILE: &str = ".manifests.json"; + +/// 这份格式自己的版本。**manifest 的读法或者这里的写法改了就加一** +const FORMAT: u32 = 1; + +/// 缓存认的版本:core 的版本、沙箱的哈希、这份格式的版本,三样有一样不同就不认 +pub fn version() -> String { + format!( + "{}/{}/{FORMAT}", + env!("CARGO_PKG_VERSION"), + tw_plugin::GUEST_WASM_SHA256 + ) +} + +/// `.manifests.json` 在哪儿。`dir` 是配置文件所在的目录 +pub fn path_in(dir: &Path) -> PathBuf { + tw_config::plugins::dir_in(dir).join(FILE) +} + +/// 文件里的样子 +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Stored { + version: String, + /// 批准的那份字节的 SHA-256(小写十六进制)→ manifest + manifests: BTreeMap, +} + +/// 一个 manifest 写进文件的样子。**和 [`Manifest`] 一一对应**,只是带着 serde +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Entry { + name: String, + api: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + permissions: Vec, + scope: ScopeEntry, + reply_mode: tw_api::ReplyMode, + settings: Vec, + hooks: HooksEntry, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ScopeEntry { + clients: Vec, + models: Vec, + upstreams: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SettingEntry { + key: String, + kind: tw_api::SettingKind, + label: String, + default: serde_json::Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct HooksEntry { + request: bool, + reply_text: bool, + reply_text_end: bool, + tool_call: bool, +} + +impl From<&Manifest> for Entry { + fn from(m: &Manifest) -> Self { + Self { + name: m.name.clone(), + api: m.api, + description: m.description.clone(), + permissions: m.permissions.clone(), + scope: ScopeEntry { + clients: m.scope.clients.clone(), + models: m.scope.models.clone(), + upstreams: m.scope.upstreams.clone(), + }, + reply_mode: m.reply_mode, + settings: m + .settings + .iter() + .map(|s| SettingEntry { + key: s.key.clone(), + kind: s.kind, + label: s.label.clone(), + default: s.default.clone(), + }) + .collect(), + hooks: HooksEntry { + request: m.hooks.request, + reply_text: m.hooks.reply_text, + reply_text_end: m.hooks.reply_text_end, + tool_call: m.hooks.tool_call, + }, + } + } +} + +impl From<&Entry> for Manifest { + fn from(e: &Entry) -> Self { + Self { + name: e.name.clone(), + api: e.api, + description: e.description.clone(), + permissions: e.permissions.clone(), + scope: Scope { + clients: e.scope.clients.clone(), + models: e.scope.models.clone(), + upstreams: e.scope.upstreams.clone(), + }, + reply_mode: e.reply_mode, + settings: e + .settings + .iter() + .map(|s| SettingSpec { + key: s.key.clone(), + kind: s.kind, + label: s.label.clone(), + default: s.default.clone(), + }) + .collect(), + hooks: Hooks { + request: e.hooks.request, + reply_text: e.hooks.reply_text, + reply_text_end: e.hooks.reply_text_end, + tool_call: e.hooks.tool_call, + }, + } + } +} + +/// 进程里的那一份,对着一个插件目录。**读一次**,之后改的时候连文件一起写 +#[derive(Debug, Default)] +pub(crate) struct Cache { + /// 对着的是哪个目录。换了目录就重新读 + dir: Option, + entries: HashMap, +} + +impl Cache { + /// 换到这个目录上(还没读过就读一次) + fn at(&mut self, dir: &Path) { + if self.dir.as_deref() == Some(dir) { + return; + } + self.entries = read(&path_in(dir)).unwrap_or_default(); + self.dir = Some(dir.to_path_buf()); + } + + /// 这份字节的 manifest,有就给 + pub(crate) fn get(&mut self, dir: &Path, sha256: &str) -> Option { + self.at(dir); + self.entries.get(sha256).cloned() + } + + /// 记下一个编出来的 manifest。和记着的一样就不写文件 + pub(crate) fn put(&mut self, dir: &Path, sha256: &str, m: &Manifest) { + self.at(dir); + if self.entries.get(sha256) == Some(m) { + return; + } + self.entries.insert(sha256.to_string(), m.clone()); + self.save(dir); + } + + /// 只留这几份字节的。**配置里不再有的插件,它的那一条跟着走** + pub(crate) fn keep(&mut self, dir: &Path, wanted: &std::collections::HashSet) { + self.at(dir); + let before = self.entries.len(); + self.entries.retain(|sha, _| wanted.contains(sha)); + if self.entries.len() != before { + self.save(dir); + } + } + + fn save(&self, dir: &Path) { + let stored = Stored { + version: version(), + manifests: self + .entries + .iter() + .map(|(k, m)| (k.clone(), Entry::from(m))) + .collect(), + }; + let path = path_in(dir); + let written = serde_json::to_vec_pretty(&stored) + .map_err(std::io::Error::other) + .and_then(|mut bytes| { + bytes.push(b'\n'); + tw_config::private_dir::create(&tw_config::plugins::dir_in(dir))?; + write_private(&path, &bytes) + }); + // 写不成只是下一次启动要多起一次运行时才列得全,不影响别的 + if let Err(e) = written { + tracing::debug!(file = %path.display(), "the plugin manifest cache could not be written: {e}"); + } + } +} + +/// 读一份缓存。**版本对不上、读不出来、写坏了都是没有** +fn read(path: &Path) -> Option> { + let bytes = std::fs::read(path).ok()?; + let stored: Stored = serde_json::from_slice(&bytes).ok()?; + if stored.version != version() { + return None; + } + Some( + stored + .manifests + .iter() + .filter(|(sha, _)| tw_config::plugins::valid_sha256(sha)) + .map(|(sha, e)| (sha.clone(), Manifest::from(e))) + .collect(), + ) +} + +/// 一个 manifest 写成 JSON(默认插件预先算好的那一份就是这么生成的) +#[cfg(test)] +pub(crate) fn to_json(m: &Manifest) -> serde_json::Value { + serde_json::to_value(Entry::from(m)).unwrap_or_default() +} + +/// 从 JSON 读回一个 manifest +pub(crate) fn from_json(v: &serde_json::Value) -> Option { + let e: Entry = serde_json::from_value(v.clone()).ok()?; + Some(Manifest::from(&e)) +} + +/// 原子地写一个只给自己看的文件:建的那一刻就是 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> Manifest { + Manifest { + name: "附加日期".into(), + api: 1, + description: Some("在系统提示里写上今天的日期".into()), + permissions: vec![tw_api::Permission::System], + scope: Scope { + clients: vec![], + models: vec!["deepseek*".into()], + upstreams: vec![], + }, + reply_mode: tw_api::ReplyMode::Block, + settings: vec![SettingSpec { + key: "note".into(), + kind: tw_api::SettingKind::String, + label: "附加内容".into(), + default: "第一行\n第二行".into(), + }], + hooks: Hooks { + request: true, + ..Default::default() + }, + } + } + + const SHA: &str = "6f1c000000000000000000000000000000000000000000000000000000000abc"; + + /// 写下去、换一个进程(新的一份)读回来,一模一样;文件只给自己 + #[test] + fn a_manifest_written_once_reads_back_in_the_next_process() { + let dir = tempfile::tempdir().unwrap(); + let mut c = Cache::default(); + c.put(dir.path(), SHA, &manifest()); + let mut again = Cache::default(); + assert_eq!(again.get(dir.path(), SHA), Some(manifest())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(path_in(dir.path())) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + + /// 版本不对、写坏了:整份不认 + #[test] + fn another_version_or_a_broken_file_is_no_cache_at_all() { + let dir = tempfile::tempdir().unwrap(); + Cache::default().put(dir.path(), SHA, &manifest()); + let path = path_in(dir.path()); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, text.replace(&version(), "0.0.0/old/1")).unwrap(); + assert_eq!(Cache::default().get(dir.path(), SHA), None); + std::fs::write(&path, "{ not json").unwrap(); + assert_eq!(Cache::default().get(dir.path(), SHA), None); + } + + /// 配置里不再有的插件,它那一条跟着走 + #[test] + fn only_the_hashes_still_in_use_are_kept() { + let dir = tempfile::tempdir().unwrap(); + let mut c = Cache::default(); + let other = SHA.replace("abc", "def"); + c.put(dir.path(), SHA, &manifest()); + c.put(dir.path(), &other, &manifest()); + c.keep(dir.path(), &[other.clone()].into_iter().collect()); + let mut again = Cache::default(); + assert_eq!(again.get(dir.path(), SHA), None); + assert!(again.get(dir.path(), &other).is_some()); + } + + #[test] + fn json_round_trips() { + let m = manifest(); + assert_eq!(from_json(&to_json(&m)), Some(m)); + } +} diff --git a/crates/tw-gateway/src/plugin/mod.rs b/crates/tw-gateway/src/plugin/mod.rs index 2634e54c..7e685015 100644 --- a/crates/tw-gateway/src/plugin/mod.rs +++ b/crates/tw-gateway/src/plugin/mod.rs @@ -27,7 +27,8 @@ //! - [`pool`]:插件调用都是阻塞的、吃 CPU 的,放在专用线程池上跑,不占 tokio 的线程。 //! - [`trial`]:对着存下来的请求和回答试跑一个插件。 //! -//! [`defaults`] 是随 core 一起发的那几个插件(清单和源码)。 +//! [`defaults`] 是随 core 一起发的那几个插件(清单和源码);[`manifests`] 是编过的插件的 +//! manifest 缓存,一个插件都没开时拿它显示停用的插件,不为此起运行时(见 [`load`])。 pub mod bridge; pub mod defaults; @@ -37,6 +38,7 @@ pub mod engine; pub mod fake; pub mod host; pub mod load; +pub mod manifests; pub mod pool; pub mod reply; pub mod request; diff --git a/crates/tw-gateway/tests/plugins_defaults.rs b/crates/tw-gateway/tests/plugins_defaults.rs index 5533e455..b0f29387 100644 --- a/crates/tw-gateway/tests/plugins_defaults.rs +++ b/crates/tw-gateway/tests/plugins_defaults.rs @@ -12,17 +12,11 @@ use tw_config::Security; use tw_gateway::plugin::engine::Engine; const REPLY_LANGUAGE: &str = include_str!("../src/plugin/defaults/reply-language.js"); -const CURRENT_DATE: &str = include_str!("../src/plugin/defaults/current-date.js"); -const TERM_UNIFY: &str = include_str!("../src/plugin/defaults/term-unify.js"); -const REPLY_REDACT: &str = include_str!("../src/plugin/defaults/reply-redact.js"); const WSL_PATHS: &str = include_str!("../src/plugin/defaults/wsl-paths.js"); const DEEPSEEK_FLAGS: &str = include_str!("../src/plugin/defaults/deepseek-flags.js"); -const DEFAULTS: [(&str, &str); 6] = [ +const DEFAULTS: [(&str, &str); 3] = [ ("reply-language", REPLY_LANGUAGE), - ("current-date", CURRENT_DATE), - ("term-unify", TERM_UNIFY), - ("reply-redact", REPLY_REDACT), ("wsl-paths", WSL_PATHS), ("deepseek-flags", DEEPSEEK_FLAGS), ]; @@ -70,35 +64,13 @@ fn every_default_loads_with_its_fixed_permissions_and_settings() { &'static [P], &'static [(&'static str, K)], ); - let want: [Expected; 6] = [ + let want: [Expected; 3] = [ ( "reply-language", "指定回答语言", &[P::System], &[("language", K::String)], ), - ( - "current-date", - "附加当前日期", - &[P::System], - &[("utc_offset", K::Number)], - ), - ( - "term-unify", - "统一用词", - &[P::ReplyText], - &[("terms", K::String)], - ), - ( - "reply-redact", - "回答内容打码", - &[P::ReplyText], - &[ - ("patterns", K::String), - ("replacement", K::String), - ("ignore_case", K::Boolean), - ], - ), ( "wsl-paths", "WSL 路径转换", @@ -160,16 +132,15 @@ fn the_defaults_directory_holds_exactly_the_tested_plugins() { ); } -// ── 改系统提示词的两个 ────────────────────────────────────────── +// ── 改系统提示词的 ────────────────────────────────────────────── #[tokio::test] -async fn current_date_and_reply_language_append_to_the_system_prompt_in_every_format() { +async fn reply_language_appends_to_the_system_prompt_in_every_format() { for fmt in FORMATS { let up = Upstream::start(vec![Answer::Text("好的".into())]).await; let gw = Gateway::start( config(&up, Security::default()), vec![ - Plug::new("current-date", CURRENT_DATE), Plug::new("reply-language", REPLY_LANGUAGE) .settings(json!({ "language": "English" })), ], @@ -186,22 +157,12 @@ async fn current_date_and_reply_language_append_to_the_system_prompt_in_every_fo .await; let system = sent_system(&up.body(0)); assert!(system.starts_with("你是助手。"), "{fmt:?}: {system}"); - // 默认时区是 UTC+8 - assert!( - system.contains("Today's date: 20") && system.contains("(UTC+8)."), - "{fmt:?}: {system}" - ); assert!( system.contains( "Always respond in English, unless the user explicitly asks for another language." ), "{fmt:?}: {system}" ); - // 按插件表的顺序:日期在前 - assert!( - system.find("Today's date").unwrap() < system.find("Always respond").unwrap(), - "{fmt:?}: {system}" - ); } } @@ -227,132 +188,6 @@ async fn reply_language_takes_only_a_language_name() { assert_eq!(up.hits(), 0); } -// ── 改回答文字的两个 ──────────────────────────────────────────── - -#[tokio::test] -async fn term_unify_replaces_terms_split_across_streamed_pieces_in_every_format() { - for fmt in FORMATS { - for stream in [true, false] { - // 假上游一个字一帧:「登」「陆」必然落在两帧里 - let up = - Upstream::start(vec![Answer::Text("请先登陆你的帐号,再打开登陆页".into())]).await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![ - Plug::new("term-unify", TERM_UNIFY) - .settings(json!({ "terms": "登陆=登录\n登陆页=登录界面\n帐号=账号" })), - ], - ) - .await; - let r = ask( - &gw, - fmt, - MODEL, - "你是助手。", - &[Turn::User("怎么用".into())], - stream, - ) - .await; - assert_eq!( - fmt.text(&r.body, stream), - "请先登录你的账号,再打开登录界面", - "{fmt:?} stream={stream}: {}", - r.body - ); - } - } -} - -#[tokio::test] -async fn reply_redact_masks_every_pattern_in_every_format() { - for fmt in FORMATS { - for stream in [true, false] { - let up = Upstream::start(vec![Answer::Text( - "员工 EMP-123456 在 Build-01.CORP.example.com 上,另见 example.com".into(), - )]) - .await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![Plug::new("reply-redact", REPLY_REDACT).settings(json!({ - "patterns": "EMP-\\d{6}\n[a-z0-9-]+\\.corp\\.example\\.com", - "replacement": "[已隐藏]" - }))], - ) - .await; - let r = ask( - &gw, - fmt, - MODEL, - "你是助手。", - &[Turn::User("谁在哪".into())], - stream, - ) - .await; - assert_eq!( - fmt.text(&r.body, stream), - "员工 [已隐藏] 在 [已隐藏] 上,另见 example.com", - "{fmt:?} stream={stream}: {}", - r.body - ); - } - } -} - -#[tokio::test] -async fn multi_line_settings_tolerate_windows_line_endings_blank_lines_and_spaces() { - // 设置里的多行值:一行一条,`\r\n` 也认,空行和首尾的空白忽略 - let up = Upstream::start(vec![Answer::Text("请先登陆帐号,工号 EMP-123456".into())]).await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![ - Plug::new("term-unify", TERM_UNIFY) - .settings(json!({ "terms": "登陆=登录\r\n\r\n 帐号 = 账号 \r\n" })), - Plug::new("reply-redact", REPLY_REDACT).settings( - json!({ "patterns": "\r\n EMP-\\d{6} \r\n\r\n", "replacement": "***" }), - ), - ], - ) - .await; - for stream in [true, false] { - let r = ask( - &gw, - Fmt::Anthropic, - MODEL, - "你是助手。", - &[Turn::User("你好".into())], - stream, - ) - .await; - assert_eq!( - Fmt::Anthropic.text(&r.body, stream), - "请先登录账号,工号 ***", - "stream={stream}: {}", - r.body - ); - } -} - -#[tokio::test] -async fn reply_redact_without_patterns_changes_nothing() { - let up = Upstream::start(vec![Answer::Text("原样的回答".into())]).await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![Plug::new("reply-redact", REPLY_REDACT)], - ) - .await; - let r = ask( - &gw, - Fmt::Anthropic, - MODEL, - "你是助手。", - &[Turn::User("你好".into())], - true, - ) - .await; - assert_eq!(Fmt::Anthropic.text(&r.body, true), "原样的回答"); - assert_eq!(gw.outcomes("reply-redact"), ["unchanged"]); -} - // ── WSL 路径 ──────────────────────────────────────────────────── #[tokio::test] diff --git a/scripts/smoke.sh b/scripts/smoke.sh index a7187b5d..18f79fe5 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -278,10 +278,28 @@ print(m.group(1) if m else "") PY ) [ -n "$KEY" ] && ok "serve 给没有钥匙的配置补上了钥匙" || bad "配置里没有钥匙" "$(head -12 "$CFG")" -ADDED=$(diff "$TMP/config.before" "$CFG" | grep -c '^>') -REMOVED=$(diff "$TMP/config.before" "$CFG" | grep -c '^<') -[ "$ADDED" = 2 ] && [ "$REMOVED" = 0 ] && ok "只多出钥匙那两行,别的一个字节没动" \ - || bad "补钥匙改动了别的地方" "$(diff "$TMP/config.before" "$CFG" | head -8)" +# 第一次起来还会在末尾补上默认插件(`plugins:` 那一节,见下一条):先把它拆出来, +# 剩下的部分只该多出钥匙那两行 +python3 - "$CFG" "$TMP/config.head" "$TMP/config.plugins" <<'PY' +import sys +text = open(sys.argv[1], encoding='utf-8').read() +i = text.find('\nplugins:\n') +head, tail = (text[:i + 1], text[i + 1:]) if i >= 0 else (text, '') +open(sys.argv[2], 'w', encoding='utf-8').write(head) +open(sys.argv[3], 'w', encoding='utf-8').write(tail) +PY +ADDED=$(diff "$TMP/config.before" "$TMP/config.head" | grep -c '^>') +REMOVED=$(diff "$TMP/config.before" "$TMP/config.head" | grep -c '^<') +[ "$ADDED" = 2 ] && [ "$REMOVED" = 0 ] && ok "只多出钥匙那两行和末尾的默认插件,别的一个字节没动" \ + || bad "补钥匙改动了别的地方" "$(diff "$TMP/config.before" "$TMP/config.head" | head -8)" +# 默认插件:插件目录里的每一个都在配置里、都停用着;记下给过哪些的那个文件只给自己 +N=$(grep -c '^ - id: ' "$TMP/config.plugins") +ON=$(grep -c '^ enabled: true' "$TMP/config.plugins") +JS=$(find "$THINKWATCH_HOME/plugins" -maxdepth 1 -name '*.js' | wc -l | tr -d ' ') +[ "$N" -gt 0 ] && [ "$N" = "$JS" ] && [ "$ON" = 0 ] && ok "装上了 $N 个默认插件,都停用着" \ + || bad "默认插件不对:配置里 $N 个、文件 $JS 个、开着 $ON 个" "$(head -12 "$TMP/config.plugins")" +MODE=$(mode_of "$THINKWATCH_HOME/plugins/.defaults.json" || echo -) +[ "$MODE" = "600" ] && ok "plugins/.defaults.json 是 0600" || bad "plugins/.defaults.json 权限是 $MODE" [ "$("$BIN" --config "$CFG" control-key)" = "$KEY" ] && ok "control-key 打印的就是这把" || bad "control-key 打印的不是配置里那把" MODE=$(mode_of "$THINKWATCH_HOME/data.db" || echo -) From 381a22f936ac6a69f7a366ea5deeaca3474d05bb Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:41:25 +0800 Subject: [PATCH 3/4] Write the default plugins' names and descriptions in English The DeepSeek plugin is now "Avoid DeepSeek request rejections"; its id stays deepseek-flags. The other two are "Answer in a chosen language" and "Convert WSL and Windows paths". Lite shows localized names and descriptions for these known ids and falls back to the manifest text for user plugins. The precomputed manifests are regenerated. Co-Authored-By: Claude Opus 5.5 --- crates/tw-control/tests/plugin_defaults.rs | 2 +- .../src/plugin/defaults/deepseek-flags.js | 6 +++--- .../src/plugin/defaults/manifests.json | 18 +++++++++--------- .../src/plugin/defaults/reply-language.js | 5 +++-- .../src/plugin/defaults/wsl-paths.js | 4 ++-- crates/tw-gateway/tests/plugins_defaults.rs | 6 +++--- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/crates/tw-control/tests/plugin_defaults.rs b/crates/tw-control/tests/plugin_defaults.rs index 866cb295..815e2720 100644 --- a/crates/tw-control/tests/plugin_defaults.rs +++ b/crates/tw-control/tests/plugin_defaults.rs @@ -689,7 +689,7 @@ async fn the_shipped_defaults_go_in_turned_off_without_starting_the_sandbox() { let again = b.restart(engine.clone()); assert_eq!(Seeder::shipped().seed(&again.mgr).await, Seeded::default()); let v = again.plugin("wsl-paths").await; - assert_eq!(v["name"], "WSL 路径转换"); + assert_eq!(v["name"], "Convert WSL and Windows paths"); assert_eq!(v["permissions"], json!(["messages", "reply_tool_calls"])); assert_eq!(engine.count(), 0); } diff --git a/crates/tw-gateway/src/plugin/defaults/deepseek-flags.js b/crates/tw-gateway/src/plugin/defaults/deepseek-flags.js index c6f936fb..d648a922 100644 --- a/crates/tw-gateway/src/plugin/defaults/deepseek-flags.js +++ b/crates/tw-gateway/src/plugin/defaults/deepseek-flags.js @@ -1,4 +1,4 @@ -// DeepSeek:替换会被拒收的旗帜表情 +// 避免 DeepSeek 拒收请求 // // DeepSeek 接口会拒收含特定地区旗帜表情的请求:模型还没运行就回 400 Content Exists Risk。 // 这类表情一旦进入对话历史(例如工具抓回的网页、读到的文件),之后这个会话的每一次请求 @@ -22,10 +22,10 @@ // 权限:system、messages(请求一侧替换),reply.text、reply.tool_calls(回答一侧换回)。 export const manifest = { - name: "DeepSeek:替换会被拒收的旗帜表情", + name: "Avoid DeepSeek request rejections", api: 1, description: - "DeepSeek 接口会拒收含特定地区旗帜表情的请求,含有它们的会话因此无法继续。请求发出前把这些表情换成 ASCII 占位文字,回答里再换回原样。默认对发往 deepseek 开头的模型的请求生效。", + "DeepSeek's API rejects requests that contain a certain regional flag emoji with 400 Content Exists Risk, and the whole conversation then stays stuck. This plugin replaces such emoji with placeholder text before sending and puts them back in answers and tool calls.", permissions: ["system", "messages", "reply.text", "reply.tool_calls"], match: { models: ["deepseek*"] }, reply: "stream", diff --git a/crates/tw-gateway/src/plugin/defaults/manifests.json b/crates/tw-gateway/src/plugin/defaults/manifests.json index fc8ab02f..26c979b1 100644 --- a/crates/tw-gateway/src/plugin/defaults/manifests.json +++ b/crates/tw-gateway/src/plugin/defaults/manifests.json @@ -2,14 +2,14 @@ "deepseek-flags": { "manifest": { "api": 1, - "description": "DeepSeek 接口会拒收含特定地区旗帜表情的请求,含有它们的会话因此无法继续。请求发出前把这些表情换成 ASCII 占位文字,回答里再换回原样。默认对发往 deepseek 开头的模型的请求生效。", + "description": "DeepSeek's API rejects requests that contain a certain regional flag emoji with 400 Content Exists Risk, and the whole conversation then stays stuck. This plugin replaces such emoji with placeholder text before sending and puts them back in answers and tool calls.", "hooks": { "reply_text": true, "reply_text_end": true, "request": true, "tool_call": true }, - "name": "DeepSeek:替换会被拒收的旗帜表情", + "name": "Avoid DeepSeek request rejections", "permissions": [ "system", "messages", @@ -26,19 +26,19 @@ }, "settings": [] }, - "sha256": "75012e294a8e019db17c88652bd291fa2f6c03a0f625bd097bac553b153fb450" + "sha256": "96a1069558726008bb19f39b09288585634570af55e02eec6194d4a50bee4327" }, "reply-language": { "manifest": { "api": 1, - "description": "在系统提示词末尾要求模型用指定的语言回答。", + "description": "Adds a fixed line to the end of the system prompt that asks the model to answer in the language set here.", "hooks": { "reply_text": false, "reply_text_end": false, "request": true, "tool_call": false }, - "name": "指定回答语言", + "name": "Answer in a chosen language", "permissions": [ "system" ], @@ -57,19 +57,19 @@ } ] }, - "sha256": "930de8249548edea683c7ce04970c592adb6c8d7d50eb1b72c8d5fb0609baede" + "sha256": "df13934d4b0d7875f3c6f6882105757b7c3b4eb0a1f12bc9f35fc12ec2c26f5f" }, "wsl-paths": { "manifest": { "api": 1, - "description": "把工具调用参数里的盘符路径统一成客户端那一侧的写法(WSL 的 /mnt/c/… 或 Windows 的 C:\\…),回答和对话历史里的都改。", + "description": "Rewrites drive paths in tool-call arguments to the form the client can open (WSL /mnt/c/… or Windows C:\\…), in answers and in the conversation history.", "hooks": { "reply_text": false, "reply_text_end": false, "request": true, "tool_call": true }, - "name": "WSL 路径转换", + "name": "Convert WSL and Windows paths", "permissions": [ "messages", "reply_tool_calls" @@ -89,6 +89,6 @@ } ] }, - "sha256": "83e1328d38fda8847a84f4a1791aaa84923da5b7dcfdf9b4f87e2b624ccf39e6" + "sha256": "92d7f7b897659b92b66f8b9c369dba60983398cfe50a06c7b994c73f087aec0e" } } diff --git a/crates/tw-gateway/src/plugin/defaults/reply-language.js b/crates/tw-gateway/src/plugin/defaults/reply-language.js index 28c7cd06..a1ad660b 100644 --- a/crates/tw-gateway/src/plugin/defaults/reply-language.js +++ b/crates/tw-gateway/src/plugin/defaults/reply-language.js @@ -10,9 +10,10 @@ // 设置:回答语言,默认简体中文。 export const manifest = { - name: "指定回答语言", + name: "Answer in a chosen language", api: 1, - description: "在系统提示词末尾要求模型用指定的语言回答。", + description: + "Adds a fixed line to the end of the system prompt that asks the model to answer in the language set here.", permissions: ["system"], settings: { language: { type: "string", label: "回答语言", default: "简体中文" }, diff --git a/crates/tw-gateway/src/plugin/defaults/wsl-paths.js b/crates/tw-gateway/src/plugin/defaults/wsl-paths.js index 638211dc..58c353f7 100644 --- a/crates/tw-gateway/src/plugin/defaults/wsl-paths.js +++ b/crates/tw-gateway/src/plugin/defaults/wsl-paths.js @@ -18,10 +18,10 @@ // 设置:客户端运行在 Windows 上(关闭时按客户端在 WSL 里处理)。 export const manifest = { - name: "WSL 路径转换", + name: "Convert WSL and Windows paths", api: 1, description: - "把工具调用参数里的盘符路径统一成客户端那一侧的写法(WSL 的 /mnt/c/… 或 Windows 的 C:\\…),回答和对话历史里的都改。", + "Rewrites drive paths in tool-call arguments to the form the client can open (WSL /mnt/c/… or Windows C:\\…), in answers and in the conversation history.", permissions: ["messages", "reply.tool_calls"], settings: { windows_client: { diff --git a/crates/tw-gateway/tests/plugins_defaults.rs b/crates/tw-gateway/tests/plugins_defaults.rs index b0f29387..bbf04637 100644 --- a/crates/tw-gateway/tests/plugins_defaults.rs +++ b/crates/tw-gateway/tests/plugins_defaults.rs @@ -67,19 +67,19 @@ fn every_default_loads_with_its_fixed_permissions_and_settings() { let want: [Expected; 3] = [ ( "reply-language", - "指定回答语言", + "Answer in a chosen language", &[P::System], &[("language", K::String)], ), ( "wsl-paths", - "WSL 路径转换", + "Convert WSL and Windows paths", &[P::Messages, P::ReplyToolCalls], &[("windows_client", K::Boolean)], ), ( "deepseek-flags", - "DeepSeek:替换会被拒收的旗帜表情", + "Avoid DeepSeek request rejections", &[P::System, P::Messages, P::ReplyText, P::ReplyToolCalls], &[], ), From dc0fe46044a8c72b1258968d349e0768d6801ad0 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:05:41 +0800 Subject: [PATCH 4/4] Check out the default plugins with LF on every platform Seeding hashes the shipped source bytes and the precomputed manifests record those hashes. A Windows checkout converted the files to CRLF, so its binary carried different bytes, the precomputed manifests did not match, and seeding fell back to compiling. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index b413575e..82df201f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ # 桌面端按行读消息码清单,换行不能随平台变 crates/tw-api/msg-codes.txt text eol=lf +# 默认插件随 core 一起发:源码按字节算哈希(装上、换新版都拿它比),预先算好的 +# manifest 也记着这份哈希。各平台检出、编进二进制的字节必须一样 +crates/tw-gateway/src/plugin/defaults/*.js text eol=lf +crates/tw-gateway/src/plugin/defaults/manifests.json text eol=lf