
NVIDIA OpenShell 0.1.0 系で Pi の sub-agent を sandbox ごとに動かしてみた
はじめに
こんにちは、クラスメソッド製造ビジネステクノロジー部の森茂です。
Pi coding agent と NVIDIA NeMo Switchyard で組んだ開発環境を、チームに配れる形にする作業を続けています。いまの形は、Pi と Switchyard の router を 1 つの container image にまとめ、NVIDIA OpenShell の sandbox で動かすものです。この記事ではこの image を Pi の sandbox image と呼びます。OpenShell を選んだのは、鍵と通信の境界を runtime 側に持たせたかったからです。
その過程で 0.1.0 系の docs を読み直すと、0.0.x のころとは部品の切り方がだいぶ変わっていました。gateway、workspace、provider、policy、template に加えて、TypeScript、Python、Go の SDK が揃っています。
部品が揃うと、次に考えたくなるのは「sandbox を外から動的に作って、指示を出して、消す」ことです。Pi には Claude Code のような sub-agent の仕組みが標準ではありません。公式 example の extension が、子の pi をローカルの process として spawn する形で補っています。その spawn 先を OpenShell の sandbox に替えれば、sub-agent 1 本につき sandbox 1 つという構成になります。
上の記事(2026-08-17 時点の記事です)のまとめで予告した「OpenShell で走らせる構成」の回収でもあります。
この記事では、OpenShell 0.1.0 系の部品の階層と認証の仕組みを、Pi の sandbox image を題材に読み解きます。そのうえで TypeScript SDK を呼ぶ Pi extension を書き、sub-agent 3 本を sandbox 3 つで並列に動かした結果を紹介します。OpenShell の docs を読んで「gateway と sandbox と provider の関係が掴みにくい」と感じた人に刺さるといいなと思っています。
OpenShell 0.1.0 系で何が変わったか
0.0.x 時点の記事を読んだ人が引っかかりそうな変更点を、upgrade guide と手元の挙動から表にまとめます。
| 項目 | 0.0.x | 0.1.0 系 |
|---|---|---|
| upgrade | 上書き install | in-place 不可。local は削除して入れ直し、sandbox は全部作り直す |
| gateway の設定 | gateway.env |
gateway.toml の schema v2(compute_driver と [openshell.drivers.<name>]) |
| provider profile | claude や gh が built-in |
built-in 廃止。claude-code や github を import してから provider create |
| 推論の経路 | inference.local の managed route |
廃止。provider を attach して native endpoint を直接呼ぶ |
sandbox create --from |
Dockerfile を build できた | image 参照だけ。build と push は先に済ませる |
| provider の指定 | 末尾の command から推定 | --provider で明示 |
| policy の schema | 未知 field を無視 | 未知 field と tls: terminate を拒否。endpoint の mode は typed enum |
一番大きな変更は inference.local の廃止です。「provider 1 つを全 sandbox に配る」経路が消え、sandbox ごとに provider を選んで attach する形になりました。sub-agent を sandbox 単位で切る今回の構成には、むしろ都合の良い変更でしたね。
登場する部品を階層で捉える
docs は部品ごとにページが分かれていて入れ子関係が見えにくかったので、触った範囲で階層を 1 枚に描き直しました。

gateway が control plane。workspace が隔離と RBAC の単位で、sandbox、template、provider、policy、service はその中に作る。実行系は compute driver → supervisor → openshell-sandbox の順で、agent は一番内側で 1 process だけ動く。
同じ構造を表にすると次のとおりです。「作った後に変えられるか」の列が後の章で効いてきます。
| 概念 | 役割 | 作る単位 | 作った後に変えられるか | 主な CLI |
|---|---|---|---|---|
| gateway | control plane。認証、sandbox の状態、policy の検証、JWT の発行 | 1 台(または k8s) | 設定は gateway.toml |
openshell gateway add / select / info |
| workspace | 隔離と RBAC の境界。下の資源は全部 workspace 単位 | 管理者が作る | member の追加と削除 | openshell workspace create / member add |
| template | image、cpu、memory、env の雛形 | workspace ごと | 作り直し | openshell sandbox template create |
| sandbox | agent を 1 process 動かす箱。policy と provider は作成時に付ける | 実行ごと | network policy と provider だけ | openshell sandbox create / exec / delete |
| provider | 鍵を預かる実体。profile が endpoint と binary を決める | 鍵ごと | 鍵の更新、attach と detach | openshell provider create / update |
| policy | filesystem、process、network の境界 | sandbox ごと | network 節だけ hot reload | openshell policy update / set / get |
| service | sandbox の loopback port を gateway 経由で外に出す | port ごと | 追加と削除 | openshell service expose / list |
押さえておきたいのは 1 点だけで、agent 自身は policy の判定をせず、鍵も JWT も持ちません。外に出る道は supervisor 1 本で、supervisor が policy と照らして通し、鍵を差し替えます。以前の記事で Codex を閉じ込めたときに「agent layer と runtime layer の二層ハーネス」と呼んだ考え方は、そのまま使えます。
上の記事(2026-06-20 時点の記事です)の確認手順は 0.1.0 系でもほぼ同じですが、コマンドは前の表のとおり変わっているので読み替えてください。
認証は 3 本の線で読む
自分が一番分かりにくかったのが認証です。docs には mTLS、OIDC、Edge JWT、Plaintext の 4 方式が並び、supervisor の JWT の話が別ページにあります。整理すると、線は 3 本しかありません。

人と SDK が gateway に入る線、supervisor が gateway に入る線、supervisor が sandbox に入る線の 3 本。agent は 4 本目を持たない。
利用者が選ぶのは 1 本目だけで、4 方式は全部この線の話です。
| 方式 | 誰が使うか | 何を持つか | 今回試したか |
|---|---|---|---|
| mTLS | Docker、Podman、VM の local 既定 | ~/.config/openshell/gateways/<name>/mtls/ の CA、client cert、key |
試した(CLI と SDK の両方) |
| OIDC | 人(PKCE か device flow)、CI と SDK(client credentials) | IdP の bearer token。roles claim で admin と user を分け、scopes で method を絞る | 試していない(IdP が手元に無い) |
| Edge JWT | reverse proxy の後ろの gateway | proxy が発行する token、websocket tunnel | 試していない |
| Plaintext | port-forward した検証用 | 無し | 試していない |
手元の gateway は mTLS です。openshell status は Status と Authentication を別に出すので、「gateway には届いているが token が切れている」を見分けられます。
$ openshell whoami
Subject: openshell-client
Provider: mtls
Roles: openshell-user
$ openshell status
Status: Connected
Authentication: Authenticated (mTLS transport)
残る 2 本は runtime が持つ線です。supervisor は sandbox 1 つに束縛された Gateway JWT で gateway に戻り、sandbox の中の openshell-sandbox へは mTLS と Sandbox JWT で入ります。
認可は workspace 単位で、Platform Admin、Workspace Admin、Workspace User の 3 段です。OIDC を組んでいない local の gateway は利用者を Platform Admin として扱うので、今回の手元は default workspace を 1 人で使う形です。
provider が鍵を預かり placeholder を差し替える
provider の骨格は NemoHermes の記事で紹介した「sandbox には placeholder しか入らず、実鍵は supervisor が差し替える」のままです。
上の記事(2026-06-17 時点の記事です)との違いは built-in の profile が無くなったことで、YAML を import してから provider を作ります。鍵が辿る道はこうなります。

鍵が実体として存在するのは gateway と supervisor の中だけ。sandbox の環境変数に入るのは解決用トークンで、差し替えは profile が許した endpoint に対してしか起きない。
Pi の sandbox image に付けている Fireworks 用の profile です。binaries に switchyard-server だけを書いているので、sandbox の中の pi や curl が同じ環境変数を読んでも Fireworks には出られません。公式 tutorial「Run Pi with OpenRouter」の profile も同じ形で、違いは binaries が node か switchyard-server かだけです。
id: fireworks
credentials:
- name: api_key
env_vars: [FIREWORKS_API_KEY]
auth_style: bearer
header_name: authorization
endpoints:
- host: api.fireworks.ai
port: 443
protocol: rest
access: read-write
enforcement: enforce
binaries:
- path: /usr/local/bin/switchyard-server
sandbox の中で環境変数を見ると、値は openshell:resolve:env: で始まる解決用トークンです。そのまま Authorization header に載せれば supervisor が実鍵に差し替えます。「本物の鍵ではない」と判断して上書きすると 401 で落ちます。env の値には触らない、が原則ですね。provider は稼働中の sandbox でも付け外しでき、detach すると新しい process では環境変数が消えて推論は 502 になり、attach し直すと 2.2 秒で応答が戻りました。有効な policy は provider が合成する rule と sandbox の policy の和集合なので、宛先を絞りたいときは両方を狭くします。
policy は動かせる節と動かせない節に分かれる
policy の YAML は filesystem_policy、landlock、process、network_policies の 4 節で、稼働中に変えられるのは network だけです。
| 節 | 変更手段 | 反映のタイミング | 例 |
|---|---|---|---|
| filesystem、landlock、process | sandbox の作り直し | 作成時に固定 | /sandbox と /tmp だけ read_write、/usr は read_only |
| network(自分で書く rule) | openshell policy update --add-endpoint … --wait か policy set |
数秒で reload、policy list に版が残る |
curl に example.com を許す |
| network(provider の合成 rule) | sandbox provider attach / detach --wait |
数秒で reload | _provider_fireworks |
rule の無い host へ curl すると接続段階で落ち、sandbox の log には DNS と TCP の両方で DENIED が残ります。
$ openshell sandbox exec -n sy0 -- curl -sS -m 5 https://example.com
curl: (7) Failed to connect to example.com port 443 after 1 ms: Couldn't connect to server
$ openshell logs sy0 --since 2m --source sandbox
NET:REFUSE [MED] DENIED example.com [reason:policy_dns_ineligible]
NET:OPEN [MED] DENIED /usr/bin/curl(0) -> example.com:443 [reason:transparent_tcp_policy_denied]
rule は --dry-run で適用後の YAML を眺めてから --wait で当てます。手元では 3.0 秒で version 2 が loaded になり、同じ curl が 200 を返しました。log を見るときは --level warn で絞らないのがコツで、policy の event は INFO と MED に混ざって出ます。
openshell policy update sy0 --rule-name demo-example --binary /usr/bin/curl \
--add-endpoint example.com:443:read-only:rest:enforce --dry-run
openshell policy update sy0 --rule-name demo-example --binary /usr/bin/curl \
--add-endpoint example.com:443:read-only:rest:enforce --wait
# ✓ Policy version 2 submitted (hash: 13303a499750)
# ✓ Policy version 2 loaded (active version: 2)
この rule の追加を sub-agent 側から申請させる仕組みが policy advisor です。gateway か sandbox の設定で agent_policy_proposals_enabled を有効にすると、sandbox の中に http://policy.local という API が現れ、agent は直近の拒否を読み、宛先と binary と method を絞った rule を提案できます。提案は policy prover が検査してから pending に並び、人が openshell rule approve するまで policy は変わりません。手元では、curl を拒まれた sandbox の中から example.com への read-only の rule を提案し、host 側で pending を見て承認すると、約 6 秒で version 2 が loaded になり、同じ curl が 200 を返しました。cloud metadata の 169.254.169.254 を提案すると prover が finding を 1 件付けるので、自動承認の mode でも人の目に回ります。「sub-agent が必要な宛先を申請し、人が承認する」運用は、この API と 3 つの CLI で組めます。
$ openshell rule get adv --status pending
Chunk: 8db57246-5083-43a0-acc5-17746372e4b3
Status: pending
Rule: example-com-readonly
Prover: prover: no new findings
Endpoints: example.com:443 [L7 rest, access=read-only]
$ openshell rule approve adv --chunk-id 8db57246-5083-43a0-acc5-17746372e4b3
OK Chunk approved. Policy version: 2, hash: eef57c38497a
Pi の extension から sandbox の sub-agent に指示を出す
ここからが本題です。Pi の公式 example にある subagent extension は、agents/*.md の agent 定義を読み、子の pi --mode json -p --no-session をローカルで spawn して、stdout の JSON event を親に返します。自作した openshell-subagent は、この spawn の 1 か所を OpenShell の SDK に置き換えたものです。

親 Pi は手元の Mac で動き、sub-agent は sandbox の中で動く。鍵は sub-agent の supervisor が差し替えるので、親も sub-agent も実鍵を持たない。
公式 example との差分は次の 5 点です。
| 観点 | 公式 example(ローカル spawn) | openshell-subagent(sandbox) |
|---|---|---|
| 子の起動先 | 親と同じマシンの process | template から作った sandbox。createFromTemplate → waitReady → provider 待ち |
| 資格情報 | 親の環境変数を継承 | provider の解決用トークン。親は gateway の mTLS だけ持つ |
| 作業ディレクトリ | 親の cwd | sandbox の /sandbox。親の file は見えないので依頼文に材料を全部入れる |
| 出力の受け取り | stdout の pipe | execStream の stdout を行ごとに JSON parse(parse の code は example と同じ) |
| 中断と後片付け | SIGTERM |
AbortSignal で exec を止め、delete と waitDeleted を必ず呼ぶ |
SDK は CLI が使っている mTLS の bundle をそのまま読みます。docs には OIDC の例しか無いのですが、型定義に caCert、clientCert、clientKey があり、local の gateway にはこれで入れます。
const client = await OpenShellClient.connect({
gateway: "https://localhost:17670",
caCert: readFileSync(`${mtls}/ca.crt`),
clientCert: readFileSync(`${mtls}/tls.crt`),
clientKey: readFileSync(`${mtls}/tls.key`),
});
const created = await client.sandbox.createFromTemplate({
name: "sa-worker-3jpsci",
workloadTemplate: "pi-kit",
providers: ["fireworks"],
labels: { role: "subagent", agent: "worker", parent: parentSessionId },
command: ["sleep", "infinity"],
});
await client.sandbox.waitReady(created.name, 180);
await waitProvidersReady(client, created.name, ["fireworks"]); // 後述
for await (const event of client.sandbox.execStream(created.name, [
"/opt/kit/launch", "--mode", "json", "-p", "--no-session", "--model", "switchyard/auto",
"--append-system-prompt", agent.systemPrompt, `Task: ${task}`,
])) {
// stdout を行ごとに JSON parse し、message_end を集める(公式 example と同じ)
}
await client.sandbox.delete(created.name);
親の Pi は手元の Mac で --no-extensions -e を付けて起動し、「3 件を worker に並列で委任して、要点を 1 行ずつまとめて」と 1 文で頼みました。tool の呼び出しは 1 回で、結果がこの表です。
| sub-agent | 依頼 | Ready | provider 待ち | pi の実行 | 削除 | 合計 | 判定役の呼び出し | session id |
|---|---|---|---|---|---|---|---|---|
| sa-worker-3jpsci | 3 行の要約 | 0.81 s | 9.8 s | 8.9 s | 0.08 s | 19.6 s | 1 | 1 |
| sa-worker-0fm9xw | median 関数の実装 | 0.79 s | 9.8 s | 24.7 s | 0.07 s | 35.4 s | 1 | 1 |
| sa-worker-8vkncw | 5 語の階層の説明 | 0.79 s | 9.8 s | 46.1 s | 0.07 s | 56.8 s | 1 | 1 |
sandbox は 3 つとも 0.8 秒で Ready になり、削除は 0.1 秒でした。親の tool 呼び出し 1 回は 75.1 秒で、3 本を直列に足した 111.8 秒より短くなっています。sub-agent 1 本につき判定役の呼び出しは 1 回、session id は 1 つ、環境変数の鍵は 3 本とも解決用トークンのままで、実行後に sandbox は残っていません。
ここまで書くと順調に見えますが、最初の 2 回は 3 本のうち 1〜2 本が upstream transport error で落ちました。sandbox の log に原因が書いてありました。
NET:OPEN [MED] DENIED api.fireworks.ai:443 [reason:L7 tunnel closed before inspection
because policy changed: policy generation is stale [captured_generation:1 current_generation:2]]
sandbox が Ready になった後、provider の install が数秒遅れて届き、policy の世代が上がります。 その間に張った Fireworks への stream は、世代が古いという理由で閉じられます。waitReady は sandbox の phase しか見ていないので、SDK の raw.getSandboxProviderStatus で state が READY になるまで待つ関数を足しました。表の「provider 待ち 9.8 秒」がそれで、入れてからは 3 本とも通ります。
extension は MIT で GitHub に置きました。sandbox の image と provider があれば、template 名と provider 名を環境変数で指定するだけで同じ形が動きます。親の Pi 向けに、委任の判断、agent 定義の書き方、事前の確認、失敗の読み方をまとめた skill も同じ package に入れてあります。gateway や provider の深い診断は NVIDIA が OpenShell の repo で配っている skill に任せています。
sub-agent を sandbox 単位で運用する形を考えてみる
sub-agent = sandbox という切り方で自分が一番使いたいのは、用途ごとに権限とモデルを変えることです。権限を決める部品はどれも sandbox 単位で付けるものなので、agent の定義ごとに変えられます。extension では frontmatter の providers と template がその agent の sandbox にだけ効き、model と tools は Pi 側で切り替わります。
| 用途 | provider(鍵と宛先) | policy と image(binary と host) | model | tools |
|---|---|---|---|---|
| web 検索 | 検索 API の provider だけ | 検索 script の binary から検索 API だけ | 安い weak 側 | bash, read |
| PR レビュー | read-only の fine-grained PAT を預けた github |
gh と git から GitHub だけ |
読解が要るので strong 側 | read, grep, bash |
| コード修正 | 推論 API の provider | 推論 API だけ。GitHub には出ない | 判定役に任せる auto |
全部 |
web 検索の agent は GitHub に出られず、PR レビューの agent は検索 API に出られません。この境界は agent の定義ではなく provider と policy で決まるので、prompt が崩れても越えられません。表の分け方で動かす実測はまだしていないので、設計としての説明に留めます。
大きいコードベースで先に引っかかるのがコードの受け渡しです。OpenShell には Docker の -v にあたる mount が無く、あるのは --upload、sandbox upload と download、それに管理者が opt-in する driver 設定だけです。sub-agent にコードを渡す形は 4 つに絞られます。
| 形 | 向く用途 | 代償 |
|---|---|---|
sandbox の中で git clone し、成果は branch を push |
修正、PR 作成 | sub-agent ごとに clone が要る。GitHub の provider と PAT が前提 |
作成時に --upload、結果を download |
小さい repo、1 回きりの依頼 | 3 本並列なら 3 回転送する。大きい repo は .gitignore を尊重しても秒単位で効く |
| 用途ごとの sandbox を常駐させ、stop と start で使い回す | 大きい repo の反復作業 | 作って消す軽さは失う。clone は初回だけで、以後は git pull で追従 |
| scout に読ませて要約だけ返させ、修正は親が行う | 調査、レビュー | code の往復が無い。返るのは text だけ |
大きい repo なら 1 行目と 3 行目の組み合わせです。用途ごとの sandbox を project 単位で先に用意し、clone を持たせておきます。親からは branch 名と依頼文だけを渡し、成果は push された branch か PR を親が読みます。extension にはこの常駐の形も入れてあり、agent 定義に sandbox: proj-review と書くとその sandbox を作らずに使い、stop されていれば start してから exec し、終わっても消しません。Ready 済みの再利用は 0.04 秒で、作って消す形より約 10 秒短くなりました。stop からの start は provider の install 待ちがもう一度約 10 秒かかるので、頻繁に使う sandbox は置いたままのほうが速いですね。
Pi は本体に permission popup を持たず、sub-agent は非対話で走るので確認の UI も効きません。止められるのは sandbox の policy と provider、つまり通信と鍵の側です。
そう考えると、Pi は 2 種類に分けるのが自然です。普段使いの Pi は host のままで確認用の extension だけ付け、OpenShell の資格情報は持たせません。project 用の Pi は orchestrator として container で起動し、その project の workspace にしか届かない資格情報を持ちます。境界を引くのは Pi ではなく workspace の側で、provider と鍵、template、常駐 sandbox、policy、member が workspace ごとに分かれます。この RBAC が効くのは OIDC を組んだ gateway で、local の gateway では「分けて管理できる」までです。
同じ Pi を両方に使えるのは、Pi が小さな core と package だけでできているからです。本体に permission model や sub-agent を持たず、extension と skill を package で足し、-p で非対話に走ります。だから host では確認付きの相棒、container では資格情報を絞った orchestrator として、環境変数と package の差だけで使い分けられます。
これを開発の流れに置くとこうなります。
- project ごとに用途別の sandbox を先に作る。調査用は検索 API の provider だけ、修正用は推論 API と push できる GitHub の provider、レビュー用は read-only の PAT を預けた GitHub の provider を付け、修正用とレビュー用には初回に repo を clone しておく。
- project 用の Pi を container で起動し、issue の内容を渡す。普段使いの Pi はそのまま。
- 親は調査を scout に委任する。scout は検索 sandbox で動き、結果は text で返る。
- 親は修正を worker に委任する。worker は修正用 sandbox の repo で branch を切り、直して、テストを回し、push する。親に返るのは branch 名と要約だけで、file は往復しない。
- 親はレビューを reviewer に委任する。reviewer はレビュー用 sandbox で
gh pr diffを読み、指摘を text で返す。push はできない。 - 人が PR を見て merge する。sandbox は消さずに置き、使わない時間帯だけ stop する。
O'Reilly の『Agentic Mesh』(Eric Broda と Davis Broda、2026)は、registry、marketplace、trust、human-in-the-loop で agent の ecosystem を描いています。OpenShell が持っているのは、そのうち trust の土台にあたる runtime の境界と鍵の管理です。registry や discovery は無く、今回は agent 定義の *.md と template の名前がその代わりをしています。
まとめ
OpenShell 0.1.0 系の部品は、階層で捉えると読みやすくなりました。gateway が workspace を持ち、その中に template、sandbox、provider、policy、service が並びます。運用の要は 3 点です。認証で利用者が選ぶのは gateway に入る 1 本だけで、鍵は provider が預かり sandbox には解決用トークンだけが入ります。policy は network 節だけが稼働中に動きます。
その部品を TypeScript SDK から呼ぶ Pi extension を書き、sub-agent 3 本を sandbox 3 つで並列に動かせました。Ready まで 0.8 秒、削除 0.1 秒、鍵は 3 本とも解決用トークンのままです。provider の install が Ready の後に届くことを知らずに 1〜2 本を落としたのが、一番の収穫かなと思っています。
OIDC の gateway は手元に IdP が無いので試していません。dev build は日々変わるので、コマンドは執筆時点の版に対するものとして読んでください。
次は、OIDC を組んだ gateway で project ごとに workspace を分け、orchestrator の Pi に Workspace User の資格情報だけを渡す形を試してみたいところです。
参考リンク
- NVIDIA OpenShell Documentation
- Upgrade to OpenShell 0.1.0
- Gateway Authentication
- Manage Workspaces
- Inference and Providers
- Manage Policies
- Policy Advisor
- Sandbox Templates
- TypeScript SDK
- Run Pi with OpenRouter — OpenAI 互換の provider で Pi を動かす公式手順
- NVIDIA/OpenShell — Apache-2.0
- Pi coding agent の subagent example
- himorishige/pi-openshell-subagent — この記事の extension(MIT)
- Agentic Mesh: The GenAI-Powered Autonomous Agent Ecosystem(Eric Broda と Davis Broda、O'Reilly、2026)
- Pi coding agent を Switchyard につないで開発環境を組んでみた(2026-08-17)
- NVIDIA OpenShell に Codex を閉じ込めて動かしてみた(2026-06-20、0.0.63)
- NemoHermes に GitHub token を渡さずに private リポジトリの PR を読ませてみた(2026-06-17)








