2025-03-07

Emacsからssh-agentを起動する

ssh-agentというのは基本的にはEmacsが起動する前に実行しておくものだと思います。しかしAndroid版の場合、(Termuxの(非GUIの)Emacsパッケージだったりrootを取って色々したりしない限り)普通はそんなことは不可能です。ダイレクトにEmacsが起動してしまうので。

Android版のEmacsはTermuxと連携させればsshが使用できます。Magitも動作します(なぜかWindowsバリに遅いですが)。でもssh-agentが起動していなければ毎回パスフレーズを入力しなければなりません(設定しているのなら。Trampに限ってはauthinfoに記録させる方法もありますが)。

となると、Emacsからssh-agentを起動するという方法が浮かんできます。

やることは簡単。ssh-agentを起動して出力される環境変数(SSH_AUTH_SOCKSSH_AGENT_PID)をEmacs自体に反映してやれば良いのです。後はssh-addを起動してパスフレーズを入力してもらいます。

ということでcall-process関数を使用して ssh-agent を起動しようとしたのですが、なぜが処理が返ってきません。 (call-process "ssh-agent" nil t) のように書いて評価すると何秒経っても終了しません。C-gで中断することはできます。Windows上で試すとちゃんとすぐに処理が帰ってきます。call-processが必ず帰ってこないわけではありません。 ls のようなものは大丈夫ですし、 ssh-agent -k のようなものもすぐに帰ってきます。

shell-commandもダメでした(&を付けて非同期にするのは試していません)。内部でcall-processを呼んでいるからでしょう。

EShellのeshell-commandはすぐに帰ってきました。とは言えその後 ssh-add するときにキーフレーズを入力する方法が分からず諦めました。

というわけで、非同期的にプロセスを実行するstart-process関数を使って書くことにしました。

(defun my-ssh-setup ()
  "ssh-agentとssh-addを起動します。"
  (interactive)
  (my-ssh-agent-start)
  (my-ssh-add))

(defun my-ssh-call-process (program &optional args body on-exit)
  "PROGRAMを実行して終了するまで待ちます。

ARGSはPROGRAMの引数です。

終了するまでの間にBODYに指定した関数を不定期的に呼び出します。
関数に引き渡される引数はプロセスオブジェクト一つです。

終了した後にON-EXITに指定した関数を呼び出します。
関数に引き渡される引数はPROGRAMの終了ステータスコードです。

BODYやON-EXITを呼び出す際のカレントバッファはプロセスの出力を保持するバッ
ファです。それぞれのタイミングでPROGRAMが出力したテキストを調べることが
出来ます。"
  (let ((buffer (get-buffer-create "*ssh-agent*")))
    (with-current-buffer buffer
      (erase-buffer)
      (let ((process (apply #'start-process
                            program buffer
                            program args)))
        (set-process-sentinel
         process
         (lambda (process _event)
           (when (memq (process-status process)
                       '(exit signal))
             (process-put process :my-ssh-finished t))))
        (while (not (process-get process :my-ssh-finished))
          (when (input-pending-p)
            (discard-input)
            ;; Androidではdiscard-inputしてもread-eventで少し待たない
            ;; とsit-forが即時リターンしてsentinelが呼び出されないこと
            ;; がある。
            (read-event nil nil 0.2))
          (sit-for 0.1) ;; ここでsentinelが呼び出されるかも
          (when body
            (funcall body process)))
        (when on-exit
          (funcall on-exit (process-exit-status process)))
        (process-exit-status process)))))

(defun my-ssh-agent-start ()
  "ssh-agentを開始します。"
  (interactive)
  (if (getenv "SSH_AGENT_PID")
      (message "SSH_AGENT_PID is already set")
    (my-ssh-call-process
     "ssh-agent" nil nil
     (lambda (status)
       (unless (zerop status)
         (error "ssh-agent failed"))
       (let ((vars
              (mapcar
               (lambda (var)
                 (goto-char (point-min))
                 ;; Error if not found
                 (re-search-forward (concat var "=\\([-_a-zA-Z0-9./]+\\)"))
                 (list var (match-string 1)))
               '("SSH_AUTH_SOCK" "SSH_AGENT_PID"))))
         (dolist (vv vars) (apply #'setenv vv))
         (message "ssh-agent vars=%s" vars))))))

(defun my-ssh-agent-stop ()
  "ssh-agentを停止します。"
  (interactive)
  (call-process "ssh-agent" nil nil nil "-k")
  (setenv "SSH_AGENT_PID"))

(defun my-ssh-add ()
  "ssh-addを起動してパスフレーズを入力・記録します。"
  (interactive)
  (let ((read-pos nil))
    (my-ssh-call-process
     "ssh-add" nil
     (lambda (process)
       (save-excursion
         (goto-char (or read-pos (point-min)))
         ;; Enter passphrase ... が現れたらパスフレーズを入力させてそ
         ;; れをプロセスへ送信する。
         (when (re-search-forward "^Enter .*: *" nil t)
           (let ((prompt (match-string 0)))
             (process-send-string
              process
              (concat (read-passwd prompt) "\n")))
           (setq read-pos (point))))))))


;; 以下おまけ

(defun my-ssh-ensure ()
  "環境変数SSH_AGENT_PIDが設定されていなければ`my-ssh-setup'を呼び出します。
設定されているなら何もしません。"
  (interactive)
  (unless (getenv "SSH_AGENT_PID")
    (my-ssh-setup)))

(defun my-ssh-init ()
  "適当なタイミングでパスフレーズを入力するよう準備します。
これをinit.elから呼び出しておけば、MagitやVC、Trampのパスフレーズが必要
そうなタイミングで自動的に`my-ssh-ensure'が呼び出されます。
あまり細かい条件は見ていないので必要に応じて修正してください。"
  ;; Magitのpushまたはpull
  (defun my-ssh-agent-init-on-magit-start-git (_input &rest args)
    (with-demoted-errors "Error my-ssh-agent: %s"
      (when (member (flatten-tree args) '("push" "pull"))
        (my-ssh-ensure))))
  (advice-add #'magit-start-git :before
              #'my-ssh-agent-init-on-magit-start-git)

  ;; VCのgitのpushまたはpull
  (defun my-ssh-agent-init-on-vc-git--pushpull (&rest _)
    (with-demoted-errors "Error my-ssh-agent: %s"
      (my-ssh-ensure)))
  (advice-add 'vc-git--pushpull :before
              #'my-ssh-agent-init-on-vc-git--pushpull)

  ;; Trampのssh等
  (defun my-ssh-agent-init-on-tramp-ssh-controlmaster-options (vec)
    (with-demoted-errors "Error my-ssh-agent: %s"
      (when (seq-find (lambda (hop)
                        (member (tramp-file-name-method hop)
                                '("ssh" "sshx" "scp" "scpx")))
                      (tramp-compute-multi-hops vec))
        (my-ssh-ensure))))
  (advice-add #'tramp-ssh-controlmaster-options :before
              #'my-ssh-agent-init-on-tramp-ssh-controlmaster-options))

少し試した限りうまく行っているようです。

他の方法について。

非同期プロセスの処理を書くのは面倒なのでeshellをうまく利用して何とかならないかなと思ったのですが、私にはあまりうまく出来ませんでした。ssh-agentが出力するスクリプトをそのまま評価できれば良かったのですが。環境変数(process-environment変数)はデフォルトだとバッファローカルになりますがeshell-modify-global-environment変数を変えれば行けそう。最終的にはssh-addの入力部分をどうしたらよいのか分かりませんでした。無理矢理実現するなら大人しく上のように書いてしまった方が良いのかなと。

Magitだけを通すならおそらく magit-process-password-prompt-regexpsmagit-process-find-password-functions をいじれば(そもそもssh-agentを起動しなくても)何とかなりそうな気もします。どこかにパスフレーズを保存しておく方法に限らず、Emacsがssh-agentのようにパスフレーズを一時的に覚えておくことも出来るかもしれません。とは言えsshを使うのがMagitに限らないのであれば、大人しくssh-agentを起動してしまった方が良いでしょう。

AndroidではOSが無駄なプロセスを自動的に削除してしまうことがあるらしいので、そこは注意が必要かもしれません。