土曜の夕方、個人開発で動かしている定期タスクの手順1を実行しました。作業フォルダの場所を変数 WS に入れる、いつもの一行です。
返ってきたのは、空文字でした。エラーは一行も出ておりません。終了コードも 0 です。
たまたま私は変数の中身を echo で確かめる癖があり、その場で気づけました。もし確かめていなければ、次の手順の cat "${WS}/_skills/…" が「そのようなファイルはありません」と答えたはずです。——ただしそのメッセージが指すのは、本当の原因ではなく、ルート直下のパスでした。
この記事は、その朝に分かったことの記録です。エージェントのシェルが二つあるとき、取り違えは「失敗」ではなく「空」として現れます。 空を失敗に変える手当てを、実際に動かした結果とあわせてお伝えします。
手順1の変数が空のまま、先へ進みかけました
起きたことは単純です。定期タスクの手順は「bash で作業フォルダを探し、設定ファイルを読む」という書き出しで始まっておりました。私が最初に呼んだのは、クラウド側で動く標準の bash ツールです。
ところが、この実行環境にはシェルが二つあります。クラウド側の bash と、端末側の仮想マシンで動くシェルです。作業フォルダがつながっているのは、端末側だけでした。
クラウド側で探すと、ls -d は何も見つけられません。それでも 2>/dev/null | head -1 が末尾に付いているため、見つからなかったという事実は、そのまま空文字になって流れていきます。
この構成の怖さは、間違ったシェルでも、ほとんどのコマンドが普通に動く ことにあります。次の節で、二つを並べた結果を示します。
二つのシェルは「ほぼ同じ」に見えて、見えるものが違いました
同じ検査スクリプトを両方のシェルで実行して、結果を並べました。スクリプトは次の通りです。作業フォルダ名は伏せて Workspace と書き換えておりますが、検証では実際のフォルダ名で実行しました。
#!/bin/bash
# probe.sh — シェルの素性を洗い出す。二つのシェルで同じものを実行する
echo "host=$(hostname)"; echo "home=$HOME"; echo "pwd=$(pwd)"
echo "sessions_dir=$([ -d /sessions ] && echo yes || echo no)"
echo "ws_glob=[$(ls -d /sessions/*/mnt/Workspace 2>/dev/null | head -1)]"
echo "git=$(git --version 2>&1)"; echo "node=$(node -v 2>&1)"; echo "py=$(python3 --version 2>&1)"
s=$(date +%s%N)
curl -s -o /dev/null -m 10 -w 'github_api=%{http_code}' https://api.github.com/zen
echo " ($(( ($(date +%s%N)-s)/1000000 ))ms)"
結果は次の表のとおりです。
git も Python も Node も、どちらでも動きます。違うのは、バージョンと、見えるフォルダと、外への通信の通り方でした。
通信の 403 について、理由までは調べておりません。認証なしの素の curl が通らなかったという観測だけを、そのまま書き残します。外への通信が静かに止まる話は、スケジュールタスクが SUCCEEDED のまま成果を出さなくなったら、最初の1分で外への通信を疑いますに別途まとめました。
もう一つ、地味に効いてくるのが Python の版です。クラウド側は 3.13、端末側は 3.10 でした。同じスクリプトでも、書き方によっては片方でしか動きません。
空文字は、終了コード 0 のまま通り抜けます
次に、空がどのように通り抜けるのかを、クラウド側のシェルで確かめました。
# 1) 代入の終了コード
WS="$(ls -d /sessions/*/mnt/Workspace 2>/dev/null | head -1)"
echo "assign_status=$? ws=[$WS]"
# → assign_status=0 ws=[]
# 2) pipefail を有効にすると
set -o pipefail
WS="$(ls -d /sessions/*/mnt/Workspace 2>/dev/null | head -1)"
echo "pipefail_assign_status=$? ws=[$WS]"
# → pipefail_assign_status=2 ws=[]
# 3) 空のまま後続で使うと
cat "$WS/_skills/x/SKILL.md"
# → cat: /_skills/x/SKILL.md: No such file or directory
1 番目の代入は、終了コード 0 で通り抜けます。パイプラインの終了コードは最後の head のものになるためです。2 番目は pipefail で 2 になりましたが、変数が空であることに変わりはありません。
3 番目が、いちばん私の背筋を伸ばしたところでした。空の WS はパスから消えるのではなく、/_skills/… というルート直下のパスに化けます。今回は cat だったので読めないだけで済みました。これが rm -rf "$WS/tmp" だったなら、と想像すると、冷や汗が出ます。
対処は一行で済みます。
: "${WS:?作業フォルダが見えないシェルです。端末側のシェルに切り替えてください}"
${変数:?メッセージ} は、変数が空なら、メッセージを出して非対話のシェルを止めます。クラウド側で試したところ、メッセージが出て、終了コードは 127 でした。直後に置いた echo reached は実行されません。端末側では同じ行が素通りして、reached が出ました。
手当てその一 — 手順1の直前に、素性を一行で名乗らせます
定期タスクのプロンプトの先頭に、次の三行を置くことにしました。どちらのシェルに着いたのかが、実行ログに必ず残ります。
WS="$(ls -d /sessions/*/mnt/Workspace 2>/dev/null | head -1)"
echo "shell=$(hostname) py=$(python3 -c 'import platform;print(platform.python_version())') ws=[${WS:+found}]"
: "${WS:?作業フォルダが見えないシェルです。端末側のシェルに切り替えてください}"
端末側では shell=claude py=3.10.12 ws=[found] と出て先へ進み、クラウド側では shell=vm py=3.13.16 ws=[] を出して止まりました。ログには、パスそのものではなく found とだけ出しております。セッション固有の識別子が、ログに混ざらないようにするためです。
空かどうかを調べる一行を、使う一行の前に置きます。 原則はそれだけです。
手当てその二 — 空の出力を、tool_result の失敗として返します
ここまでは、シェルの中の話でした。Messages API でエージェントを自作しているなら、同じ手当てをツールの返し方に組み込めます。
次の twoshell_guard.py は、標準ライブラリだけで動きます。実行先のシェルの素性を調べる fingerprint() と、結果を tool_result に整える dispatch() が中身です。
"""二つのシェルを持つエージェントのための薄いガード。標準ライブラリだけで動きます。"""
import glob, json, os, platform, socket, subprocess, sys
WS_NAME = "Workspace" # 作業フォルダ名(例)
# ツールごとの「契約」。説明文と同じ内容を機械側でも持つ。
CONTRACT = {
"run_in_sandbox": {"needs_workspace": False},
"run_on_desktop": {"needs_workspace": True},
}
def fingerprint():
"""シェルの素性。空文字ではなく、必ず何かを返す。"""
found = glob.glob(f"/sessions/*/mnt/{WS_NAME}") + glob.glob(
os.path.expanduser(f"~/mnt/{WS_NAME}")
)
return {
"host": socket.gethostname(),
"python": platform.python_version(),
"has_sessions": os.path.isdir("/sessions"),
"workspace": found[0] if found else None,
}
def dispatch(tool_use_id, name, command, fp, expect_empty=False):
"""tool_use を実行し、Messages API にそのまま返せる tool_result を作る。"""
need = CONTRACT[name]["needs_workspace"]
if need and not fp["workspace"]:
return {
"type": "tool_result", "tool_use_id": tool_use_id, "is_error": True,
"content": f"{name} の実行先 (host={fp['host']}) に作業フォルダがありません。"
f"ツールと実行先の対応を確認してください。",
}
p = subprocess.run(["bash", "-c", command], capture_output=True, text=True, timeout=30)
out = p.stdout.strip()
empty_is_error = (not out) and (not expect_empty)
if p.returncode != 0 or empty_is_error:
why = f"exit={p.returncode}" if p.returncode else "exit=0 だが出力が空"
return {
"type": "tool_result", "tool_use_id": tool_use_id, "is_error": True,
"content": f"{why}。stderr={p.stderr.strip()[:200]!r}。"
f"パスの解決結果が空でないか、実行先のシェルが正しいかを確認してください。",
}
return {"type": "tool_result", "tool_use_id": tool_use_id, "content": out}
このファイルを、二つのシェルで同じものを実行しました(チェックサムは一致しています)。末尾で、手順1と同じ ls -d を二通りの契約で流した結果です。
クラウド側では、作業フォルダが必要な run_on_desktop は、実行すらされずに止まりました。
{"host": "vm", "python": "3.13.16", "has_sessions": false, "workspace": null}
→ is_error: true
"run_on_desktop の実行先 (host=vm) に作業フォルダがありません。ツールと実行先の対応を確認してください。"
契約の上では作業フォルダが要らない run_in_sandbox で、作業フォルダを探すコマンドを流した場合は、こうなりました。
→ is_error: true
"exit=0 だが出力が空。stderr=''。パスの解決結果が空でないか、実行先のシェルが正しいかを確認してください。"
端末側では、同じ二つの呼び出しが、どちらも作業フォルダのパスを返して成功しました。Python は 3.10.12 でも、ファイルは問題なく動いております。
このガードが見ているのは、モデルが選んだツールの名前ではなく、実行先のシェルの素性 です。端末側の実行先に run_in_sandbox と書いたコマンドが流れてきても、ガードは通します。配線そのものの誤りは、ツール名では見つけられません。
手当てその三 — ツール定義の description に、見える範囲を書きます
三つ目は、モデルに渡すツール定義です。名前が似ているだけで中身の違うツールを二つ並べると、モデルは説明文から選ぶしかありません。その説明文に「どこで動き、何が見えるか」を書きます。
import anthropic
import twoshell_guard as g
MODEL = "claude-sonnet-5-5"
TOOLS = [
{
"name": "run_in_sandbox",
"description": (
"使い捨てのクラウド側サンドボックスでコマンドを実行します。"
"見えるのは /tmp と作業用ディレクトリだけで、ユーザーのフォルダは見えません。"
"外部への通信が制限されることがあります。"
),
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
{
"name": "run_on_desktop",
"description": (
"ユーザーの端末側のシェルでコマンドを実行します。"
"接続された作業フォルダは $HOME/mnt/Workspace にあります。"
"ユーザーのファイルを読み書きするときは、必ずこちらを使います。"
"接続したフォルダの中のファイルは、既定では削除できません。"
),
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
]
def run_turn(client, messages, fps):
"""1ターン進める。fps は {ツール名: fingerprint()} を起動時に集めたもの。"""
resp = client.messages.create(
model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages
)
messages.append({"role": "assistant", "content": resp.content})
results = [
g.dispatch(b.id, b.name, b.input["command"], fps[b.name])
for b in resp.content if b.type == "tool_use"
]
if results:
messages.append({"role": "user", "content": results})
return resp.stop_reason
先にお断りしておきます。このループは、API キーを持たない環境で書きました。実際の API 呼び出しは行っておりません。実行して確かめたのは dispatch までです。SDK の型は、ToolParam が name・description・input_schema を、ToolResultBlockParam が tool_use_id・content・is_error を持つことだけ、型定義の上で確かめました。
description は、fingerprint() の契約と同じ内容を、人間の言葉で書き写したものです。説明文と契約を別々に持つと、いつか食い違います。 私は CONTRACT を先に書き、そこから説明文を起こす順にしております。
どちらのシェルに何を任せるか、私の場合の線引きです
この日の作業では、リポジトリの取得を端末側のシェルで行いました。クラウド側から GitHub へ素の curl を投げたところ、先の表のとおり 403 が返り、所要も 1,352 ms と 98 ms で、約 13.8 倍の開きがあったためです。一回きりの観測ですので、速さの比較ではなく「通り方が違った」という目安としてお読みください。
私の場合は、次のように分けております。
- 作業フォルダのファイルを読み書きする作業は、端末側にまとめます
- 外部サービスへ接続する作業は、先に端末側の通信を確かめてから任せます
- 使い捨ての計算や、手元のファイルに触れない検証は、クラウド側で行います
この分け方は、誤った側に流れても被害の小さい作業をクラウド側に置く、という考え方です。取り違えの回避を人の注意に頼らず、手順の側に持たせることをお勧めします。接続先の違うシェルが混ざる構成では、素性を名乗らせる一行を標準にしておくことを推奨します。
落とし穴は、空を「正常」にしてしまう側にありました
実際に踏みそうになった点を、三つ書き残します。
一つ目は、本当に空で正しいコマンドです。grep が何も見つけないのが正常な検査や、出力を捨てて終了コードだけを見る呼び出しがそうです。dispatch に expect_empty=True を渡して、空を許す場所を呼び出し側が明示します。許す場所を絞るほど、ガードは効きます。
二つ目は、素性の使い回しです。fingerprint() は起動時に一度だけ取り、各ツールの実行先ごとに保存します。ターンごとに取り直すと、時間を食うだけで、実行先が途中で入れ替わる事故は防げません。入れ替わりが起きるなら、呼び出しの前に host が保存した値と一致するかを見る一行を足します。
三つ目は、パスそのものを説明文やログに書かないことです。今回の端末側のパスには、セッション固有の識別子が含まれておりました。説明文には $HOME/mnt/Workspace のように変数で、ログには found とだけ書きます。
今日できること
手元のエージェントや定期タスクに、次の三つを足してみてください。
- 手順1の直前に、素性を名乗る
echo と : "${WS:?…}" の二行を置きます
- ツールの結果を返す関数で、終了コード 0 の空出力を
is_error にします
- ツール定義の description に、そのツールが見えるフォルダと見えないフォルダを一文で書きます
私は、片方のシェルが「ほぼ同じ」に見えるあいだは、取り違えは起きていないものだと思い込んでおりました。気づいたのは、空になったその一度きりです。見えるのは、空になったときだけだと感じています。
最後までお読みいただき、ありがとうございます。