BESTNET TECH BLOG
AIエージェントにWindows実機作業を任せて ― SSH非対話化・文字コード・T-SQLの“継ぎ目”で踏んだ実戦記
シリーズ本編では、Oracle 19cからSQL Server 2022への移行前処理を、AIコーディングエージェント(Claude Code)に実データを渡さずに自動化し、移行先実機での適用・機能検証まで完了したことを報告しました。
本稿はその裏側です。AIエージェントに「実機での作業」を任せると、つまずくのはたいていAIの賢さではなく、AIとシェルとOSの文字コードとツールの“継ぎ目”でした。SSHの非対話化、Windowsの文字化け、cmdのクォート、コードからのT-SQL投入——どれも単体では地味ですが、自動化を止めるには十分です。実戦で踏んだ罠のうち、本稿では技術寄りの10個を、症状・原因・対処の形で残します(残り2個はAIの安全機構に関わるガバナンスの話なので、番外編その2「AIガードレール編」へ)。同種の自動化を検討している方の数時間を節約できれば幸いです。
本稿のコマンド例は、接続先・認証情報をすべてプレースホルダ(
USER@HOST、-pw '****'、C:\work)に置き換えています。
1. 接続編 — ハンズフリーのSSHを成立させる #
AIエージェントに作業させる前提として、人間が一度もパスワードを打たずにSSHコマンドが通る必要があります。ここで早速3つ。
① OpenSSHはパスワードを非対話で渡せない #
- 症状:
ssh user@host "コマンド"がパスワード入力待ちで止まる。スクリプトから自動実行できない。 - 原因: OpenSSHクライアントはセキュリティ上、パスワードを端末(tty)からしか読まない。標準入力にパイプしても受け取らない。Windowsには
sshpassも標準では無い。 - 対処: PuTTY の
plinkを使い-pwでパスワードを渡す。
plink -ssh -pw '****' USER@HOST "whoami"
セキュリティ的には、本番運用では公開鍵認証(鍵ペア+
pageantや-i)を第一に検討してください。-pwのコマンドライン渡しはプロセス一覧やシェル履歴に残りうるため、やむを得ず使う場合も使い捨て+作業後ローテーションが前提です。
② plink の初回ホスト鍵プロンプトでハングする #
- 症状:
echo y | plink ...としても進まない。タイムアウトするまで無反応。 - 原因: plink は初回接続時の鍵キャッシュ確認「Store key in cache? (y/n)」を端末(tty)から読みます。AIエージェント実行のように tty の無い環境(パイプ/プロセス起動経由)では、パイプした
yを受け取れずハングします。対話コンソール上ではecho y | plinkが効く構成もありますが、自動実行では当てになりません。 - 対処: 先に
ssh-keyscanでホスト鍵を取り、SHA256フィンガープリントを-hostkeyでピン留めしてしまう。-batchでプロンプト自体を無効化。
# 1) ホスト鍵のフィンガープリントを取得
ssh-keyscan -T 6 HOST 2>/dev/null | ssh-keygen -lf -
# => 256 SHA256:<fingerprint> <host> (...) のように、鍵種ごとに1行ずつ出る
# 2) 取得値を -hostkey で固定し、-batch で非対話接続
# サーバが複数の鍵種(ED25519/RSA等)を返す環境では、ネゴシエートされる鍵に備え各 -hostkey を列挙する
plink -ssh -hostkey SHA256:<fingerprint> -batch -pw '****' USER@HOST "コマンド"
ピン留めはセキュリティ上もプラスです(中間者攻撃に対し、想定した鍵以外を弾く)。
③ 長時間コマンドは「投げて待つ」設計にする #
- 症状: ISOダウンロードや製品インストールのような長時間処理を同期実行すると、ツール側のタイムアウトに当たって切れる。
- 原因: SSHセッションを掴んだまま10分以上ブロックする処理は、エージェントの実行制限に引っかかりやすい。
- 対処: 長時間処理はバックグラウンドで起動し、完了をセンチネルファイルや終了通知で拾う。SSHセッションを長時間占有しない。
:: 非同期起動し、完了時に終了コードをセンチネルへ(同一 cmd /c 内なので & は逐次評価される)
start "" /b cmd /c "installer.exe /quiet & echo DONE=%errorlevel%> C:\work\done.txt"
:: 別コマンドで完了確認: if exist C:\work\done.txt type C:\work\done.txt
2. 文字コード編 — CP932 と BOM の二段構え #
日本語Windowsを相手にすると、文字化けは「見栄えの問題」ではなく処理を壊すバグになります。
④ リモート出力が Shift-JIS で化ける #
- 症状: コマンド結果が
�w�肳�ꂽ�p�X...のような文字化けで読めない。 - 原因: 日本語WindowsはCP932(Shift-JIS)で出力する。UTF-8前提の端末に渡ると化ける。
- 対処: リモート側コマンドの先頭で
chcp 65001(UTF-8に切替)。PowerShellは出力エンコーディングも明示。
chcp 65001>nul & 後続コマンド
[Console]::OutputEncoding = [Text.UTF8Encoding]::new()
⑤ PowerShell 5.1 はBOM無し .ps1 を CP932 として読む【最も時間を溶かした罠】 #
- 症状: スクリプト中の日本語リテラル(
'すべて'等)が'鬟溷刀'のような別物に化け、'~' 付近に不適切な構文がありますでSQLや処理が落ちる。 - 原因: PowerShell 5.1 は、BOMの無い
.ps1をシステムANSIコードページ(日本語環境ならCP932)として解釈する。UTF-8で保存したスクリプトの日本語が壊れる。ASCIIだけのスクリプトは無傷なので、発覚が遅れるのが厄介。 - 対処: 日本語を含む
.ps1は UTF-8(BOM付き) で保存する。PS5.1はBOMを見てUTF-8と判定する。
# UTF-8 BOM付きで書き出す(既存のBOM無しUTF-8を変換する例)
$txt = [IO.File]::ReadAllText($src, [Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllText($dst, $txt, [Text.UTF8Encoding]::new($true)) # $true = BOM付き
この2つ(④⑤)は別の層の問題(端末出力 vs スクリプト読込)ですが、症状が同じ「文字化け」なので混同しがちです。出力はchcp、入力(スクリプト)はBOM、と切り分けて覚えると早いです。
3. 実行パターン編 — cmdクォート地獄からの脱出 #
⑥ cmd経由のインラインPowerShellが | " ; で壊れる #
- 症状:
powershell -Command "Get-ChildItem | Select-Object Name"を流すと'Select-Object' は…認識されていません。"の入れ子でも崩れる。 - 原因: SSH→cmd→PowerShell と多段で渡るため、
|をcmdがパイプとして先に解釈してしまう。ダブルクォートの入れ子もcmdの段で壊れる。標準入力に流し込む方式(-Command -)も、長いスクリプトだと途中で切れることがあった。 - 対処: インラインを諦め、ローカルで
.ps1を書く→pscpで転送→powershell -NoProfile -Fileで実行する。本作業の環境(Windows Server 2022 既定のRemoteSigned)では、ローカルの未署名.ps1がそのまま動きました。これでクォート問題も標準入力切れも大半が解消します。
本作業ではこのパターンが安定しました。「複雑なリモート処理はインラインで頑張らず、ファイルにして送って
-Fileで叩く」。実行ポリシーをBypassで弱める必要もありません(その操作はむしろ安全機構に止められました——番外編その2参照)。ただし前提に注意が要ります。
RemoteSignedで転送スクリプトが動くかは、組織のグループポリシーや署名ポリシー(AllSigned/Constrained Language Mode/AppLocker・WDAC 等)次第で変わります。また「pscp転送ファイルにMOTW(Mark of the Web)が付かない=署名ガードが効かない」のは利点ではなくトレードオフで、第三者由来のスクリプトではむしろリスクです。社内で内容を把握したツールだから許容した、という前提を忘れないでください。
pscp -hostkey SHA256:xxxx -batch -pw '****' .\task.ps1 USER@HOST:C:/work/task.ps1
plink -ssh -hostkey SHA256:xxxx -batch -pw '****' USER@HOST "powershell -NoProfile -File C:\work\task.ps1"
4. SQL投入編 — コードからT-SQLを流すときの罠 #
スキーマDDLを、sqlcmd を介さず System.Data.SqlClient(.NET標準のSQL Server接続ライブラリ。sqlcmd や SSMS を使わずアプリから直接DDL/DMLを発行できる反面、sqlcmd 専用機能=GO 区切り等は持たない)で、統合認証により直接流したときの3連発です。
⑦ GO 分割が、コメント内の GO で割れる #
- 症状: DDLをバッチ実行すると
コメントの終了マーク '*/' がありませんや'=' 付近に不適切な構文で一部バッチが落ちる。 - 原因:
System.Data.SqlClientはGOを理解しません(GOは sqlcmd/SSMS のバッチ区切りでありT-SQLではない)。自前で分割する必要がありますが、素朴に^\s*GO\s*$で割ると、/* … */コメントの中にインデントで書かれたGO(例示コード等)まで区切ってしまい、コメントが分断されます。 - 対処: 区切るのは行頭(列0)の
GOだけにする。コメント内のGOがインデントされていれば(このコードベースはそうでした)これで除外できます。より厳密には「コメントや文字列リテラルを除いてからGO分割する」のが本来的です。
# 列0のGOのみで分割(インデントされたコメント内GOは無視)
$batches = [regex]::Split($script, '(?im)^GO[ \t]*;?[ \t]*\r?$')
⑧ インデックス付きビューの作成が ARITHABORT で失敗する #
- 症状:
CREATE UNIQUE CLUSTERED INDEX(インデックス付きビュー化)が…SET options have incorrect settings: 'ARITHABORT'で失敗。 - 原因:
System.Data.SqlClientは既定でARITHABORT OFF。インデックス付きビューの作成には複数のSETオプションがONである必要がある。 - 対処: 接続直後に必要なSETを投入してからDDLを流す。
SET ANSI_NULLS ON; SET ANSI_PADDING ON; SET ANSI_WARNINGS ON;
SET ARITHABORT ON; SET CONCAT_NULL_YIELDS_NULL ON;
SET QUOTED_IDENTIFIER ON; SET NUMERIC_ROUNDABORT OFF;
⑨ 件数取得で 列名 'rows' が無効です #
- 症状: テーブル別件数を
SELECT SUM(p.rows) FROM sys.dm_db_partition_stats p …で取ろうとすると列名 'rows' が無効です(Invalid column name 'rows')で落ちる。 - 原因:
sys.dm_db_partition_stats(DMV=動的管理ビュー。サーバ内部状態を返すシステムビュー)の行数列はrow_countであってrowsではありません。rows列を持つのはsys.partitionsのほう。取り違えていました。 - 対処: 件数集計は
sys.partitionsを使う(これが定番)。
SELECT t.name, ISNULL(SUM(p.rows), 0) AS rows
FROM sys.tables t
LEFT JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0,1)
WHERE t.is_ms_shipped = 0
GROUP BY t.name ORDER BY t.name;
5. 後片付け編 — 地味だが自動化を止める罠 #
⑩ Get-ChildItem -Exclude が何も返さず、後片付けが進まない #
- 症状:
Get-ChildItem 'C:\work' -Exclude 'keep.sql' | Remove-Item -Forceを実行しても1ファイルも消えない(エラーも出ない)。 - 原因: ディレクトリ指定の
-Excludeは、パス末尾に\*を付けるか-Recurseを併用しないと期待通りに列挙されず空を返すことがある(PowerShellの既知の癖)。Remove-Itemに渡る入力が空なので、何も起きない。 - 対処: 横着せず、対象を明示列挙して
Remove-Item -LiteralPath -Force。
foreach ($f in 'a.exe','b.iso','c.ps1') {
Remove-Item -LiteralPath (Join-Path 'C:\work' $f) -Force -ErrorAction SilentlyContinue
}
まとめ — ハマりどころは「賢さ」ではなく「継ぎ目」に出る #
並べてみると、AIエージェント自体が間違えた箇所はほとんどありません。詰まったのはほぼ全部が継ぎ目でした。
- AIとシェルの継ぎ目(cmdのクォート解釈、ホスト鍵プロンプトの入力経路)
- シェルとOSの継ぎ目(CP932、BOM)
- コードとツールの継ぎ目(SqlClientが
GOを知らない、DMVの列名、ARITHABORTの既定値)
裏を返せば、継ぎ目を標準化するほど自動化は安定します(むろん別の継ぎ目が残ることはあります)。本作業での実用解は次の3点に集約されました。
- 接続は
plink/pscp+ ホスト鍵ピン留め +-batchでハンズフリー化 - 複雑な処理はインラインで書かず、
.ps1を送って-Fileで実行(本作業のRemoteSigned環境では成立。組織のポリシー次第で要確認) - 文字コードは「出力=chcp 65001/入力(スクリプト)=UTF-8 BOM」で切り分け
なお、これらを潰す過程で、AIエージェント側の安全機構がAI自身の操作を2回ブロックする一幕もありました。実行ポリシーを弱めようとした件と、認証情報をファイルに書こうとした件です。これは「ハマりどころ」というよりガバナンスの話なので、番外編その2「AIガードレール編」で扱います。