agy -p が手元でうまく動いたので、そのままコンテナに載せて夜間に回してみたいと考えたとき、私が最初に不安になったのは agy そのものではなく、「固まったときに、呼び出し側がそれに気づけるか」でした。
そこで、実物の agy は使わずに、わざと固まる擬似 agy のシェルスクリプトを作って、呼び出し側の安全網を先に組んでみました。以下に出てくるコードと出力は、すべて手元で実行したものです。実物の agy を Cloud Run で動かした記録ではありません。その部分は、すでに実体験を公開してくださっている方の記事を参照し、公式 docs で裏取りできた範囲だけを書きます。
最初にお伝えしたいのは、順番です。コンテナに載せる前に認証の出どころを決めておくこと。次に、固まったときの出口を呼び出し側に持たせること。この 2 つが先で、プロンプトの工夫はその後です。
先に押さえる事実(公式 docs と公開されている報告)
実際に Docker と Cloud Run のジョブから agy を呼んだ方の記録が、Zenn に公開されています(Antigravity CLI(agy)を Docker / Cloud Run から呼び出すときにハマること)。その内容と、公式のインストールページ・GitHub の Issue を突き合わせると、次の点が確認できます。
| 論点 | 確認できたこと | 出どころ |
|---|---|---|
| ヘッドレスの認証 | 公式 docs が挙げるのは Gemini API キー方式。settings.json に modelProvider を gemini と書き、環境変数 GEMINI_API_KEY を渡す。環境変数だけでは効かない、と明記されている | 公式インストールページ |
| サービスアカウント/ADC | 非対話認証を求める Issue(#223)で、GOOGLE_APPLICATION_CREDENTIALS などを試しても OAuth の案内が出たと報告されている。公式の回答は、確認した時点のページ上には見当たらない | GitHub Issue #223 |
| 手元で動いた理由 | 以前のブラウザログインのトークンが残っていた、という指摘。OS のキーチェーンに有効なトークンがあればブラウザなしでサインインする、と公式 docs にもある | 公式インストールページ/Zenn の記録 |
| 承認待ちの固まり | ツール実行の承認を誰も返せず --print-timeout まで待ち、終了コード 0 で部分出力を返した、という報告 | Zenn の記録 |
ひとつ注意があります。--print-timeout と --dangerously-skip-permissions は、私が確認した公式インストールページには記載がありませんでした。Zenn の記録では使われているので、お使いの版の agy --help で存在を確かめてから使ってください。版は固定されておらず、この記録では手元が 1.0.15、コンテナが 1.2.16 だったそうです。
安全網 1:インストーラーを「そのまま bash に流さない」
Zenn の記録によると、curl ... | bash で取得したインストーラーが、同じ URL なのに、ときどき gzip のまま(Content-Encoding なし)で返ってきたそうです。私の環境で同じ現象は再現していません。ただ、「gzip のまま bash に渡すとどうなるか」と「いったんファイルに落として判定する書き方が正しく動くか」は、手元で確かめられます。
# 普通のスクリプトと、gzip したスクリプトを用意
printf '#!/bin/bash\necho INSTALL_OK\n' > plain.sh
gzip -c plain.sh > gz.raw
cp plain.sh plain.raw
for f in plain.raw gz.raw; do
if gzip -t "$f" 2>/dev/null; then
gunzip -c "$f" > out.sh; echo "$f: gzip -> decompressed"
else
cp "$f" out.sh; echo "$f: plain"
fi
bash out.sh
done実行結果は次のとおりです。
plain.raw: plain
INSTALL_OK
gz.raw: gzip -> decompressed
INSTALL_OKgzip のまま cat gz.raw | bash に流すと、bash は $'\037\213...' のようなバイナリ文字列をコマンドとして解釈しようとして、意味のあるエラーを残さないまま終わりました。ビルドログに残るのが「bash: line 1: ...」という崩れた 1 行だけだと、原因にたどり着くまでに時間がかかります。判定を 3 行挟むだけで、この種の迷子は避けられます。
実際の Dockerfile では、非 root ユーザーを作ってから、この判定つきのインストールを行い、最後に agy --version でビルド時に存在確認をします。インストーラーは ~/.bashrc を書き換えますが、CMD はそれを読まないので、PATH は ENV で渡す必要がある、という点も Zenn の記録にありました。
安全網 2:呼び出し側に「期限」と「閉じた stdin」を持たせる
承認待ちで固まる挙動を、擬似 agy で再現します。--dangerously-skip-permissions が付いていなければ待ち続け、待った後は「部分出力を返した」というメッセージを出して終了コード 0 で終わる、という振る舞いをシェルで書きます。
#!/bin/bash
# fake_agy.sh — 承認待ちで固まる擬似 agy
for a in "$@"; do [ "$a" = "--dangerously-skip-permissions" ] && SKIP=1; done
if [ -z "$SKIP" ]; then
echo "waiting for tool approval..."
sleep ${FAKE_HANG:-30}
echo "print timeout after 2m0s with turn in progress; returning partial output"
exit 0
fi
echo OK > "${FAKE_OUT:-result.txt}"
echo "done"; exit 0これを呼ぶ側の Python が、次の run_agent です。ポイントは 3 つあります。stdin を DEVNULL にして入力待ちで止まらないようにすること。呼び出し側に独自の期限を持つこと。そして、成否を終了コードではなく「成果物のファイルがあるか」で決めることです。
import os, subprocess, time, pathlib
def run_agent(cmd, workdir, expect_file, timeout_s, env=None):
"""exit code を信用しない。期限・stdin 閉鎖・成果物の存在で成否を決める。"""
env = {**os.environ, **(env or {})}
for k in ("APP_PASSWORD", "SESSION_SECRET"): # エージェントに不要な秘密は渡さない
env.pop(k, None)
proc = subprocess.Popen(cmd, cwd=workdir, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, errors="replace", env=env)
deadline = time.time() + timeout_s
killed = False
while proc.poll() is None:
time.sleep(0.2)
if time.time() > deadline:
proc.kill(); proc.wait(); killed = True; break
out = proc.stdout.read()
produced = pathlib.Path(workdir, expect_file).is_file()
return {"exit": proc.returncode, "killed": killed,
"produced": produced, "ok": produced and not killed,
"tail": out[-200:]}4 つの場面で動かした結果を並べます。期限は短くして、FAKE_HANG で固まる秒数を調整しました。
| 場面 | exit | killed | produced | ok |
|---|---|---|---|---|
| フラグなし・期限 3 秒・10 秒固まる | -9(kill 後に回収) | True | False | False |
| フラグなし・期限 10 秒・2 秒で「部分出力」を返して終了 | 0 | False | False | False |
| フラグあり・成果物を書いて終了 | 0 | False | True | True |
| フラグあり・成果物を別の名前で書いて終了 | 0 | False | False | False |
表の 2 行目と 4 行目が、この記事でいちばん伝えたい部分です。終了コードは 0 なのに、ok は False になります。「終了コード 0 なら成功」という判定だと、この 2 つは成功として数えられ、後段に空のディレクトリが流れていきます。
最初のコードでは kill() のあとに wait() を入れていませんでした。その結果、1 行目の exit が None のままログに残りました。回収を足して -9 が見えるようにしたのは、擬似 agy で試したからこそ気づけた修正です。
安全網 3:「動いた」ではなく「使うツールが動いた」を確かめる
Zenn の記録で印象に残ったのは、エージェントが「完了しました」と報告していても、ログには自作の評価スクリプトが毎回例外を出している、という場面でした。エージェントはそれを環境の問題と見なして評価を飛ばし、失敗を伝えないまま終えていたそうです。
ここは擬似 agy では再現できない部分なので、手元で確かめたことではなく、記録から学んだ線引きとして書きます。
- agy が
OKと返すかどうかに加えて、エージェントに呼ばせるツールを 1 回ずつ単体で動かす自己診断モードを、ジョブに持たせます - 自己診断は、本番と同じ引数で走らせます(記録では、フラグを省いた自己診断が、承認待ちの固まりをそのまま再現したそうです)
- 成功の判定は、エージェントの報告ではなく、呼び出し側が成果物を検査して行います
安全網 2 の expect_file は、この「呼び出し側が検査する」の最小形です。ファイルが存在するだけでは足りない場面では、中身の検査をここに足していくことになります。
認証についての、最初の決め事
最後に、冒頭の「認証の出どころを先に決める」に戻ります。公式 docs が示すヘッドレスの方式は、Gemini API キーでした。その場合、環境変数だけでは効かず、settings.json で modelProvider を gemini にする必要があります。ここは私自身は実物で確かめていません。公式の記述として引用するにとどめます。
また、サービスアカウントや ADC で動かしたい場合は、Issue #223 のとおり、確認した時点では公式の対応が見当たりませんでした。「手元で動いたからコンテナでも動く」は、キーチェーンに残ったログイン情報のおかげかもしれません。コンテナに載せる前に、ブラウザログインを一度すべて捨てた状態で agy -p を動かしてみる——その確認が、いちばん安く済みます。
手元で動いたことと、無人で動き続けることのあいだを取り持つのは、呼び出し側の検査です。
次の一歩として、お使いの環境で agy --help を開き、--print-timeout と --dangerously-skip-permissions が実在する版かどうかを確かめるところから始めていただければと思います。許可の扱いそのものについては、承認ダイアログが繰り返し出るときの切り分けも参考になります。