From 5ee41748a8c2424b3c1103b456f7fbbf89d70d31 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 10 Sep 2026 12:22:39 +0900 Subject: [PATCH] feat: let a profile name the runner, so Apple's container works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiles were hard-wired to `docker run`. The argument spelling termit emits (-v, -w, -e, --network, -it, --rm) is not Docker-specific, so a profile now names the command: runner = "container" # defaults to docker runner_args = ["--memory", "2048MB"] # inserted just before the image `runner_args` is the escape hatch for flags termit knows nothing about, and `network` is now only passed when set, because the name differs per runner (docker: bridge, container: default) and both reach the network by default anyway. No runner name appears anywhere in the code except as that default. Verified end to end against Apple's container 1.4.1: an ignored test spawns a real container through termit's own pty path and asserts the guest output reaches the grid and that 40x120 arrives inside. Warm start was 0.74s, an interactive prompt 1.02s. One measured caveat is documented rather than worked around: for about the first second the guest terminal is 0x0 before the real size arrives. Re-sending the same size raises no SIGWINCH, so a workaround inside termit would have to jiggle the size and flicker; starting the agent through `sh -c 'sleep 1.5; exec claude'` avoids the window from config. docs/references/sandbox.md records the survey behind this: the six isolation levels, sbx (Docker Sandboxes, microVM per session with credential injection at a host proxy), and what termit deliberately leaves outside — sbx-shaped argument lists, git worktrees per fork, and credential injection. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GvMacYD6T9EoWqc7C247Hr --- README.md | 31 +++- docs/references/sandbox.md | 134 ++++++++++++++++++ .../specs/2026-09-08-agent-terminal-design.md | 15 +- src/config.rs | 114 +++++++++++++-- src/pty.rs | 71 ++++++++++ src/session.rs | 21 ++- 6 files changed, 360 insertions(+), 26 deletions(-) create mode 100644 docs/references/sandbox.md diff --git a/README.md b/README.md index 171a298..760e2bb 100644 --- a/README.md +++ b/README.md @@ -248,22 +248,42 @@ Turn it off with `restore_sessions = false`. ## Sandbox profiles A profile says where a pane runs. `host` is the default and runs directly. -Anything with an `image` runs inside `docker run`. +Anything with an `image` runs inside ` run`. ```toml [profile.sandbox] image = "termit-agent:latest" # an image with your agent installed workdir = "/work" mount = ["{cwd}:/work"] -network = "bridge" env = ["ANTHROPIC_API_KEY"] args = ["--dangerously-skip-permissions"] ``` The isolation comes from the mount scope and the process namespace, not from -the network. The container only sees what you mounted. `network` defaults to -`bridge` because an agent that cannot reach its API is not useful; set -`"none"` for processes that do not need to get out. +the network. The container only sees what you mounted. Leave `network` unset +and the runner's own default applies, which reaches the internet — an agent +that cannot call its API is not useful. Set `network = "none"` under Docker for +a session that does not need to get out. + +`runner` names the command, and defaults to `docker`. Anything that takes the +same arguments works, because termit only assembles the argument list: + +```toml +[profile.vm] +image = "termit-agent:latest" +runner = "container" # Apple's container: one lightweight VM each +mount = ["{cwd}:/work"] +env = ["ANTHROPIC_API_KEY", "TERM"] +runner_args = ["--memory", "2048MB"] # goes in just before the image +``` + +`runner_args` is the escape hatch for flags termit knows nothing about. Two +notes for Apple's `container` specifically, both measured: it gives a container +4 CPUs and 1024 MB by default, which is a lot per session, and for about the +first second the terminal size inside is 0x0 before the real size arrives, so a +full-screen UI that reads its size once at startup can come up at 80 columns. +Starting the agent through `sh -c 'sleep 1.5; exec claude'` is enough to miss +that window. `docs/references/sandbox.md` records the measurements. Profiles are orthogonal to forking: `^]` forks into a profile you pick, so you can move a conversation into a container right before something risky. @@ -334,6 +354,7 @@ The detailed design record is in Japanese. - [`docs/superpowers/specs/2026-09-08-agent-terminal-design.md`](docs/superpowers/specs/2026-09-08-agent-terminal-design.md) — the specification - [`docs/performance.md`](docs/performance.md) — where the time actually goes, measured - [`docs/references/performance-techniques.md`](docs/references/performance-techniques.md) — techniques taken from other terminals, each marked adopted, rejected with the measurement, or still open +- [`docs/references/sandbox.md`](docs/references/sandbox.md) — how agents are sandboxed elsewhere, what Apple's `container` measured at, and what termit deliberately leaves outside - [`docs/references/scrollback.md`](docs/references/scrollback.md) — how five other implementations handle scrollback, and which parts were copied - [`docs/warp-metrics.md`](docs/warp-metrics.md) — the sizes and paddings the left pane is based on diff --git a/docs/references/sandbox.md b/docs/references/sandbox.md new file mode 100644 index 0000000..e56634a --- /dev/null +++ b/docs/references/sandbox.md @@ -0,0 +1,134 @@ +# サンドボックスとエージェント + +作成日:2026-09-10 + +エージェントを隔離して走らせる方法を調べ、Apple の `container` を実際に試した記録。 +termit が何を担い、何を担わないかの線引きもここに置く。 + +## 1. 隔離の段階 + +Anthropic 自身が 6 段階で整理している([Choose a sandbox environment](https://code.claude.com/docs/en/sandbox-environments))。 + +| 手法 | 何が隔離されるか | Docker | 手間 | +|---|---|---|---| +| Bash サンドボックス(`/sandbox`) | Bash とその子だけ | 不要 | macOS はほぼゼロ | +| sandbox runtime | Claude Code のプロセス全体(ファイル操作・MCP・hook も) | 不要 | 小 | +| dev container | 開発環境まるごと | 必要 | 中 | +| 任意のコンテナ | 同上(自前のポリシー) | 必要 | 中〜大 | +| 仮想機械 / microVM | OS まるごと(自前カーネル) | 不要 | 大 | +| Claude Code on the web | Anthropic 管理の VM | 不要 | なし | + +実装は OS の機能そのもので、**macOS は Seatbelt(`sandbox-exec`)、Linux は bubblewrap + seccomp**。 +Codex CLI も同じ(Seatbelt / Landlock + seccomp)。 +ネットワークはいずれも**ホスト側プロキシの許可リスト**で止める。 + +重要な区別が 2 つある。 + +- **権限モード**(いつ聞くか)と**隔離境界**(できたとして何に届くか)は別物である。 + Anthropic は「`--dangerously-skip-permissions` を使うならコンテナか VM か + sandbox runtime の中で」と明記している。 +- **Bash サンドボックス単体では無人実行に足りない。** + MCP サーバと hook はホストで素のまま動く。 + +ファイル側の隔離は **worktree per task**(タスクごとに git worktree)が定番になっている。 +`sbx` はこれを内蔵していて、`--branch auto` で `.sbx/` の下に worktree を作る。 + +## 2. `sbx`(Docker Sandboxes) + +コンテナではなく **microVM 1 個 / セッション 1 個**。 + +- 独立カーネル、独立した docker daemon、workspace だけ passthrough マウント、 + 外向き通信はすべてホスト側プロキシ経由。 +- **認証情報はホストに残す。** `sbx secret set` で OS キーチェーンに入れ、 + プロキシが送信時に注入する。環境変数で中に渡さない。 +- ネットワークは Open / Balanced / Locked Down の 3 段階。 +- `brew install docker/tap/sbx`(macOS 14 以降 / Apple silicon / Docker Desktop 不要)。 +- 利用報告では**性能が最大の難点**、コミット署名(ssh-agent)を中に渡せない、 + メモリ既定はホストの 50%。 + +引数体系が `sbx run ` で docker と違うため、termit の `runner` では扱えない +(4 節)。 + +## 3. Apple の `container` を試した(2026-09-10、1.4.1) + +`brew install container` → `container system start` → +`container system kernel set --recommended`(kata 3.32.0)。 + +| 試したこと | 結果 | +|---|---| +| 初回実行(イメージ取得込み) | 15.1s | +| 2 回目以降の起動 | **0.74s**(VM 1 個ぶん込み) | +| 対話シェルの往復(pty 経由、プロンプトまで) | **1.02s**。打鍵も出力も正常 | +| `-v {cwd}:/work -w /work` | 読み書きとも可 | +| `-e TERM` | ホストの値が入る(既定は `xterm`) | +| `--network` | ネットワーク**名**を取る。既定 `default` は 192.168.64.0/24、コンテナごとに IP が 1 個 | +| 常駐(コンテナ 0 個) | apiserver 24MB + core-images 22MB ≒ 46MB | +| コンテナ 1 個あたり | ホスト側 `container-runtime-linux` が約 21MB、ゲストは既定 **4 CPU / 1024MB** | + +`-v` `-w` `-e` `--network` `-it` `--rm` の綴りが docker と同じなので、 +termit が組み立てる引数列は先頭を替えるだけで通る。 + +### 起動直後の 1 秒、端末の大きさが 0x0 になる + +外側の pty には exec の前に 40x120 を入れてある(親が後から入れる競走は避けた)。 +それでもゲスト側は: + +``` +t1: 0 0 ← コンテナ起動直後 +t2: 40 120 ← 1 秒後に正しい値が届く +… +(ホスト側で 50x200 に変えて SIGWINCH) +t6: 50 200 ← リサイズは正しく伝わる +``` + +**欠けているのは起動時の初期値だけ**で、リサイズの経路は動いている。 +`tput cols` はこのあいだ terminfo の 80 に落ちる。 +起動時に一度だけ大きさを読む全画面 UI は、80 桁で描き始めることになる。 + +termit 側で塞ごうとすると厄介である。同じ大きさで `TIOCSWINSZ` を呼んでも +SIGWINCH は出ない(大きさが変わったときだけ送られる)ので、 +一度違う値にしてから戻す必要があり、画面がちらつく。 +**設定で避けられる**ので、そちらを案内する。 + +```toml +# エージェントの起動を少し遅らせる。1 秒の窓を外すだけでよい。 +[agent] +new = "sh -c 'sleep 1.5; exec claude --session-id {new_id}'" +``` + +上流には初期値の件の報告は無い([#1747](https://github.com/apple/container/issues/1747) は +SIGWINCH 転送のエラー表示の話)。 + +## 4. termit が担うもの、担わないもの + +termit がやるのは**引数列を組み立てて起動するところまで**である。 + +**入れた(`runner` / `runner_args`)。** +`runner` は既定 `docker`、`runner_args` はイメージ名の直前に入る逃げ道。 +Apple の `container` はこれで通り、`--memory` のような道具固有の指定も書ける。 +`network` は書かなければ渡さない(名前が道具ごとに違うため)。 +termit のコードに道具の名前は一つも増えていない。 + +**入れない:`sbx` 用の分岐。** +`sbx run ` は引数の並びが違うので、いまの形では扱えない。 +対応するなら「起動コマンドの雛形」への一般化が要る。 +それは `image`/`mount`/`network` という語彙を捨てることでもあるので、 +必要になってから決める。 + +**入れない:fork のときに git worktree を作る。** +並列エージェントの定番だが、端末が git を理解し始める。 +`[agent] fork` の雛形に `git worktree add` を書けば外側で足りる。 + +**入れない:認証情報の注入。** +`env` で渡すのはホストと同じ強度しかない。 +`sbx` のようなプロキシ注入は端末の仕事ではない。README にその旨を書く。 + +## 参考 + +- [Choose a sandbox environment](https://code.claude.com/docs/en/sandbox-environments) +- [Configure the sandboxed Bash tool](https://code.claude.com/docs/en/sandboxing) +- [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) / [sbx のインストール](https://docs.docker.com/ai/sandboxes/install/) +- [Running AI agents safely in a microVM using docker sandbox](https://andrewlock.net/running-ai-agents-safely-in-a-microvm-using-docker-sandbox/) +- [apple/container](https://github.com/apple/container) — [ドキュメント](https://apple.github.io/container/documentation/) +- [Codex CLI サンドボックスの調査](https://agent-safehouse.dev/docs/agent-investigations/codex) +- [AI エージェント用サンドボックスの比較(E2B / Modal / Daytona ほか)](https://blog.logrocket.com/comparing-ai-agent-sandbox-platforms-e2b-modal-daytona-and-more/) diff --git a/docs/superpowers/specs/2026-09-08-agent-terminal-design.md b/docs/superpowers/specs/2026-09-08-agent-terminal-design.md index 2a52bf8..e4e119d 100644 --- a/docs/superpowers/specs/2026-09-08-agent-terminal-design.md +++ b/docs/superpowers/specs/2026-09-08-agent-terminal-design.md @@ -194,7 +194,13 @@ args = ["--dangerously-skip-permissions"] **mount**:`docker run -v` に渡すマウント指定。`{cwd}` を展開する。 -**network**:`docker run --network` に渡す値。既定は `bridge`。 +**runner**:イメージを起動する道具。既定は `docker`。 +docker と同じ並びの引数を取るものなら何でもよい(Apple の `container` など)。 + +**runner_args**:イメージ名の直前に差し込む引数。道具ごとの指定に使う。 + +**network**:`--network` に渡す値。書かなければ渡さない(道具の既定に従う)。 +名前は道具ごとに違う(docker は `bridge`、`container` は `default`)。 **env**:ホストから引き継ぐ環境変数名。値ではなく名前だけを書く。 @@ -207,7 +213,6 @@ args = ["--dangerously-skip-permissions"] ``` docker run --rm -it \ -v /Users/tkc/repo:/work -w /work \ - --network bridge \ -e ANTHROPIC_API_KEY \ tex-agent:latest \ claude --session-id --dangerously-skip-permissions @@ -217,8 +222,10 @@ docker run --rm -it \ コンテナから見えるファイルは `mount` に書いた範囲だけであり、ホストの他のディレクトリ、ホストのプロセス、ホストにインストールされたコマンドには届かない。 権限確認を省く `--dangerously-skip-permissions` を使えるのは、この範囲の限定が効いているときに限る。 -`network` の既定を `bridge` とするのは、モデル API への接続が切れるとエージェントが動かないためである。 -外部へ出る必要のないプロセスには `network = "none"` を明示的に指定する。 +`network` を書かなければ何も渡さないのは、道具ごとに名前が違うためである +(docker は `bridge`、Apple の `container` は `default`)。 +どちらも既定で外へ出られる。モデル API への接続が切れるとエージェントが動かないので、これでよい。 +外部へ出る必要のないプロセスには docker なら `network = "none"` を明示する。 termit は `network` と `args` の組み合わせを検査しない。 どの隔離が必要かはエージェントと作業の性質で決まり、端末が判定できる事柄ではない。 diff --git a/src/config.rs b/src/config.rs index fc659b6..649cca3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -108,12 +108,26 @@ pub struct AgentConfig { pub struct Profile { /// 使用するイメージ。省略するとホスト上で直接起動する。 pub image: Option, + /// イメージを起動する道具。docker と同じ並びの引数を取るものなら何でもよい。 + /// + /// Apple の `container` は同じ綴りの `-v` `-w` `-e` `--network` を持つ。 + /// termit は引数を組み立てて起動するだけで、中身のことは知らない。 + #[serde(default = "default_runner")] + pub runner: String, + /// イメージ名の直前に差し込む引数。道具ごとの細かい指定に使う。 + /// + /// 例:`["--memory", "2048MB"]`。termit は中身を見ない。 + #[serde(default)] + pub runner_args: Vec, #[serde(default = "default_workdir")] pub workdir: String, #[serde(default)] pub mount: Vec, - #[serde(default = "default_network")] - pub network: String, + /// 繋ぐネットワーク。省略すると道具の既定に任せる。 + /// + /// 道具ごとに名前が違う(docker は `bridge`、`container` は `default`)。 + /// どちらも既定で外へ出られるので、閉じたいときだけ書く。 + pub network: Option, #[serde(default)] pub env: Vec, #[serde(default)] @@ -123,18 +137,19 @@ pub struct Profile { fn default_workdir() -> String { "/work".to_string() } -fn default_network() -> String { - // モデル API への接続が切れるとエージェントが動かないため bridge を既定とする。 - "bridge".to_string() +fn default_runner() -> String { + "docker".to_string() } impl Default for Profile { fn default() -> Self { Self { image: None, + runner: default_runner(), + runner_args: Vec::new(), workdir: default_workdir(), mount: Vec::new(), - network: default_network(), + network: None, env: Vec::new(), args: Vec::new(), } @@ -244,6 +259,12 @@ impl Config { ))); } } + if p.runner.trim().is_empty() || p.runner.contains(char::is_whitespace) { + return Err(ConfigError::Invalid(format!( + "profile.{name}.runner \"{}\" must be a single command name", + p.runner + ))); + } for e in &p.env { if e.contains('=') { return Err(ConfigError::Invalid(format!( @@ -459,7 +480,7 @@ pub fn build_argv(profile: &Profile, cwd: &Path, command: &[String]) -> Vec Vec write!(f, "cannot build command: {e}"), SessionError::Spawn(e) => write!(f, "{e}"), - SessionError::NoDocker(p) => { - write!(f, "profile.{p} needs docker, but docker was not found") + SessionError::NoRunner { profile, runner } => { + write!( + f, + "profile.{profile} needs {runner}, but {runner} was not found" + ) } } } @@ -564,8 +567,11 @@ impl Manager { key: Option, ) -> Result { let profile = config.profile(profile_name); - if !profile.is_host() && !docker_available() { - return Err(SessionError::NoDocker(profile_name.to_string())); + if !profile.is_host() && !runner_available(&profile.runner) { + return Err(SessionError::NoRunner { + profile: profile_name.to_string(), + runner: profile.runner.clone(), + }); } let argv = config::build_argv(&profile, cwd, &base); let id = self.next_id; @@ -771,8 +777,9 @@ fn shell_argv(config: &Config) -> Vec { argv } -fn docker_available() -> bool { - std::process::Command::new("docker") +/// その道具が入っているか。起動して失敗するより先に知らせる。 +fn runner_available(runner: &str) -> bool { + std::process::Command::new(runner) .arg("--version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null())