---
title: "Kobako：執行腳本"
date: 2026-09-23T00:00:00+08:00
publishDate: 2026-09-23T00:00:00+08:00
lastmod: 2026-09-21T21:42:48+08:00
tags: ["LLM","AI","經驗","Ruby","WebAssembly","Gem","mruby"]
series: "kobako"
toc: true
permalink: "https://blog.aotoki.me/posts/2026/09/23/kobako-script-execution/"
language: "zh-tw"
---


當我們有了[標準輸出](https://blog.aotoki.me/posts/2026/09/16/kobako-standard-output/)能力後，執行腳本是否正常就可以順利地被驗證，這表示我們可以開始真正的來把 Ruby 跑在 Kobako 的沙盒（Sandbox）環境下。

<!--more-->

## 介面設計{#interface-design}

最初使用了 `#run` 來跑一段 Ruby 的腳本，但是在經過幾次使用之後，我開始思考這樣真的適合嗎？如果每次想在 Kobako 跑腳本，卻需要組合一大段程式碼到裡面，這樣真的合理嗎？

舉例來說，我有一個常用的模組。

```ruby
module Formatter
  module_function

  def tag(name, *messages)
    "[#{name}] #{messages.join(" ")}"
  end
end
```

假設用 `#run` 來跑，就會變成

```ruby
sandbox.run(<<~SANDBOX)
module Formatter
  module_function

  def tag(name, *messages)
    "[#{name}] #{messages.join(" ")}"
  end
end

puts Formatter.tag("info", "Hello World")
SANDBOX
```

這表示我需要每次都「複製」一次 Formatter 的程式碼，這並不方便，經過評估後，決定加入 `#preload` 的概念。

```ruby
sandbox.preload(<<~SANDBOX)
module Formatter
  module_function

  def tag(name, *messages)
    "[#{name}] #{messages.join(" ")}"
  end
end
SANDBOX

sandbox.run("puts Formatter.tag('info', 'Hello World')")
```

這樣一來，每次跑的時候，就不用反覆的把 Formatter 放到 `#run` 裡面，只需要先用 `#preload` 放到裡面就可以。

## 片段機制{#snippet}

會這樣設計，實際上也是非常合理的。在原本的 Kobako 設計中之所以只有 `#run` 的理由，是因為 Kobako 的設計是採取「拋棄式使用」的角度思考的。

```ruby
# 樣板
sandbox = Sandbox.new
# ...

# mrb_state（獨立）
sandbox.run("true")

# mrb_state（獨立）
sandbox.run("true")
```

一個 `mrb_state` 就是一個完整的 mruby VM（虛擬機器，Virtual Machine）因此兩次 `#run` 之間不會互相繼承，這是造成需要每一次執行都複製 Formatter 的理由。

以給 AI 使用的沙盒來說是相對乾淨的處理，因此不會互相污染也沒有狀態問題，那麼就不容易發生問題，但相對的使用起來就不那麼好用。

因此 `#preload` 實際上非常簡單，基本上是這樣實作的。

```ruby
class Sandbox
  attr_reader :snippets

  def initialize
    # ...
    @snippets = []
  end

  def preload(snippet)
    @snippets << snippet
  end

  def run(code)
    vm.run([*@snippets, code].join("\n\n"))
  end
end
```

只要事先把要預先載入的程式碼片段保存起來，真的要執行的時候再組合回去就可以達到類似的效果。

## Run 和 Eval{#run-and-eval}

在使用了 `#preload` 後，我再次調整為 `#eval` 和 `#run` 兩個方法，理由是我們可以有更多不同的應用方式，像是模擬 Rack 的設計。

```ruby
sandbox.preload(<<~SANDBOX)
class MyApp
  def self.call(env)
    puts "Hello, #{env[:name]}"
  end
end
SANDBOX

sandbox.run(:MyApp, name: "Aotoki")
```

相對的，原本的 `#run` 則改為 `#eval` 更貼近執行一段程式碼的意義。

```ruby
sandbox.eval(<<~SANDBOX)
  puts "Hello World"
SANDBOX
```

但這樣明顯會有一個問題，我們怎麼讓 WebAssembly 內的 `mrb_state` 知道要呼叫 `MyApp.call(...)` 呢？

這就會應用到 [WebAssembly 的記憶體交換](https://blog.aotoki.me/posts/2026/08/19/kobako-webassembly-memory-exchange/)的技巧來處理，但是是從相反的方向來呼叫，利用 WebAssembly 的 Host Function 能力，在 `#run` 或者 `#eval` 呼叫時，都引導到一個「啟動 mruby 環境」的方法，來統一處理。

因為需要借助 Rust 來處理，因此實際上是將 `#run` 和 `#eval` 對應到 Ruby 的方法，並且共用一個 `boot` 啟動方法，把登記的片段（Snippet）一次就全部載入到裡面。

```rust
fn boot(snippets: &[Snippet]) -> Result<Mrb, Exc> {
    let mrb = Mrb::open();
    for s in snippets {
        mrb.load(&format!("(snippet:{})", s.name()), s.body());
        if let Some(e) = mrb.take_exception() { return Err(e); }
    }
    Ok(mrb)
}

fn outcome(mrb: &Mrb, value: Val) -> Outcome {
    match mrb.take_exception() {
        Some(e) => Outcome::Panic(e),
        None => Outcome::Value(mrb.to_wire(value)),
    }
}

fn eval(snippets: &[Snippet], source: &[u8]) -> Outcome {
    let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };

    let value = mrb.load("(eval)", source);
    outcome(&mrb, value)
}

fn run(snippets: &[Snippet], target: &str, args: Vec<Val>, kwargs: Option<Val>) -> Outcome {
    let mrb = match boot(snippets) { Ok(m) => m, Err(e) => return Outcome::Panic(e) };

    let Some(entrypoint) = mrb.top_level_const(target) else {
        return Outcome::Panic(Exc::sandbox_error(format!("undefined entrypoint: {}", target)));
    };
    if !mrb.respond_to(&entrypoint, "call") {
        return Outcome::Panic(Exc::sandbox_error(format!("entrypoint {} does not respond to :call", target)));
    }

    let mut argv = args;
    argv.extend(kwargs);
    let value = mrb.funcall(&entrypoint, "call", &argv);
    outcome(&mrb, value)
}
```

跟 `#eval` 不同的地方在於 `#run` 會直接透過 mruby 的 C API 去確認使用者指定的那個物件，是否具備一個可以呼叫（`#call`）的方法存在，如果存在的話就把宿主（Host）帶入的參數，轉換成 mruby 可以讀懂的格式，傳遞進去。

到目前為止，Kobako 的設計並沒有太多複雜或者困難的設計，都是基於常見的方法呼叫、變數保存等機制，但就是這樣一步一步評估每一個使用情境，才得以逐漸成為可以使用的沙盒環境。
