Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 73 additions & 2 deletions crates/tw-control/tests/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ impl Bed {
}
}

/// 一张床:配置文件在 `dir`(相对临时目录的一段路径)里
/// 一张床:配置文件在 `dir`(相对临时目录的一段路径)里,引擎是假的
fn bed_in(sub: &str) -> Bed {
bed_with(sub, true)
}

fn bed_with(sub: &str, fake: bool) -> Bed {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(sub);
std::fs::create_dir_all(&dir).unwrap();
Expand All @@ -97,7 +101,9 @@ fn bed_in(sub: &str) -> Bed {
);
let store = Arc::new(tokio::sync::Mutex::new(rec));
let gw = tw_gateway::AppState::new(tw_config::try_parse(BASE).unwrap()).unwrap();
gw.set_plugin_engine(Arc::new(FakeEngine));
if fake {
gw.set_plugin_engine(Arc::new(FakeEngine));
}
let bus = gw.bus.clone();
let state = ControlState {
shutdown: Default::default(),
Expand Down Expand Up @@ -1079,3 +1085,68 @@ async fn a_plugin_changed_while_core_was_down_starts_out_changed() {
let p = gw.runtime().plugins.get(&id).unwrap().clone();
assert_eq!(p.broken(), Some(&tw_gateway::plugin::Broken::Changed));
}

/// 真的沙箱:从源码到装上、文件被改、批准,整条路走一遍(不跑钩子,那是数据面的事)
#[tokio::test]
async fn a_real_plugin_goes_through_the_sandbox_from_source_to_approval() {
let b = bed_with("real", false);
let src = r#"export const manifest = {
name: "Add date",
api: 1,
permissions: ["system"],
match: { models: ["claude-*"] },
settings: { note: { type: "string", label: "Note", default: "today" } },
};
export function onRequest(req, ctx) {
return { ...req, system: `${req.system} ${ctx.settings.note}` };
}
"#;
let (st, v) = call(
&b.app,
"POST",
"/plugins/inspect",
Some(json!({"source": src})),
)
.await;
assert_eq!(st, StatusCode::OK, "{v}");
assert!(v["error"].is_null(), "{v}");
assert_eq!(v["manifest"]["name"], "Add date");
assert_eq!(v["manifest"]["scope"]["models"], json!(["claude-*"]));

let broken = src.replace("return {", "return {{");
let (_, v) = call(
&b.app,
"POST",
"/plugins/inspect",
Some(json!({"source": broken})),
)
.await;
assert!(v["manifest"].is_null(), "{v}");
assert!(v["error"]["line"].is_number(), "{v}");

let id = b
.install(
src,
json!({"scope": {"clients": [], "models": ["claude-*"], "upstreams": []}}),
)
.await;
assert_eq!(id, "add-date");
assert_eq!(b.plugin(&id).await["status"], json!({"kind": "ok"}));
assert!(b.gw.runtime().plugins.get(&id).unwrap().ready().is_some());

let edited = src.replace("today", "tomorrow");
std::fs::write(b.file(&id), &edited).unwrap();
b.gw.reload_plugins();
assert_eq!(b.plugin(&id).await["status"], json!({"kind": "changed"}));
let (st, v) = call(
&b.app,
"POST",
&format!("/plugins/{id}/approve"),
Some(json!({"sha256": sha(&edited)})),
)
.await;
assert_eq!(st, StatusCode::OK, "{v}");
let v = b.plugin(&id).await;
assert_eq!(v["status"], json!({"kind": "ok"}));
assert_eq!(v["settings_schema"][0]["default"], "tomorrow");
}
2 changes: 2 additions & 0 deletions crates/tw-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ tw-breaker = { workspace = true }
# Bedrock:SigV4 签名、eventstream 拆帧、推理地址和模型目录,和企业版共用一份
tw-bedrock = { workspace = true }
tw-engine = { workspace = true }
# 脚本插件的沙箱(QuickJS 跑在 Wasmtime 里)。接在 `plugin::sandbox` 上
tw-plugin = { workspace = true }
tw-observe = { workspace = true }
tw-secret = { workspace = true }
tw-pricing = { workspace = true }
Expand Down
7 changes: 4 additions & 3 deletions crates/tw-gateway/src/plugin/engine.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! 网关要从插件运行时那里拿到的东西:把一份源码编成插件,读出它的 manifest。
//!
//! **这里是一道接缝**:沙箱在 `tw-plugin` 里,网关只认这个 trait。真的运行时接上之前,
//! 由 [`Unavailable`] 顶着 —— 每个插件都「加载不了」,有一个就拒一个请求(出错时
//! 拒绝是出厂的做法),而不是悄悄放过。测试拿一个假的引擎接在这里。
//! **这里是一道接缝**:沙箱在 `tw-plugin` 里(接在 [`crate::plugin::sandbox`]),网关只认
//! 这个 trait。测试拿一个假的引擎接在这里(`plugin::fake`);[`Unavailable`] 是「没有
//! 运行时」的样子 —— 每个插件都「加载不了」,有一个就拒一个请求(出错时拒绝是出厂的
//! 做法),而不是悄悄放过。

use std::sync::Arc;

Expand Down
9 changes: 5 additions & 4 deletions crates/tw-gateway/src/plugin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! 插件是 JavaScript,**只在沙箱里跑**(`tw-plugin`,Wasmtime)。网关这一侧分三块:
//!
//! - [`engine`]:网关要从运行时那里拿到的东西 —— 把一份源码编成一个插件,读出它的
//! manifest。运行时还没接上时由一个替身顶着,所有插件都是「加载不了」;
//! manifest。真的那一个在 [`sandbox`](`tw-plugin`),测试接假的;
//! - [`host`]:一个编好的插件能做什么(跑请求钩子、回答钩子),数据面调它;
//! - [`set`]:跟着配置一起换的那一份 —— 每个配置了的插件此刻的样子(能跑、文件
//! 变了、加载出错)、范围、出错时怎么办,以及跨重载存活的计数和日志。
Expand Down Expand Up @@ -35,6 +35,7 @@ pub mod load;
pub mod pool;
pub mod reply;
pub mod request;
pub mod sandbox;
pub mod set;
pub mod trial;
pub mod view;
Expand Down Expand Up @@ -94,10 +95,10 @@ impl crate::AppState {
}
}

/// 这个进程用的插件运行时。**沙箱还没接上**:在那之前每个插件都「加载不了」,
/// 管得着的请求照它的 `on_error` 处置。
/// 这个进程用的插件运行时:`tw-plugin` 的沙箱,**第一次编插件时才起**(见
/// [`sandbox::Sandbox`])。
pub fn default_engine() -> std::sync::Arc<dyn Engine> {
std::sync::Arc::new(Unavailable::default())
std::sync::Arc::new(sandbox::Sandbox::new(tw_plugin::Limits::default()))
}

/// 出错却没说为什么。数据面总该给一句,这里只是不让通知空着
Expand Down
Loading
Loading