はじめに
Claude Code に、SSH で別の Linux サーバ(検証用の VM)のコマンドを実行させていました。
その中で、人が打てば動くはずのコマンドが、AI 経由だと失敗することがありました。systemctl edit です。
Cannot edit units if not on a tty.
原因は単純で、AI が SSH で送ったコマンドには、端末(TTY)が割り当てられていなかったからです。
ただ、困ったのは失敗したことよりも、そのあと AI が自分で回避策を選んで、そのまま進めたことでした。
この記事は、そのときのログと、そこから決めたルールの記録です。
環境
| AI | Claude Code(Windows の PC で動かす) |
| 接続先 | 検証用 VM 2台(AlmaLinux 8.10 / AlmaLinux 9.7) |
| 接続のしかた | 小さなシェルスクリプトを通して ssh <接続先> '<コマンド>' の形で実行。コマンド・出力・終了コードを毎回ファイルに残す |
| ssh のオプション |
BatchMode=yes、StrictHostKeyChecking=accept-new、ConnectTimeout=10
|
何が起きたか
テスト用のサービスに、systemctl edit で設定(MemoryMax= と Restart=)を足して、その効き方を確かめるつもりでした。
systemctl edit はエディタを開くコマンドなので、エディタの代わりに「決まった内容をファイルに書くだけ」の小さなスクリプトを SYSTEMD_EDITOR に渡しています。
AlmaLinux 8.10 でのログです(出力の一部をそのまま抜き出しています)。
--- SYSTEMD_EDITOR を差し替えて systemctl edit(対話エディタの代わり) ---
Cannot edit units if not on a tty.
edit の終了コード: 1
できたドロップイン:
[B] edit 直後(daemon-reload は自分では打っていない)
Restart=no
MainPID=2185
MemoryMax=infinity
systemctl edit は終了コード 1 で失敗し、ドロップイン(設定を足すファイル)は作られていません。その後の [B] は、設定が入っていない状態(Restart=no、MemoryMax=infinity)のままです。
エディタを差し替えても、systemctl edit は端末が無いと始まりませんでした。 エディタが対話的かどうかではなく、端末があるかどうかを見ているようです。
もう1つ気づいたことがあります。このログ全体の終了コードは 0 でした。 送ったコマンドの中で最後に動いたものが成功していたからです。途中で edit の終了コード を表示させていなければ、失敗に気づけませんでした。
なぜ端末が無いのか
ssh にコマンドを渡して実行すると、既定では端末が割り当てられません。
man ssh_config の RequestTTY は、既定の auto について「ログインのセッションを開くときに端末を求める」と説明しています。
人が ssh でログインしてから打つときは、ログインのセッションなので端末があります。
一方、AI は ssh <接続先> '<コマンド>' の形でコマンドを送ります。これはログインのセッションではないので、端末がありません。
人の手順書どおりに AI に打たせても、同じようには動かないことがある、ということです。
39秒後、AI は自分で回避した
失敗から39秒後、AI は script(1) で疑似端末を用意して、やり直していました。
script -qec "SYSTEMD_EDITOR=/usr/local/bin/zzedit systemctl edit $U" /dev/null
今度は成功しています(同じく AlmaLinux 8.10 のログの一部)。
--- SYSTEMD_EDITOR を差し替え、script(1) で疑似端末を与えて systemctl edit ---
script 経由の edit の終了コード: 0
できたドロップイン:
合計 4
-rw-r--r--. 1 root root 39 9月 24 00:11 override.conf
| [Service]
| MemoryMax=64M
| Restart=always
AlmaLinux 9.7 でも、同じ方法で終了コード 0 になり、設定が入りました。
結果は正しかったのですが、この回避策を使ってよいかは、誰も判断していませんでした。
私の CLAUDE.md には、端末が要るコマンドについての決まりが無かったからです。AI はその場で考えて、動く方法を選びました。
今回はテスト用のサービスで、戻す手順も決めてあったので問題はありませんでした。
ただ、AI にサーバを触らせる範囲が広がると、「手順どおりに動かなかったときに、AI がその場で選んだ別の方法」を、人が見ないまま進むことになります。
先に決めておくこと
端末が要るコマンドについて、どちらにするかを先に決めて、CLAUDE.md に書いておきます。
A. 端末が要るコマンドは使わせない
たとえば systemctl edit の代わりに、ドロップインのファイルを直接書いて systemctl daemon-reload します。
⚠️ これはサービスの設定を変える操作です。 設定によっては、稼働中のサービスにすぐ効きます(今回の検証では、
MemoryMax=とRestart=がdaemon-reloadだけで稼働中のサービスに効きました)。本番のサービスに当てる前に、壊してよい環境で試してください。
実行の前に、変える設定の今の値を記録しておきます。
# 変更前の値を読む(読み取りだけ)
systemctl show <ユニット名>.service -p <設定名>
# すでにあるドロップインを確かめる(読み取りだけ)
ls -l /etc/systemd/system/<ユニット名>.service.d/ 2>/dev/null
すでにあるドロップインのファイルは、上書きしないでください。 中にほかの設定が入っていることがあります。下の例では、既存のものとぶつからない名前(<名前>.conf)で新しく作ります。
変更のコマンドです。
# <ユニット名>.service に設定を足す
sudo mkdir -p /etc/systemd/system/<ユニット名>.service.d
sudo tee /etc/systemd/system/<ユニット名>.service.d/<名前>.conf >/dev/null <<'EOF'
[Service]
<設定名>=<値>
EOF
sudo systemctl daemon-reload
実行したあとは、同じ systemctl show で値が変わったことを確かめます。
戻すときは、作った <名前>.conf だけを消して sudo systemctl daemon-reload し、同じ systemctl show で変更前の値に戻ったことを確かめます。
この書き方そのものは、今回の検証では試していません。 ドロップインの仕組みは man systemd.unit に書かれています。今回の検証で確かめたのは、ドロップインを消して systemctl daemon-reload するだけで、設定が元に戻ることです。
B. 疑似端末を使ってよいと決める
script -qec "<コマンド>" /dev/null のように、疑似端末を用意して実行することを認めます。今回 AI が選んだのは、この方法です。
認めるなら、使ってよい場面を限って書きます(たとえば「systemctl edit だけ」)。
CLAUDE.md に書く例
- リモートのコマンドには端末(TTY)が無い。端末が要るコマンドは失敗する
- 端末が要るコマンドは <使わない / `script -qec` で疑似端末を与えてよいのは <コマンド名> だけ>
- 手順どおりに動かなかったときは、別の方法を試す前に止まって人間に知らせる
3行目がいちばん大事だと思っています。端末に限らず、「動かなかったときに AI が自分で別の道を探す」こと自体を、先に止めておくためです。
範囲と限界
-
端末が無くて失敗したのを確かめたのは、AlmaLinux 8.10 の
systemctl editだけです。9.7 では最初からscript(1)を使ったので、端末なしの失敗は見ていません。 - ほかの対話を前提にしたコマンドは、確かめていません。 同じことが起きうる、という以上のことは言えません。
-
A の書き方(ドロップインを直接書く)は、今回は試していません。 確かめたのは、ドロップインを消して
daemon-reloadすると元に戻ることです。 - 接続は検証用 VM に
rootで行いました(検証用なので許可していました)。本番サーバでは、同じにしないでください。 - 端末が割り当てられない理由は、
man ssh_configのRequestTTYの説明にもとづいています。
お気づきの点があればコメントで教えてください。
このルールのもとになったもの
この記事は、AI に SSH でサーバを触らせる前に決めている10項目のうちの1つです。
残りの項目(接続した先が本当にその機械かの確かめ方、パスワードを入力させない理由、終了コード 0 を成功と読ませないこと など)と、使っているスクリプト・チェックリスト・実際のログを、note にまとめています。