エージェントに「記事の本数を数えて」と頼んだら、0 件でした と返ってきました。
ワークスペースには 977 本の MDX があります。ターミナルで同じことを手で打つと、ちゃんと 977 と出ます。
エラーは出ていません。コマンドは成功しています。ただ、数えた場所が違っていました。
原因はワークスペースの名前でした。Dolice Labs。この真ん中の空白ひとつです。
個人開発では、ワークスペースの置き場所を自分で選べます。私自身、Dropbox の同期フォルダをそのまま作業場所にしていて、フォルダ名に空白を入れたことを長いあいだ気にしていませんでした。人間が読むための名前ですから、空白があるほうが読みやすい。
エージェントに手を動かしてもらうようになって、この判断のつけが回ってきました。
空白は末端ではなく、接頭辞にあります
最初に測ったのは「そもそもどれくらい該当するのか」でした。
ワークスペース直下から3階層のディレクトリを数えます。
WS = "/path/to/Dolice Labs"
find " $WS " -maxdepth 3 -type d | wc -l # 1951
find " $WS " -maxdepth 3 -type d | grep -c ' ' # 1951
1,951 件中 1,951 件。100% です。
ファイル名のほうを見ると、印象がまったく変わります。
find " $WS " -maxdepth 3 -type f | wc -l # 1816
find " $WS " -maxdepth 3 -type f | awk -F/ '{print $NF}' | grep -c ' ' # 5
1,816 件中 5 件。0.3% です。
同じワークスペースの、同じファイル群です。数え方を変えただけで 100% と 0.3% に割れました。
空白はファイル名の側にはほとんどありません。パスの接頭辞、つまり Dolice Labs という一箇所にだけあります。そして絶対パスを組み立てる限り、その一箇所が全てのパスに配られます。
node_modules の奥深くにある名前の綺麗なファイルも、絶対パスで指した瞬間に空白を含みます。
ここが分かれ道でした。問題はファイル名ではなく、パスの組み立て方のほうにありました。
未引用パスの11形を並べて、何が返るかを測りました
エージェントが書くシェルコマンドは、毎回 "$WS" と引用してくれるとは限りません。実際、$WS と素で書かれたときに何が起きるかを、11の形で測りました。
検証用に、空白入りのワークスペースを作ります。
BASE = " $HOME /pathprobe"
WS = " $BASE /Dolice Labs"
mkdir -p " $WS /content/articles/ja/tips" " $WS /out"
for i in 1 2 3 4 5 ; do echo "# a $i " > " $WS /content/articles/ja/tips/a $i .mdx" ; done
mkdir -p " $BASE /sandbox" && cd " $BASE /sandbox"
.mdx は5本。ここで $WS を引用せずに触ります。
コマンドの形 終了コード 返った値 正しい値
find $WS -name "*.mdx" | wc -l0 0 5
ls $WS | wc -l0 0 4
grep -rl "a1" $WS | wc -l0 0 1
[ -d $WS ] && echo yes || echo no0 no yes
mkdir -p $WS/out/gen0 作成された 別の2箇所に作成
for f in $(find "$WS" -name "*.mdx")0 10 回 5 回
find "$WS" ... | xargs wc -l0 0 total 5 total
python3 script.py $WS(argv の数)0 2 1
du -sh $WS0 12K(別物のサイズ) 実体のサイズ
cat $WS/.../a1.mdx1 — # a1
cp $WS/.../a1.mdx ./c.mdx1 — copied
11形のうち、即座に失敗したのは cat と cp の2形だけでした。残る9形、82% は終了コード 0 を返しながら誤った結果を渡しています。
内訳を見ると、壊れ方が一様でないことが分かります。
find と grep は 0件で成功 します。$WS が /path/to/Dolice と Labs の2語に分かれ、どちらも存在しないため探索対象がなく、何も見つからないまま正常終了します。エラーは標準エラー出力に出ますが、| wc -l を挟んだ時点で終了コードは wc のものになり、上流の悲鳴は消えます。
[ -d $WS ] は 存在するのに「ない」と答えます 。2語になったことで [ が引数の数を数え間違え、判定式として成立しないまま偽を返します。存在チェックで分岐する処理は、ここで静かに逆へ進みます。
for f in $(find "$WS" ...) は 5本のファイルを10行として扱います 。find 自体は引用されていて正しく5本返すのに、コマンド置換の結果が空白で語分割され、各パスが2つに割れます。ループは10回まわり、10回とも壊れたパスを掴みます。引用したのに壊れる、という一番気づきにくい形です。
python3 script.py $WS は argv が2個になります 。スクリプト側が sys.argv[1] だけを見ていれば、/path/to/Dolice を作業対象だと信じて動き出します。
そして mkdir -p $WS/out/gen は、終了コード 0 で成功しながら、実体ではない2箇所にディレクトリを作りました。
$ ls -A " $BASE /sandbox" # カレントディレクトリ
Labs
$ ls -A " $BASE "
Dolice Dolice Labs sandbox
mkdir -p /path/to/Dolice Labs/out/gen は、/path/to/Dolice と Labs/out/gen という2つの引数として解釈されます。前者は絶対パスなので $BASE の直下に、後者は相対パスなのでカレントディレクトリの下に生まれます。
読み取りは 0件で返り、書き込みは知らない場所に届く。この非対称が、この問題の性質を決めています。
読み取りが 0件で返るなら、まだ「何もしなかった」だけです。書き込みが別の場所に成功していると、エージェントは「やりました」と報告し、成果物はどこにもない。私が最初に踏んだのは後者でした。
エラーの不在は、正しさの証明ではありませんでした。
引用の徹底では、守りきれない部分があります
最初に考えたのは「エージェントに引用を守らせる」という方針でした。AGENTS.md に「パスは必ずダブルクォートで囲む」と書き、shellcheck の SC2086 を CI で落とす。
これは半分効きます。ただ、上の表を見返すと、引用では届かない場所があります。
for f in $(find "$WS" ...) は、パスをきちんと引用してもなお壊れました。壊れているのは $WS の引用ではなく、コマンド置換の結果を語分割する仕組みのほうです。xargs も同じで、既定では空白を区切りとして扱います。
これらには別の道具が要ります。
# 空白区切り(既定)— 0 total
find " $WS " -name '*.mdx' | xargs wc -l | tail -1
# ヌル区切り — 5 total
find " $WS " -name '*.mdx' -print0 | xargs -0 wc -l | tail -1
# while read の場合
find " $WS " -name '*.mdx' -print0 | while IFS = read -r -d '' f ; do
printf '%s\n' " $f "
done | wc -l # 5
-print0 と -0 と IFS= read -r -d '' の3点セットです。正しい書き方ではあります。
ただ、これを毎回エージェントに守らせるのが現実的か、という問いが残りました。手で書くコードなら規約で回せます。エージェントが1日に何十本もシェルを書く前提だと、規約の遵守率がそのまま事故率になります。しかも遵守しなかったときの失敗が終了コード 0 なので、ゲートを一つすり抜けたら気づく機会がありません。
規約を守らせる方向は、守れなかったときに壊れる設計です。私はこの手の設計を好みません。守らなくても壊れない形にできないか、を先に探しました。
シンボリックリンクの別名は、入口にしかなりません
次に試したのが、空白のない別名を一本立てる案です。
ln -sfn " $WS " " $BASE /ws"
ALIAS = " $BASE /ws"
これなら $ALIAS を引用し忘れても2語になりません。実際、いくつかは通りました。
[ -d $ALIAS ] && echo yes || echo no # yes(正しい)
mkdir -p $ALIAS /out/gen # rc=0、実体に到達
書き込みは実体に届き、カレントディレクトリへの誤爆もありませんでした。ここまでは期待どおりです。
ところが find で止まりました。
find $ALIAS -name '*.mdx' | wc -l # 0 ← 期待は5
find $ALIAS / -name '*.mdx' | wc -l # 5
find -L $ALIAS -name '*.mdx' | wc -l # 5
find は既定でシンボリックリンクを辿りません。末尾にスラッシュを付けるか -L を渡せば辿りますが、これは「エージェントに find -L と書かせる」という新しい規約の追加です。守らせる方向に戻ってしまいました。
そして決定的だったのがこれです。
readlink -f $ALIAS
# /path/to/Dolice Labs
別名は入口にすぎません。実体のパスを解決する処理が一段でも挟まれば、空白入りのパスがそのまま下流へ流れます。realpath を呼ぶツール、__file__ から親を辿るスクリプト、エラーメッセージに絶対パスを出すツール。どれも別名の外側にいます。
シンボリックリンクは、空白を消すのではなく、空白を見せない層を一枚かぶせるだけでした。層の下は変わっていません。
相対パスなら、空白は最初から存在しません
行き詰まって、最初の測定に戻りました。100% と 0.3% に割れた、あの数字です。
空白は接頭辞にしかない。ファイル名の側にはほぼない。
ということは、接頭辞を取り除いてしまえば、残りに空白はありません。cd で一度そこに立てば、以降の相対パスは空白と無縁になります。
cd " $WS "
find . -name '*.mdx' | wc -l # 5
for f in $( find . -name '*.mdx' ); do echo x ; done | wc -l # 5
find . -name '*.mdx' | xargs wc -l | tail -1 # 5 total
先ほど 0 と 10 と 0 を返した3形が、全て 5 を返しました。引用も -print0 も使っていません。
理由は単純で、./content/articles/ja/tips/a1.mdx という文字列に空白が含まれていないからです。語分割される余地がありません。
エージェントに「引用を守れ」と要求する代わりに、「空白のない世界で作業させる」に切り替える。守らなくても壊れない形は、こちらでした。
空白入りのパスに触るのは、cd の一行だけ。その一行を、私が書いた一箇所に閉じ込めます。
入口を一本にする ws.sh
境界を閉じるスクリプトです。ワークスペースの bin/ws.sh に置きます。
#!/usr/bin/env bash
# ws.sh — 空白を含むワークスペースへの唯一の入口。
# 絶対パスに触れるのはこのファイルだけ。呼ばれた側は相対パスの世界で動く。
set -euo pipefail
# 1) 実体を1回だけ解決する。以降 WS_ROOT を再構築しない
WS_ROOT = "$( cd -- "$( dirname -- "${ BASH_SOURCE [0]}")/.." && pwd -P )"
# 2) 存在チェックではなく実書き込みで可否を決める
_probe () {
local t = "${ WS_ROOT }/.ws-probe. $$ "
if ( : > " $t " ) 2 > /dev/null ; then rm -f -- " $t " ; return 0 ; fi
return 1
}
if ! _probe ; then
printf 'ws.sh: 書き込めません: %s\n' " $WS_ROOT " >&2
exit 78 # EX_CONFIG —「失敗」と「0件」を取り違えないため専用コードにする
fi
# 3) ここで境界を閉じる。以降の相対パスに空白は含まれない
cd -- " $WS_ROOT "
# 4) 子プロセスへは環境変数で渡す(引数の語分割を経由させない)
export WS_ROOT
export LC_ALL = "${ LC_ALL :- C . UTF-8 }"
if [ " $# " -eq 0 ]; then
printf 'usage: bin/ws.sh <command> [args...]\n' >&2
exit 64
fi
exec -- " $@ "
一行ずつ、なぜそう書いたかを残しておきます。
WS_ROOT を $0 の位置から求めているのは、呼び出し側にパスを渡させないためです。パスを引数で受け取れば、その引数が語分割される可能性がまた生まれます。スクリプト自身の居場所から相対で辿れば、外から空白入りの文字列が入ってくる経路がなくなります。
cd -- "$(dirname ...)" && pwd -P の組み合わせは、readlink -f の代わりです。pwd -P はシンボリックリンクを解決した実体を返すので、bin/ws.sh 自体が別名経由で呼ばれても、実体の位置に着地します。
-- を各所に置いているのは、パスがハイフンで始まったときにオプションと誤解されないためです。空白の話とは別筋ですが、パスを扱う入口では一緒に塞いでおきます。
そして exec -- "$@" です。引数配列をそのまま渡すので、ここでも語分割は起きません。exec にしているのは、プロセスを置き換えて終了コードとシグナルを素通しさせるためです。ラッパーが一段挟まると、Ctrl+C の効き方や終了コードの伝わり方が変わります。
存在チェックではなく、実書き込みで判定します
_probe の部分だけ、意図を補足します。
[ -d "$WS_ROOT" ] で十分に見えます。ただ、読めるけれど書けない状態は普通に起こります。クラウド同期フォルダの権限が変わったとき、別のユーザーが所有者になったとき。存在チェックは真を返し、その先で書き込みが失敗します。
実際にファイルを1バイト作って消す。これが一番確実でした。この判定の考え方は、無人実行中に作業フォルダの所有者が変わる問題を追ったときにも使っています。詳しくは無人ランで作業フォルダの所有者が変わったときの切り分けと三段フォールバック にまとめました。
終了コードを 78 にしているのは、意図があります。1 だと「コマンドが失敗した」と区別がつきません。呼び出し側が「0件だった」のか「そもそも入口で止まった」のかを機械で判別できるように、設定エラー用の番号を割り当てています。
読み取り専用にして確かめます。
$ chmod 555 " $RO_WS "
$ " $RO_WS /bin/ws.sh" bash -c 'echo should_not_run'
ws.sh: 書き込めません: /path/to/Read Only WS
$ echo $?
78
should_not_run は実行されず、78 で止まりました。
エージェントへ渡す規約は3行で足ります
AGENTS.md に書くのは、これだけにしました。
## ワークスペース内でコマンドを実行するとき
- シェルコマンドは必ず `bin/ws.sh <command>` 経由で実行してください
- パスは全て相対パスで書いてください(絶対パスを組み立てないでください)
- 出力先も相対パスです(例: `out/report/` )
規約が短いほど、遵守率は上がります。「引用を徹底し、-print0 と -0 を使い、for にコマンド置換を渡さない」という規約と比べて、覚えることが一つに減りました。
そして守らなかったときの挙動が違います。エージェントが bin/ws.sh を忘れて素で find . -name '*.mdx' を打てば、カレントディレクトリが違うので 0件が返る。これは今までと同じ静かな失敗です。ただ、絶対パスを組み立てて別の場所へ書き込む事故は起きなくなります。
失敗の種類を「静かに間違う」から「静かに何もしない」へ寄せた、という言い方が正確です。事故の質が一段下がりました。
エージェントが生成したスクリプトが意図しない副産物をコミットしてしまう問題も、ステージ範囲の側で塞ぎました。あわせてエージェントのコミットに .bak が混じる問題の切り分け も参考になるかもしれません。
入口が効いているかを、毎回確かめます
規約と実装だけでは足りませんでした。入口を通したつもりで通っていない、が起こります。
検証スイートを置きます。相対パスだけで書いてあります。
#!/usr/bin/env bash
# tests/path_contract.sh — 入口が効いているかを同じ手順で確かめる
set -uo pipefail
fail = 0
chk () {
if [ " $2 " = " $3 " ]; then printf ' ok %-28s %s\n' " $1 " " $3 "
else printf ' FAIL %-28s expected=%s actual=%s\n' " $1 " " $2 " " $3 " ; fail = 1 ; fi
}
EXPECT_MDX = " $1 "
chk "cwd は WS_ROOT" "$( pwd -P )" "${ WS_ROOT :- unset }"
chk "相対 find" " $EXPECT_MDX " "$( find . -name '*.mdx' | wc -l | tr -d ' ')"
chk "未引用 for" " $EXPECT_MDX " "$( for f in $( find . -name '*.mdx'); do echo x; done | wc -l | tr -d ' ')"
chk "xargs(空白区切り)" " $EXPECT_MDX " "$( find . -name '*.mdx' | xargs -r wc -l 2> /dev/null | tail -1 | awk '{print $1}')"
chk "cwd 直下に誤爆なし" "0" "$( ls -A | grep -cx 'Labs' || true )"
exit " $fail "
わざと未引用の for と素の xargs を検査項目に入れています。守れなかった書き方でも通ることが、この入口の目的だからです。正しい書き方だけを検査すると、入口が効いているかを確かめたことになりません。
cwd は WS_ROOT の一行は、ws.sh を経由せずに実行されたときに落ちます。
$ cd /somewhere/else
$ " $WS /bin/ws.sh" bash tests/path_contract.sh 5
ok cwd は WS_ROOT /path/to/Dolice Labs
ok 相対 find 5
ok 未引用 for 5
ok xargs(空白区切り) 5
ok cwd 直下に誤爆なし 0
$ echo $?
0
全て通りました。未引用の for が 5 を返しています。
このスイートは無人実行のスケジュールにも組み込んでいます。入口の設定が壊れたことに、夜中の3時ではなく実行前に気づけます。
この設計で守れないこと
正直に書いておきます。
ワークスペースの外は守れません。 cd で閉じられるのは中だけです。エージェントが /Users/name/Library/Application Support/... のような外部のパスに触る必要があるなら、そこはまた空白入りの絶対パスです。私の場合、外に出る用事は限られていたので、必要な外部パスは ws.sh の中で環境変数に入れて渡し、参照箇所を数えられる状態にしました。数えられれば、そこだけ引用を確認できます。
エラーメッセージには空白入りの絶対パスが出ます。 これは避けられません。pwd -P の結果を表示するツール、スタックトレースにファイルパスを出す言語ランタイム。エージェントがそのメッセージをコピーして次のコマンドを組み立てると、そこから空白が再流入します。実際に一度やられました。対処は「エラーメッセージ由来のパスをそのまま使わせない」という規約の追加で、これは守らせる方向の設計です。ここだけは妥協しています。
ファイル名側の空白は別問題です。 測定で 5/1,816 件でした。少ないですが、ゼロではありません。相対パスにしても ./out/report dir/x.txt は空白を含みます。ここは -print0 と -0 の出番で、入口では解決できません。私は出力先の命名規約でハイフンに寄せることにしました。
Windows のパスは検証していません。 この記事の測定は Linux 環境です。ドライブレターやバックスラッシュ、WSL2 をまたぐ場合は挙動が変わります。WSL2 でのファイル監視まわりは別の落とし穴があり、WSL2 でファイル監視が遅くなるときの対処 で触れています。
そもそも改名すれば済む話でもあります。 Dolice Labs を dolice-labs にすれば、この記事の大半は不要になります。私はしませんでした。Dropbox の共有リンク、iOS アプリ側のブックマーク、4サイト分の設定に散っている参照を全部追う手間と、ws.sh を1本書く手間を比べて、後者を選んだだけです。この判断は環境によって逆になります。まだ参照が少ない段階なら、改名するほうが確実だと思います。
次に確かめるとしたら
もしお手元のワークスペースのパスに空白が含まれているなら、まず一行だけ試してみてください。
[ -d $WS ] && echo yes || echo no
no と返ってきたら、この記事の話が起きています。エージェントの分岐が、存在するものを「ない」と判断している状態です。
そのうえで、順番としてはこうお勧めします。参照がまだ少ない段階なら、迷わず改名してください。ws.sh を書くより確実で、限界の節に挙げた4つの穴が最初から存在しません。改名の追跡コストが見合わないところまで参照が広がっているなら、bin/ws.sh と tests/path_contract.sh の2本から始めるのが良いと考えています。規約の追加は最後です。守らせる設計は、守らせなくて済む設計を尽くしたあとの手段だと私は思っています。
数え方を変えただけで 100% と 0.3% に割れた、あの数字を見たときに、問題の形がやっと見えました。エラーを探していたときには何も見つからなかったのに、返ってきている値そのものを疑ったら、9形が嘘をついていた。
空白ひとつを甘く見ていた自分への戒めとして、この測定を残しておきます。同じ場所でつまずいた方の遠回りが少しでも短くなれば、書いた甲斐があります。