VS Code Remote SSHでは、統合ターミナル内で「code」というコマンドが使用でき、「code .」「code /path/to/file」のようにするとディレクトリやファイルをVS Codeで開くことができます。ファイルなら既存ウインドウで開いてくれたりと、良い感じに判定してくれます。
この便利なcodeコマンドと同等のものを、VS Code Remote SSHの統合ターミナルではなく、普通にSSHしたシェルの中でも使えるようにしよう、というのがこの記事の趣旨です。
codeコマンドの仕組み
前提として、このcodeコマンドがだいたいどんな仕組みで動いているか見ておきましょう。
まず、このcodeというのは普通のVS Codeの起動コマンドとは別物です。普通のVS Codeは(Linuxなら)/usr/bin/codeにあり、統合ターミナルのcodeコマンドは~/.vscode-server/cli/servers/Stable-xxxxxx/server/bin/remote-cli/codeみたいな感じの場所にあります。
で、このcodeコマンドにフォルダやファイルを指定して実行すると、VSCODE_IPC_HOOK_CLIという環境変数に設定されているファイルパスにあるUnixドメインソケットに対してフォルダやファイルの情報が通知されます。これが、すでに起動しているVS Codeのインスタンスに通知されます。
これによって、リモート先のターミナル内でコマンドを打っただけなのに、ローカル側で新しいウインドウが開いたりする、というちょっと面白い挙動になるわけです。
リモートポートフォワード
前述の通り、目的を達成するには「SSH先のターミナルでコマンドを打つことでローカル側に情報を伝達する」ことが必要になります。
VS Codeに頼らずにこれを達成する方法として、リモートポートフォワード(ポート転送)があります。
例えば、リモート側でlocalhost:3000にアクセスするとローカル側のlocalhost:3000につながるようになっているとしたら、リモート側でcodeというコマンドを打ったときにリモートのlocalhost:3000にそれが送信されて、ローカル側でそのファイルやフォルダの情報を受け取り、ローカル側でVS Code Remote SSHを実行する、ということが実現できそうです。
毎回ポートフォワードを打つのは面倒ですが、.ssh/configにRemoteForwardを書いておけば普通にsshするだけで勝手にポート転送してくれます。
Unixドメインソケットに転送
SSHを常に最大1つだけしか開かないといった単純なケースではTCP転送でも十分そうです。しかし、より一般的な状況を考えると、単純にTCPポート転送するだけでは不十分なことがすぐにわかります。例えば、SSHを1つ開いてポート転送した状態でもう一つ開くと、同じTCPポートをlistenすることはできないのでポート転送は失敗します。この状態で最初に開いたほうのSSHを閉じると、もうポート転送はなくなります。これではローカル側に情報を伝える手段がありません。
ついでに言えば、通常のTCPポートを使うのはセキュリティ的な問題もあります。ポートは全ユーザー共用なので、認証を付けなければ、SSHサーバーの他のユーザーが勝手に自分のPCで新しいVS Codeウィンドウを開くことができてしまう状況になります。また、使おうとしたポートが誰かに使われていたり、逆に自分がポートを使っているせいで他のユーザーが不便になったり、ということもあり得ます。
ところで、OpenSSH 6.7(2014年リリース)からは、ポート転送でUnixドメインソケットがサポートされました。
Unixドメインソケットはファイルの一種なので、パーミッションがなければ他のユーザーからは見えず、大量に転送しても誰かに迷惑がかかることはありません。こっちを使うことにします。
.ssh/configでの設定としては、例えば
RemoteForward /home/%r/ssh-sock 127.0.0.1:4280
のように書いておきます(localhostでもいいですが、IPv4/IPv6について考えるのが面倒なため)。Unixドメインソケットのパス指定では残念ながら$HOMEなどの環境変数は使えませんが、リモート側ユーザー名は%rとして参照できるので、多少の柔軟性は確保できます。
詳細は後述しますが、この127.0.0.1:4280で全てのリモート先を一括して担当することにするので、この RemoteForwardはすべての(codeコマンドを使いたい)サーバーに共通して設定しておきます。ただしもちろんホームディレクトリが/home/%rでないサーバーについては分ける必要があります。
.bashrcや.ssh/rcで即移動
Unixドメインソケットにしても、既存ファイルが存在すれば転送が失敗するというのはTCPポートと変わりません。ちなみにStreamLocalBindUnlinkがサーバー側で有効なら既存ファイルがあっても上書きされますが、デフォルトが無効なので大抵のサーバーではそもそも使えないですし、上書きされるとこんどは古いほうのセッションでは使えなくなります。
そこで、接続した瞬間にUnixドメインソケットを別の場所に移動してしまう、というのを思いつきます。これでもポート転送は維持されます。
SSH接続したタイミングで実行されるものには色々ありますが、今回は.bashrcや.zshenvを使うことにしましょう。
他の選択肢について説明しておきます。
- .profile…普通の対話ログインじゃないと大抵実行されないので論外です。
- .ssh/rc…ほとんどすべての場合に実行されるものの、サブシステムが起動される場合に実行されません(ssh my-server -s sftpなど)(参照)。ssh接続時以外には決して実行されることがないというのは有利です。サーバー側のPermitUserRCというオプションも必要ですが大抵は有効になっているようです。
- authorized_keysの鍵にcommandで設定する…【Android】proot無しのTermuxでVS Code Remote SSHを使う - turgenev’s blogでも扱った手法です。サブシステムの場合でも実行されますが、鍵ごと分けるなどしない限り通常ログインでmotdが出なくなるという問題があります。あと設定をミスるとログインできなくなりやすいのもデメリット。
- RemoteCommand…コンセプトとしては良さげですが、通常のコマンド(ssh my-server command)と一緒に指定することができません。
これらと比べると.bashrcや.zshenvは、デフォルトシェルをbashやzshに設定しなければならないという欠点があります。また、.bashrcは対話モードだと実行されないので注意が必要です(とはいえ普通は.bash_profileや.profileによって結局読み込まれることになっている場合が多い)。
それと、残念ながら、上記のいずれについても、sshで-Nが指定された場合は実行されません。なので、以下のどれかのような対策が必要です。
- -Nはたまにしか使わないので、不具合が出るのは諦める(ポート転送の警告が出たらつなぎ直すなど)
- -Nを使わずsleep infなどで代用する
- ポート転送の設定は別のconfigに書いておき、.ssh/config2とかからは読み込まれないようにして、-Nを使用する場合は-F .ssh/config2を指定する
- ただしIncludeはOpenSSH 7.3以降
- -F .ssh/config2を毎回書くのが面倒なら、"ssh2"みたいな適当なラッパーを作ってもいいかも
- -Nの有無を判定するロジックを自前で実装したラッパーを作って"ssh"コマンドとして使用する
- ただしWindowsはexecveがないのでラッパー分のプロセス数が増える
- あるいは、Match Exec(OpenSSH 6.5以降)で親プロセスのsshのコマンドラインに-Nが含まれているか判定することもできる
- 常駐はしないのでプロセス数は削れるが、親プロセスのルックアップなどの分、数ミリ秒余計にかかる可能性はある
- この方法の場合、ついでに-sの有無もチェックして.ssh/rcを使うほうが楽
- -fNDも含めるとか-o Proxycommand="someprogram -N"は含めないとか考えるとロジックの実装が自明ではない
- とはいえ、個人的には結局一番便利なこの方式を採用した
- サーバー側に何かを常駐させて作成後n秒経った~/ssh-sockは掃除する
- (ControlMasterを使うと何かしら良いことがありそうな気もするが、Windowsで使えないので試していない)
また、-Nと類似の動作である-Wやscp・sftp(サブシステムではなくコマンドのほう)については、ClearAllForwardings yesが暗黙に指定されてポート転送が無効化されるため、問題ありません。ProxyJumpで呼ばれた場合も同様です。
で、.bashrcには以下のように書きます(.ssh/rcならifは要りません)。
if [ -e ~/ssh-sock ]; then
SOCK_PATH=~/.cache/ssh-sock/$(echo $SSH_CONNECTION | sed 's/ /-/g')
mv ~/ssh-sock $SOCK_PATH 2> /dev/null
fi
これで、SSH接続情報と1対1対応するパスにUnixドメインソケットが移動されます。
- 追記訂正: 当初はSSH_CONNECTIONではなくログインシェルのPIDを使っていましたが、親プロセスを何回たどるかなど考えると環境差が大きいため修正しました。
.cache/ssh-sockという場所は適当です。別に/tmpとか$XDG_RUNTIME_DIRに作っても何も悪いことはありません(Unixドメインソケットのパーミッションは600に設定されるので/tmpでも他ユーザーに読まれることはない)。
SSH_CONNECTIONは何度も接続し直せばいずれ同じ値になることもあり、使われなくなったソケットのクリーンアップなども気になるところですが、とりあえず後回しにします。
あとこの記事ではサーバー側はLinux/UNIX想定ですが、Windowsだとしてもまあ似たようなことはできると思います。
これで、SSHセッションの中にいれば、SSH_CONNECTIONからソケットのパスを取得してローカル側に情報を送れる状態になりました。ここまでの内容は、VS Codeに限らず、任意のコマンドに応用できます。
codeコマンドを用意する
次にcodeコマンドを用意します。
ここでまず、本物のVS Code Remoteのcodeを使うかどうか?という問題があります。
codeコマンドが$VSCODE_IPC_HOOK_CLIに送りつける内容は、「.」などのパスが絶対パスに解決された(ただしシンボリックリンクは解決しない)ファイルパスを含んだ、以下のような単純なHTTPリクエストです。
POST / HTTP/1.1
content-type: application/json
accept: application/json
Host: localhost
Connection: keep-alive
Transfer-Encoding: chunked
dc
{"type":"open","fileURIs":["file:///path/to/some/file"],"folderURIs":["file:///path/to/another/folder"],"diffMode":false,"mergeMode":false,"addMode":false,"gotoLineMode":false,"forceReuseWindow":false,"forceNewWindow":false}
0
「dc」のところは、呼び出すファイルによって変わるものの、規則性はよくわからないので、何かしらのハッシュのようなものではないかと思われます。
これを解析するのは容易ですし、すでにポート転送でUnixドメインソケットも用意しているので、それをそのままVSCODE_IPC_HOOK_CLIに設定してしまえば綺麗にいけそうな気がします。
しかし、SSH先が1つだけならいいですが、複数あるとなると、ローカル側で、どのサーバーのcodeからリクエストが来たのか区別できなくなります。サーバー側で一旦上記の情報を処理してからサーバー名を付加してやればいいのですが、若干面倒です。
結局、codeコマンドがやっているのは単純なパスの解決だけ(多分)なので、それだったら自前で作ったほうが楽です。
そこで、以下のようなシェルスクリプトを作ります。
#!/bin/sh
resolve_path () {
arg="$1"
case "$arg" in
/*)
true;;
*)
arg=$PWD/$arg;;
esac
realpath -s -m "$arg"
#python -c "import sys;import os; print(os.path.normpath(os.path.abspath(sys.argv[1])))" "$arg"
}
(printf '%s' $MY_VSCODE_REMOTE
for arg; do
abspath=$(resolve_path "$arg")
if [ -d "$abspath" ]; then
printf '\0%s/' "$abspath"
else
printf '\0%s' "$abspath"
fi
done ) | nc -U -N $MY_VSCODE_IPC_HOOK_CLI
プロトコルとしては単純で、最初にsshでのサーバー名、2つ目以降に開くファイルのパスのリスト、をヌル区切りで送信しているだけです。フォルダの場合は末尾に/を付けています。
- 追記訂正: 公開当初はこちらのシェルスクリプトでやや複雑な場合分けをしていましたが、そのロジックを全てローカル側に任せてプロトコルを単純化しました。
resolve_pathでパスの解決(「.」「..」を含まない形にする)をやっています。realpathやreadlinkなどでパス解決をするとシンボリックリンクまで解決されてしまいますが、これは望ましくない(本来のcodeもそれはしない)です。そこで、realpath -sを使うとシンボリックリンクを維持したまま解決できます。が、相対パスを指定した場合、カレントディレクトリの部分のシンボリックリンクは解決されてしまうようなので、$PWDとつなげて絶対パスに直してから実行します。さらに-mを付けると存在しないパスでもうまく処理してくれます(ただし古いrealpathだと無いようです)(そのときは付けなくていいかも)。また、-sや-mはGNU拡張でmacOSでは使えないので、そのときは次の行のようにpythonとかでやりましょう。
cdして$PWDを見るのだと存在しないパスの場合にうまく行きません。
出力はnc -UでUnixドメインソケットにつないでいます。BSD netcatではなくGNU netcatのときは-Nでなく-cにします。nc -U -N のかわりにsocat - UNIX-CONNECT:でもいいです。
MY_VSCODEで始まる環境変数については次節でやります。
.profileで環境変数を設定
さきほどのcodeコマンドを使えるようにするため、.profileを編集します。
さっきは.bashrcを使ったのに、こんどは.profileも使うの?という感じですが、.Unixドメインソケットを別パスに退避させるのは(将来の接続に支障がないようにするため)どんなときも必ずやらなければいけないのに対して、codeコマンドは対話シェル以外では使わないので.profileで十分です。また、.bashrcではなく.ssh/rcを使うことにした場合、.ssh/rcだと環境変数が設定できないという問題もあります。
具体的には、最後のほうに以下の内容を追記します。
if [ -z "$MY_VSCODE_REMOTE" ] && [ -n "$SSH_CONNECTION" ]; then
PATH=/path/to/dir:$PATH
export MY_VSCODE_REMOTE=my-server
if [ -n "$SSH_TTY" ]; then
export MY_VSCODE_IPC_HOOK_CLI=~/.cache/ssh-sock/$(echo $SSH_CONNECTION | sed 's/ /-/g')
fi
fi
/path/to/dirは先ほどのcodeスクリプトが置いてあるディレクトリで、my-serverのところはクライアント側から見たときのホストの名前(この場合はssh my-serverで接続していることになる)です。
MY_VSCODE_REMOTEを見ることで、繰り返しPATHが設定されることを防止します。
VS Codeの統合ターミナルではcodeコマンドが2つあることになりますが、本物のcodeのほうがPATH内で最優先されているので問題ありません。
VS CodeでsshしたときはMY_VSCODE_IPC_HOOK_CLIは要らないので、SSH_TTYで分岐させています。
ローカル側 - codeコマンドの仕様
これで127.0.0.1:4280にリモート先とフォルダの情報が送られてくるところまでできたのであとはローカル側だけです。
まず、ローカル側のcodeコマンド(普通のVS Code)でリモート先を開くためのコマンドラインを把握する必要があります。大きく分けて、--remote ssh-remote+my-serverみたいなのを付ける方法と、--file-uriや--folder-uriとしてvscode-remote://ssh-remote+my-server/path/to/dirみたいなのを渡す方法の2種類があります。Remote Development Tips and Tricksとか、関連issueの`code --remote` cannot open files without filename extensions · Issue #5083 · microsoft/vscode-remote-release · GitHubあたりを読むとこれらのメリット・デメリットがわかるんですが、コーナーケースだらけでなかなか面倒です。以下のような感じです。
- --file-uriや--folder-uriは複数回指定できない。--remoteと一緒に指定することもできない。
- --remoteの場合、ファイル名にピリオドが含まれるかどうかでフォルダかファイルかが判別される。
- --gotoを付けると、強制的にファイルとして開く動作になる。
- 末尾に/を付けると、(--gotoが付いている場合でさえ)強制的にフォルダとして開く動作になる。
- 末尾に/が付いているフォルダを開いた場合、付いていないフォルダと別扱いになってしまう(開いているファイルの情報が共有されないなど)(バグ?)
- しかし、幸いにも、複数フォルダを開く場合(この際は、それらのフォルダをまとめた一時的なワークスペースが作成される)には、これは影響しない?
これらをまとめて、とりあえず本来のcodeコマンドと動作を揃えられそうな方法としては、以下のようになります。
- フォルダが1つも指定されていないまたは2つ以上指定されている場合は、--gotoを付けてすべて開き、フォルダ末尾には/を付けておく
- フォルダが1つだけ指定されている場合は、まずそのフォルダだけを--folder-uriで開いた後、ファイルが1つでも指定されていれば--gotoを付けてそれらをすべて開く
本来のcodeコマンドが引数無しで実行された場合は、フォルダ・ファイルを指定せずにリモート先を開く動作になりますが、これにも対応できます。
ローカル側 - サーバーの実装
では、codeコマンドを実行するサーバー側を作ります。
TCPポートのリッスンと初歩的な文字列操作と外部コマンドの実行だけなのでなんでもいいのですが、とりあえずPythonにしました。
#!/usr/bin/env python3
import socket
import subprocess
def handle_conn(conn):
data = b""
while True:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
lines = data.decode("utf-8").split('\u0000')
print(lines)
if lines[0] == "" :
return
server = lines[0]
files = lines[1:]
dirs = [x for x in files if x.endswith("/")]
if len(dirs) == 1:
subprocess.Popen([
"code",
"--folder-uri",
f"vscode-remote://ssh-remote+{server}{dirs[0]}"
])
files = [x for x in files if not x.endswith("/")]
if not files:
return
cmd = [
"code",
"--remote",
f"ssh-remote+{server}",
"--goto",
*files,
]
subprocess.Popen(cmd)
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("localhost", 4280))
s.listen()
while True:
conn, _ = s.accept()
with conn:
handle_conn(conn)
if __name__ == "__main__":
main()
基本的には全ての引数を--gotoで実行するものの、フォルダが1つだけある場合は、それを引数から除外し、あらかじめ--folder-uriで起動する、という動作です。
あと、WindowsだとVS Code本体の「Code.exe」にはパスが通っておらず、上記のコードはそのままでは失敗します。コマンドプロンプトなどでcodeと打つとちゃんと起動するのは、「Code.exe」と同じフォルダの「bin」にある「code.cmd」というバッチファイルです。
そこで、上記のPythonを起動する前にあらかじめPATH環境変数にCode.exeのあるパス(ハードコードしてもいいし、code.cmdからたどってもいい)を設定しておきます。(この部分は簡単なので省略)
上記の「code」を「code.cmd」と書き換えれば十分と思うかもしれませんが、cmdの仕様は終わっているので「%PATH%」とかを部分文字列として含むファイルが開けなくなります。
これで完成です。codeコマンドを打ってみてちゃんとローカル側でVS Codeが開くことを確認しましょう。
Unixドメインソケットの掃除
今回のここまでの設定ではUnixドメインソケットを作ったら置きっぱなしなので、接続が終了して使われなくなったものも残ります。
そうなるとSSH_CONNECTIONが再び同じ値になった場合にどうなるのか気になりますが、mvで上書きされるので、基本的には問題ないはずです。
とはいえ際限なく増え続けるのもあれなのでクリーンアップの方法を考えておきましょう。
自前でやるにあたっては、いくつか方法はあります。まずシェルの終了をtrapして消すようなやり方が考えられますが、異常終了を考えるとやや確実性には欠けます。あるいは、ソケット名に対応するSSH接続が実際に存在するか照合するというのもありますが、ユーザー権限でやるのは面倒そうです。
そう考えると、実際にUnixドメインソケットに書き込んでエラーが返ってくるかどうか見るのが多分一番確実です。
さっきのpythonプログラムは0バイトの書き込みも問題なく無視するようになっているので、
printf "" | nc -U -N $file
printf "" | socat - UNIX-CONNECT:$file
あたりを実行してエラーになったら消す、でできます。
実行タイミングとしては、SSH接続のたびにバックグラウンドで動かすとか、あるいは常駐させてもいいと思います。
たとえばSSH接続のたびにやるならさっきの.profileのif [ -n "$SSH_TTY" ]; then...の内側の最後に以下のような内容を追加します。
(cd ~/.cache/ssh-sock/ && (
(ls *-*) && for f in *-*; do
if ! printf "" | nc -w 2 -U -N $f; then
rm $f
fi
done
) > /dev/null 2>&1 &)
fi
rmがあるので、cd成功時のみ実行するようにしています。たまに、成功でもrefusedでもなく応答がないソケットが発生する(sshdの子プロセスがハングしている?)ので、-wでタイムアウトを設定しています。タイムアウトの場合はエラーコード0(成功扱い)になるのでとりあえずスキップされます。VS Codeの起動(初回接続)時などに.profileの読み込みに数秒以上かかるとうまく.profileが反映されないことがあるようですが、この設定ではSSH_TTYを見ているため対話シェル以外なら実行されないので安全です。
サーバー名の動的な変更
基本的には、どのマシンを使っていても、同じSSHサーバーに接続するときのサーバー名は同じであることが多いと思います。この記事の方法ではそれを想定して、サーバー側でサーバー名を固定しました。しかし、デスクトップPCとノートPCで設定が違うとか、同じノートPCでも家ではプロキシ経由で、会社では直接接続しているといった場合もあるでしょう。
ただ前提として、まず前者はちゃんとやれば問題なく揃えられるはずで、後者についてもこの状況自体はかなり不便(VS Codeで別扱いになるなど)なので、これを解決することを優先したほうがいいです。前述のMatch execや適切なProxycommandなどを使えば自分のネットワーク環境を判別してつなぎ方を変えることが可能です。
その上で、どうしても無理な場合の解決策を一応考えてみました。要は、ssh my-serverとか打っているときのmy-serverの部分をリモート側に伝えられればいいわけです。Unixドメインソケットの転送先パスには%n(sshコマンドで指定されたホスト名、先ほどの例ならmy-server)も指定できるので、これを付けて/home/%r/ssh-sock-%nのようなパスに転送します。それで、リモート側ではssh-sock-で始まるファイルを探し、%nのところを取得してそれをMY_VSCODE_REMOTEに指定すればいいです。(環境変数が使えない).ssh/rcを使う場合は、移動のついでに、~/.cache/ssh-sock/$(echo $SSH_CONNECTION | sed 's/ /-/g').txtとかに%nの部分を書き込んでおけば.profile側に情報を伝えることができます。
SetEnvで環境変数を設定するのも一見使えそうですが、大抵のサーバーではLANGやLC_*など一部しか許可されないうえ、%nなども使えないので微妙です。
ホームディレクトリが共有されている
前項と似たような状況として、複数のSSHサーバーでホームディレクトリが共用されている場合というのがあります。
この場合、sshサーバー名の動的な変更という点はホスト名などで容易に区別できるので大丈夫なのですが、Unixドメインソケットをマシン別にする必要があります。そうでないと別サーバーで作られたUnixドメインソケットが接続失敗としてクリーンアップされてしまいます。
つまり、.cache/ssh-sock/ではなく.cache/ssh-sock/$(hostname)/のようなパスを使うようにしてください。あるいは/tmpや$XDG_RUNTIME_DIRでも大丈夫です。
(追記)セキュリティリスクについて
今回の記事の設定は、リモート側の入力に応じてローカル側でコマンドを実行するものです。これはセキュリティリスクになり得ます。
GTFOBinsなどを参考にリスクを検討し、適切に文字列をエスケープしてください。
また、今回はリモート側が送ってくるssh接続先情報を信用する形にしていますが、リモート側が侵害されれば、異なるssh先に対するコマンドも実行できてしまいます。これを改善するには、ローカル側のポートをサーバーごとに別々に立てるとか(特にLinuxならローカル側でもUnixドメインソケットを使って%nで自動的に振り分ける運用が可能)、あるいは認証を付けてssh先ごとに接続用アカウントを用意するような実装が必要になります。
(追記)VS Codeの統合ターミナル内でもMY_VSCODE_IPC_HOOK_CLIを使いたい
VS Codeの統合ターミナルだと、codeコマンドについてはもちろん本来のものが使えるからいいのですが、codeコマンド以外のことをMY_VSCODE_IPC_HOOK_CLIでやろうとするとうまくいきません。
直接的には、さっきSSH_TTYを見て分岐させていたので非対話的SSHで始まるVS Code Remote SSHでMY_VSCODE_IPC_HOOK_CLIがセットされないという問題がありますが、それを解決したとしてももっと根本的な問題があります。
それは、新たなウインドウでVS Code Remote SSHに接続した場合でも、既存のVS Code Remote SSHのインスタンスがあれば、そちらを親とするような形で統合ターミナルが起動してしまうということです。そのせいで正しいSSH接続の情報が取れなくなります。
具体的には、まず、VS Code Remote SSHが一切起動していない状態("Kill VS Code Server …"の実行直後など)からVS Code Remote SSHで接続したとします。このときは、いま新たに作成されたssh接続を親としてcode-serverが実行され、統合ターミナルにもそのSSH_CONNECTIONが反映されます。さらに、この状態で別のフォルダなどをVS Code Remote SSHで開くと、別のssh接続が作成されますが、統合ターミナルは先ほどのcode-serverに紐づくため、やはり先ほどと同じ(今作成されたものとは別の)SSH_CONNECTIONが割り当てられます。とはいえこの段階では古い方の接続も生きているので問題ありません。
ここで、最初に開いたウインドウを閉じる(再読み込み含む)とします。すると最初のssh接続は終了するものの、そこから開いていたcode-serverのプロセスはsshdから独立したプロセスツリーとして存続します。つまり、さっき2番目に開いたウインドウや、今後新たに開くウインドウでは、(このcode-serverに由来する)すでに終了してしまったSSH_CONNECTIONの値が統合ターミナルに設定されることになります。これではUnixソケットが使えません。
一方で、各ウインドウの起動時に作成されたssh接続それ自体は、ウインドウを開いている間は生き続けています。したがって、今やりたいことは、最初に起動したcode-serverに「取り込まれて」しまった統合ターミナルの中から、どうにかして、この「自分の本当の親」であるssh接続を見つけることです。
とはいえ普通にVS Code関連の環境変数やpsで取れるコマンドラインを見る限りではそんなに確実に使える情報は無さそうでした。
そんな中で唯一使えるのが、ssh-agentに使われるSSH_AUTH_SOCKです。
この変数は実際のssh接続に紐づいてしか意味をなさないため、VS Codeでも特別な取り扱いがされていて、具体的には、VS Code Remote SSHのウインドウを開いたときのSSH_AUTH_SOCKに対するシンボリックリンクが作成され、それが統合ターミナルにSSH_AUTH_SOCKとして設定されます。
従って、VS Code Remote SSHのウインドウ起動時に渡されるSSH_AUTH_SOCKから元のssh接続情報を特定できればいいことになります。例えば、.ssh/rcとか.bashrc(.profileは読み込みタイミングが遅いので不可)で、$SSH_AUTH_SOCK.txtみたいな名前のファイルにSSH_CONNECTIONの情報を書き込んでおけばできます。
ssh-agentを使っていない場合、VS Code側でのSSH_AUTH_SOCKの処理は変わりませんが、自前でSSH_AUTH_SOCKを設定する必要があります。従って.bashrcや.zshenv、あるいはauthorized_keysを使うことになります。例えば.bashrcだったら、最初の方(非対話モードの条件分岐などより上)に、
if [ -z "$MY_VSCODE_REMOTE" ] && [ -n "$SSH_CONNECTION" ] && [ -z "$SSH_TTY" ]; then
SOCKTEMP=$(mktemp -d ~/.cache/ssh-sock/s.XXXXXX)
if [ -n "$SSH_AUTH_SOCK" ]; then
ln -s "$SSH_AUTH_SOCK" "$SOCKTEMP/s"
fi
export SSH_AUTH_SOCK="$SOCKTEMP/s"
echo "$SSH_CONNECTION" | sed 's/ /-/g' > "$SOCKTEMP/c"
fi
このようなものを書いておくと、ランダムな一時ディレクトリのsというファイルがSSH_AUTH_SOCKとして設定され、SSH_AUTH_SOCKが定義済みならそれが実際のSSH_AUTH_SOCKへのシンボリックリンクとなり、またこのディレクトリのcというファイルに接続情報が書き込まれるので、codeなどのコマンドから以下のようにして読み出せるようになります。
if [ -z "$MY_VSCODE_IPC_HOOK_CLI" ];then
MY_VSCODE_IPC_HOOK_CLI=~/.cache/ssh-sock/$(cat $(dirname $(readlink $SSH_AUTH_SOCK))/c)
fi
PATHなどに関しては先ほどの.profileのままで問題なく設定されています。この設定はssh-agentを使っていても問題なく動作します。副作用については、SSH_TTYを見ているので通常の対話sshには影響ありませんが、非対話sshセッションには影響します(といっても、ssh-agentのエラーメッセージが変わるだけ)。非対話sshセッションのなかでVS Code由来のものだけを確実に識別する簡単な方法は無さそうです。
クリーンアップについては以下のように存在しないソケットを参照しているものを不要とみて消せば良さそうです。さっきのUnixドメインソケットの掃除のところにつなげて書いています。
(cd ~/.cache/ssh-sock/ && (
(ls *-*) && for f in *-*; do
if ! printf "" | nc -w 2 -U -N $f; then
rm $f
fi
done
(ls s.*) && for d in s.*; do
if [ ! -e $(cat $d/c) ]; then
rm -r $d
fi
done
) > /dev/null 2>&1 &)
他の方法
すでにVS Code Remote SSHが起動しているという条件であれば、VS Codeのプロセスなどを見ることでVSCODE_IPC_HOOK_CLIを取得して普通のcodeコマンドを使うという方法もあり、VSCode Remote SSHで別Shellからファイルを開くなどで紹介されています。ただ、Remote SSHが既に起動していることを仮定するのは結構微妙なので採用しませんでした。あと多分複数台のPCから同時に同じサーバーに(同じユーザーとして)VS Code Remote SSHしているときにうまくいかないと思います。
あとこの記事の方法を思いつく前は、カスタムURIスキームを使った文字列をターミナルで出力させてマウスクリックで開くというのを考えていましたが、さすがにマウスクリックは大減点すぎるのでポート転送があって助かりました。