當我們有了標準輸出能力後,執行腳本是否正常就可以順利地被驗證,這表示我們可以開始真正的來把 Ruby 跑在 Kobako 的沙盒(Sandbox)環境下。
介面設計
最初使用了 #run 來跑一段 Ruby 的腳本,但是在經過幾次使用之後,我開始思考這樣真的適合嗎?如果每次想在 Kobako 跑腳本,卻需要組合一大段程式碼到裡面,這樣真的合理嗎?
舉例來說,我有一個常用的模組。
1module Formatter
2 module_function
3
4 def tag(name, *messages)
5 "[#{name}] #{messages.join(" ")}"
6 end
7end假設用 #run 來跑,就會變成
1sandbox.run(<<~SANDBOX)
2module Formatter
3 module_function
4
5 def tag(name, *messages)
6 "[#{name}] #{messages.join(" ")}"
7 end
8end
9
10puts Formatter.tag("info", "Hello World")
11SANDBOX這表示我需要每次都「複製」一次 Formatter 的程式碼,這並不方便,經過評估後,決定加入 #preload 的概念。
1sandbox.preload(<<~SANDBOX)
2module Formatter
3 module_function
4
5 def tag(name, *messages)
6 "[#{name}] #{messages.join(" ")}"
7 end
8end
9SANDBOX
10
11sandbox.run("puts Formatter.tag('info', 'Hello World')")這樣一來,每次跑的時候,就不用反覆的把 Formatter 放到 #run 裡面,只需要先用 #preload 放到裡面就可以。
片段機制
會這樣設計,實際上也是非常合理的。在原本的 Kobako 設計中之所以只有 #run 的理由,是因為 Kobako 的設計是採取「拋棄式使用」的角度思考的。
1# 樣板
2sandbox = Sandbox.new
3# ...
4
5# mrb_state(獨立)
6sandbox.run("true")
7
8# mrb_state(獨立)
9sandbox.run("true")一個 mrb_state 就是一個完整的 mruby VM(虛擬機器,Virtual Machine)因此兩次 #run 之間不會互相繼承,這是造成需要每一次執行都複製 Formatter 的理由。
以給 AI 使用的沙盒來說是相對乾淨的處理,因此不會互相污染也沒有狀態問題,那麼就不容易發生問題,但相對的使用起來就不那麼好用。
因此 #preload 實際上非常簡單,基本上是這樣實作的。
1class Sandbox
2 attr_reader :snippets
3
4 def initialize
5 # ...
6 @snippets = []
7 end
8
9 def preload(snippet)
10 @snippets << snippet
11 end
12
13 def run(code)
14 vm.run([*@snippets, code].join("\n\n"))
15 end
16end只要事先把要預先載入的程式碼片段保存起來,真的要執行的時候再組合回去就可以達到類似的效果。
Run 和 Eval
在使用了 #preload 後,我再次調整為 #eval 和 #run 兩個方法,理由是我們可以有更多不同的應用方式,像是模擬 Rack 的設計。
1sandbox.preload(<<~SANDBOX)
2class MyApp
3 def self.call(env)
4 puts "Hello, #{env[:name]}"
5 end
6end
7SANDBOX
8
9sandbox.run(:MyApp, name: "Aotoki")相對的,原本的 #run 則改為 #eval 更貼近執行一段程式碼的意義。
1sandbox.eval(<<~SANDBOX)
2 puts "Hello World"
3SANDBOX但這樣明顯會有一個問題,我們怎麼讓 WebAssembly 內的 mrb_state 知道要呼叫 MyApp.call(...) 呢?
這就會應用到 WebAssembly 的記憶體交換的技巧來處理,但是是從相反的方向來呼叫,利用 WebAssembly 的 Host Function 能力,在 #run 或者 #eval 呼叫時,都引導到一個「啟動 mruby 環境」的方法,來統一處理。
因為需要借助 Rust 來處理,因此實際上是將 #run 和 #eval 對應到 Ruby 的方法,並且共用一個 boot 啟動方法,把登記的片段(Snippet)一次就全部載入到裡面。
1fn boot(snippets: &[Snippet]) -> Result<Mrb, Exc> {
2 let mrb = Mrb::open();
3 for s in snippets {
4 mrb.load(&format!("(snippet:{})", s.name()), s.body());
5 if let Some(e) = mrb.take_exception() { return Err(e); }
6 }
7 Ok(mrb)
8}
9
10fn outcome(mrb: &Mrb, value: Val) -> Outcome {
11 match mrb.take_exception() {
12 Some(e) => Outcome::Panic(e),
13 None => Outcome::Value(mrb.to_wire(value)),
14 }
15}
16
17fn eval(snippets: &[Snippet], source: &[u8]) -> Outcome {
18 let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };
19
20 let value = mrb.load("(eval)", source);
21 outcome(&mrb, value)
22}
23
24fn run(snippets: &[Snippet], target: &str, args: Vec<Val>, kwargs: Option<Val>) -> Outcome {
25 let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };
26
27 let Some(entrypoint) = mrb.top_level_const(target) else {
28 return Outcome::Panic(Exc::sandbox_error(format!("undefined entrypoint: {}", target)));
29 };
30 if !mrb.respond_to(&entrypoint, "call") {
31 return Outcome::Panic(Exc::sandbox_error(format!("entrypoint {} does not respond to :call", target)));
32 }
33
34 let mut argv = args;
35 argv.extend(kwargs);
36 let value = mrb.funcall(&entrypoint, "call", &argv);
37 outcome(&mrb, value)
38}跟 #eval 不同的地方在於 #run 會直接透過 mruby 的 C API 去確認使用者指定的那個物件,是否具備一個可以呼叫(#call)的方法存在,如果存在的話就把宿主(Host)帶入的參數,轉換成 mruby 可以讀懂的格式,傳遞進去。
到目前為止,Kobako 的設計並沒有太多複雜或者困難的設計,都是基於常見的方法呼叫、變數保存等機制,但就是這樣一步一步評估每一個使用情境,才得以逐漸成為可以使用的沙盒環境。
喜歡這篇文章?請我喝杯奶茶 🧋