diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ed90d2a..275a1a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ これまで `devbase build` は `dev` を建てる一方、`up`・`login`・`open`・`post-start`・`scale`・`--expires` の判定・ token の配布は空の名前のサービスを探して開発サービスを見失っていました。未設定や空でない値のプロジェクトは変わりません。 +- **SSH から立てた tmux のセッションに手元の Mac の端末から attach し直したとき、`devbase editor open` と `devbase up --open` が + VS Code を開かず「SSH セッションを検出しました」とコマンドを提示するだけになっていたのを直しました。** + tmux 内では、SSH かどうかをペインのシェルに残った `SSH_CONNECTION` ではなく、tmux のセッション環境(attach のたびに + 更新される)で判定します。あわせて、接続できない `VSCODE_IPC_HOOK_CLI` と、ペインを作ったクライアントと attach し直したクライアントの SSH 状態が + 食い違う(SSH から立てた tmux に手元から、手元で立てた tmux に Remote-SSH から attach し直した)ペインに残る以前の + `VSCODE_IPC_HOOK_CLI`(ウィンドウが開いたままで接続できても)は使わないようにしました。tmux のセッション環境にいまの + クライアントの値があればそれを使い、無ければ SSH ではコマンドの提示、手元では手元の `code` の起動になります。 + このとき `PATH` に残る Remote-SSH の `remote-cli` の `code` は使わず、手元の `code` を起動します(見つからなければスキップします)。 + ## [4.1.0] - 2026-10-07 base イメージの削減に伴い、使う者の無い道具を外す変更を含みます(lfm・DinD・amd64 の Chrome・Playwright のブラウザなど、 diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index f209737f..e392f68d 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -563,6 +563,10 @@ devbase は**実際にソケットへ接続できるかを確認**してから V 死んでいた場合、**tmux 内なら `tmux show-environment` を見に行きます**。`update-environment`(後述)を設定していれば、tmux のセッション環境は attach のたびに更新されるので、**ペインのシェルが古くても tmux 側には生きた値が入っています**。拾えた場合はその値を使い、起動する `code` にも渡します(変数を差し替えないと `code` 自身が古いソケットへ繋ぎに行って失敗するため)。 +SSH 越しかどうかの判定も、tmux 内ではセッション環境の `SSH_CONNECTION` を見ます。SSH から立てた tmux サーバーに手元の端末から attach し直すと、ペインのシェルには `SSH_CONNECTION` が残りますが、セッション環境は `-SSH_CONNECTION`(いまのクライアントは SSH でない)に更新されます。`SSH_CONNECTION` は tmux の `update-environment` に既定で入っているので、設定は要りません。セッション環境から分からないとき(tmux 外・tmux の失敗)は、ペインのシェルの `SSH_CONNECTION` / `SSH_CLIENT` / `SSH_TTY` で判定します。 + +セッション環境から分かるいまのクライアントの SSH 状態が、ペインのシェルの SSH 状態(`SSH_*` の有無)と食い違うときは、向きによらず、ペインのシェルに残る `VSCODE_IPC_HOOK_CLI` はペインを作った以前のクライアントのものとみなし、そのウィンドウが開いたままで接続できても使いません。SSH から立てた tmux に手元から attach し直したときは、ホスト上のコンテナを開く URI がリモートのクライアントへ届いて開けず、手元で立てた tmux に Remote-SSH から attach し直したときは、SSH 先を経由する URI が Remote-SSH の接続を持たない手元の VS Code へ届いて開けないためです。tmux のセッション環境にいまのクライアントの生きた値が入っていれば、そちらを使います。入っていなければ IPC 無しとして扱い、SSH なら手元で実行するコマンドを示し、手元なら手元の `code` を起動します。食い違わないとき・セッション環境から分からないときは、ペインの値をこれまでどおり使います。死んだソケットを指す `VSCODE_IPC_HOOK_CLI` も、起動する `code` に渡しません。`VSCODE_IPC_HOOK_CLI` を外すときは、`PATH` から Remote-SSH などの `remote-cli`(`~/.vscode-server/bin//bin/remote-cli`・`~/.cursor-server/...` など)を除いて手元の `code` を探します。`remote-cli` の `code` は `VSCODE_IPC_HOOK_CLI` が無いと何も開かずに終わるためです。手元の `code` が見つからなければ、起動せずにスキップします。 + 拾えなかった場合は警告を出したうえで「手元で実行するコマンドの提示」へ degrade するので、**黙って何も起きないという状態にはなりません**。提示されたコマンドを手元で実行すれば開けます。 ソケットの死に方は 2 通りあります。 diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 8b3c7d72..1f50f4b6 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -87,6 +87,10 @@ class EditorContext: # 実際に使う IPC ソケット。env の VSCODE_IPC_HOOK_CLI が死んでいて tmux の # セッション環境から拾い直した場合、env の値とは異なる (None は未解決)。 ipc_socket: Optional[str] = None + # ペインの VSCODE_IPC_HOOK_CLI が、いま attach しているクライアントではなく + # ペインを作った以前のクライアントのものと分かったので採らなかった + # (:func:`_client_changed`)。 + ipc_from_previous_client: bool = False @dataclass(frozen=True) @@ -170,16 +174,16 @@ def _socket_connectable(sock: Optional[str]) -> bool: return False -def _tmux_env(name: str, environ) -> Optional[str]: - """tmux の **セッション環境** から変数を 1 つ読む。tmux 外なら None。 +# tmux のセッション環境で変数が消されている (``-NAME``) ことを表す印。 +_TMUX_REMOVED = object() - tmux サーバーはセッション作成時の環境変数を保持し続けるが、``update-environment`` - に登録された変数は **attach のたびに**接続してきたクライアントの値へ更新される。 - そのため「すでに動いているペインのシェルは古い値、tmux のセッション環境は新しい値」 - という状態が普通に起きる。ここはその新しい方を読むための口。 - ``tmux show-environment `` は未設定の変数を ``-NAME`` の形で返すため、 - 値として扱わないようにする。 +def _tmux_show_env(name: str, environ): + """tmux の **セッション環境** での変数の状態を返す。 + + 戻り値は 3 通り。値があればその文字列、``-NAME`` (attach してきたクライアントが + 持っていなかったので消された) なら :data:`_TMUX_REMOVED`、tmux 外・tmux の失敗・ + 想定外の出力・空の値なら None (わからない)。 """ if not environ.get("TMUX"): return None @@ -196,14 +200,73 @@ def _tmux_env(name: str, environ) -> Optional[str]: if out.returncode != 0: return None line = out.stdout.strip() + if line == f"-{name}": + return _TMUX_REMOVED prefix = f"{name}=" if not line.startswith(prefix): - # "-NAME" (削除済み) や想定外の出力 return None return line[len(prefix):] or None -def resolve_ipc_socket(environ) -> Optional[str]: +def _tmux_env(name: str, environ) -> Optional[str]: + """tmux の **セッション環境** から変数を 1 つ読む。tmux 外なら None。 + + tmux サーバーはセッション作成時の環境変数を保持し続けるが、``update-environment`` + に登録された変数は **attach のたびに**接続してきたクライアントの値へ更新される。 + そのため「すでに動いているペインのシェルは古い値、tmux のセッション環境は新しい値」 + という状態が普通に起きる。ここはその新しい方を読むための口。 + + ``tmux show-environment `` は未設定の変数を ``-NAME`` の形で返すため、 + 値として扱わないようにする。 + """ + value = _tmux_show_env(name, environ) + return None if value is _TMUX_REMOVED else value + + +_SSH_ENV_KEYS = ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY") + + +def _detect_ssh(environ, tmux_value=None) -> bool: + """いまの端末が SSH 越しか。 + + tmux 内では、ペインのシェルの ``SSH_*`` はペインを作ったときのクライアントの値で、 + いま attach しているクライアントとは限らない (SSH から立てた tmux サーバーに手元の + 端末から attach し直すと、シェルには ``SSH_CONNECTION`` が残る)。``SSH_CONNECTION`` + は tmux の ``update-environment`` に既定で入っており、セッション環境は attach の + たびに更新されるので、そちらで分かればそれに従う。分からなければ (tmux 外・tmux の + 失敗) ペインのシェルの変数で判定する。 + + ``tmux_value`` は :func:`_tmux_show_env` で読んだ ``SSH_CONNECTION`` の状態 + (呼び出し側で読み済みなら渡す。None なら読みに行く)。 + """ + if tmux_value is None: + tmux_value = _tmux_show_env("SSH_CONNECTION", environ) + if tmux_value is _TMUX_REMOVED: + return False + if tmux_value is not None: + return True + return any(environ.get(k) for k in _SSH_ENV_KEYS) + + +def _client_changed(environ, tmux_ssh) -> bool: + """ペインを作ったクライアントと、いま attach しているクライアントの SSH 状態が食い違うか。 + + セッション環境の ``SSH_CONNECTION`` (値あり / ``-SSH_CONNECTION``) から分かる + いまのクライアントの SSH 状態が、ペインのシェルの ``SSH_*`` の有無と違うとき True。 + 向きは問わない (SSH から作ったペインに手元から attach し直した、手元で作ったペインに + SSH から attach し直した)。どちらでも、ペインの ``VSCODE_IPC_HOOK_CLI`` は以前の + クライアントのウィンドウを指しており、そのウィンドウが開いたままだと接続できてしまう。 + そこへ委譲すると、いまの判定 (``is_ssh``) で組んだ URI が別のクライアントへ届いて + 開けない。セッション環境から分からないとき (tmux 外・tmux の失敗) は False。 + """ + if tmux_ssh is None: + return False + current_is_ssh = tmux_ssh is not _TMUX_REMOVED + pane_is_ssh = any(environ.get(k) for k in _SSH_ENV_KEYS) + return current_is_ssh != pane_is_ssh + + +def resolve_ipc_socket(environ, trust_current: bool = True) -> Optional[str]: """実際に使える VS Code IPC ソケットのパスを返す (無ければ None)。 1. ``VSCODE_IPC_HOOK_CLI`` が生きていればそれを使う @@ -213,9 +276,12 @@ def resolve_ipc_socket(environ) -> Optional[str]: 抱えたままという状態が頻繁に起きるため (:func:`_tmux_env` 参照)。シェル側の プロンプトフックで追随させる運用もあるが、それが入っていない環境でも ``devbase up --open`` が自動で開けるように devbase 側でも拾いにいく。 + + ``trust_current`` が False のときは 1 を飛ばす (ペインの値が以前のクライアントの + もので、生きていても使えない場合。:func:`_client_changed`)。 """ current = environ.get("VSCODE_IPC_HOOK_CLI") - if current and _socket_connectable(current): + if trust_current and current and _socket_connectable(current): return current candidate = _tmux_env("VSCODE_IPC_HOOK_CLI", environ) if candidate and candidate != current and _socket_connectable(candidate): @@ -239,8 +305,13 @@ def detect_context(environ=None, isatty: Optional[bool] = None, isatty = _stdout_isatty() if system is None: system = platform.system() + # SSH の判定と IPC の採用元は同じセッション環境の読みで揃える。いまの + # クライアントの SSH 状態がペインのそれと食い違うなら、ペインに残る以前の + # クライアントの IPC へ委譲すると、いまの判定で組んだ URI が別のクライアントへ届く。 + tmux_ssh = _tmux_show_env("SSH_CONNECTION", env) + changed = _client_changed(env, tmux_ssh) if ipc_alive is None: - sock = resolve_ipc_socket(env) + sock = resolve_ipc_socket(env, trust_current=not changed) ipc_alive = sock is not None else: sock = env.get("VSCODE_IPC_HOOK_CLI") if ipc_alive else None @@ -248,9 +319,10 @@ def detect_context(environ=None, isatty: Optional[bool] = None, is_tty=bool(isatty), in_vscode=bool(ipc_alive), is_wsl=_detect_wsl(env), - is_ssh=any(env.get(k) for k in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")), + is_ssh=_detect_ssh(env, tmux_ssh), is_darwin=(system == "Darwin"), ipc_socket=sock, + ipc_from_previous_client=changed and bool(env.get("VSCODE_IPC_HOOK_CLI")), ) @@ -270,21 +342,31 @@ def is_open_enabled(environ=None, config=None) -> bool: return value.strip().lower() in _TRUTHY -def resolve_editor_cmd(environ=None) -> Optional[list]: +def _which(name: str, path: Optional[str]) -> Optional[str]: + """``shutil.which`` の薄い包み。``path`` が None なら既定の PATH で探す。""" + if path is None: + return shutil.which(name) + return shutil.which(name, path=path) + + +def resolve_editor_cmd(environ=None, path: Optional[str] = None) -> Optional[list]: """起動に使うエディタコマンド (argv list) を解決する。 ``DEVBASE_EDITOR`` があればそれを (シェル風に分割して) 優先。なければ既定の ``code``。attach URI は VS Code 系 CLI でのみ解釈できるため、``$EDITOR`` (vi 等) へのフォールバックは意図的に行わない。実在しなければ None。 + + ``path`` を渡すとその PATH で探す (Remote-SSH の remote-cli を除いた PATH。 + :func:`_drop_vscode_ipc`)。 """ env = os.environ if environ is None else environ explicit = env.get("DEVBASE_EDITOR") if explicit: parts = shlex.split(explicit) - if parts and shutil.which(parts[0]): + if parts and _which(parts[0], path): return parts return None - if shutil.which("code"): + if _which("code", path): return ["code"] return None @@ -626,6 +708,26 @@ def _launch(cmd: list, env: dict) -> None: ) +def _is_remote_cli_dir(entry: str) -> bool: + """PATH の要素が VS Code 系サーバーの ``remote-cli`` (``~/.vscode-server/bin//bin/remote-cli`` + ・``~/.cursor-server/...`` など) か。""" + return os.path.basename(entry.rstrip("/")) == "remote-cli" + + +def _drop_vscode_ipc(env): + """``VSCODE_IPC_HOOK_CLI`` を外し、PATH から remote-cli を除いた env を返す。 + + remote-cli の ``code`` は IPC が無いと「Command is only available in WSL or + inside a Visual Studio Code terminal.」を stdout に出して終わるだけなので、 + IPC を外すときは手元の ``code`` を探せるよう PATH からも除く。 + """ + env = {k: v for k, v in env.items() if k != "VSCODE_IPC_HOOK_CLI"} + if "PATH" in env: + env["PATH"] = os.pathsep.join( + e for e in env["PATH"].split(os.pathsep) if not _is_remote_cli_dir(e)) + return env + + def _prepare_ipc_env(env, ctx: EditorContext): """拾い直した IPC ソケットを env へ反映し、必要な警告ログを出す。 @@ -646,7 +748,20 @@ def _prepare_ipc_env(env, ctx: EditorContext): "tmux 設定を参照してください。", stale_ipc or "(未設定)", ctx.ipc_socket, ) - if stale_ipc and not ctx.in_vscode: + if ctx.ipc_from_previous_client: + # ペインを作った以前のクライアントの IPC。生きていても code に渡すとそちらへ + # 委譲してしまうので外す (セッション環境の IPC を拾えていれば上で差し替え済み)。 + if ctx.ipc_socket is None: + env = _drop_vscode_ipc(env) + logger.info( + "VSCODE_IPC_HOOK_CLI (%s) はこのペインを作った以前のクライアントのもので、" + "いま attach しているクライアントのものではないため使いません。", + stale_ipc, + ) + elif stale_ipc and not ctx.in_vscode: + # 死んだソケットは code に渡さない。PATH に Remote-SSH の remote-cli が + # 残っていると、その code は IPC 無しでは何も開かずに終わるので PATH からも除く。 + env = _drop_vscode_ipc(env) logger.warning( "VSCODE_IPC_HOOK_CLI が指すソケットに接続できません (%s)。VS Code 統合" "ターミナルとしては扱いません。tmux/screen のセッションを再利用している" @@ -741,8 +856,13 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, ctx = detect_context(env, isatty=isatty, system=system, ipc_alive=ipc_alive) # tmux のセッション環境から拾い直せた場合は env を差し替え、死んだ IPC ソケット # の警告も出す。 + had_ipc = bool(env.get("VSCODE_IPC_HOOK_CLI")) env = _prepare_ipc_env(env, ctx) - editor = resolve_editor_cmd(env) # launch 用 (which 込み・None あり得る) + # IPC を外したときは remote-cli を除いた PATH で手元の code を探す + # (見つからなければ skip / print_command へ落ちる)。 + dropped_ipc = had_ipc and "VSCODE_IPC_HOOK_CLI" not in env + editor = resolve_editor_cmd( # launch 用 (which 込み・None あり得る) + env, path=env.get("PATH") if dropped_ipc else None) display = resolve_editor_display(env) # print 用 (必ず非 None) plan = decide_action(ctx, editor_available=bool(editor)) diff --git a/tests/conftest.py b/tests/conftest.py index fb1fb2b9..9c4f2522 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -408,6 +408,8 @@ def do_GET(self): # 隔離しない一覧: lib/devbase が読むが未設定へ戻さない変数と、その理由 NOT_ISOLATED_ENV = { 'DEVBASE_ROOT': '_isolate_devbase_root がテストごとの tmp の root へ固定する (#209)', + 'PATH': 'テストが外部コマンドを探すのに要る。editor/opener.py は渡された env の値を読むだけで、' + 'テストは env を自前で渡す', } diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 130c9152..fa4cf748 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -180,6 +180,13 @@ def test_tmux_env_reads_value(monkeypatch): assert "PATH" in env # os.environ 由来のキーは失わない +def test_tmux_show_env_distinguishes_removed(monkeypatch): + """``-NAME`` は「わからない (None)」ではなく「消されている」として返す。""" + monkeypatch.setattr(opener.subprocess, "run", + _FakeRun(stdout="-SSH_CONNECTION\n")) + assert opener._tmux_show_env("SSH_CONNECTION", {"TMUX": "x"}) is opener._TMUX_REMOVED + + def test_tmux_env_treats_removed_marker_as_unset(monkeypatch): """未設定の変数は ``-NAME`` で返るので値として扱わない。""" monkeypatch.setattr(opener.subprocess, "run", @@ -273,8 +280,289 @@ def test_open_editor_without_tmux_does_not_recover(monkeypatch, tmp_path, caplog text = "\n".join(r.getMessage() for r in caplog.records) assert "接続できません" in text assert "拾い直しました" not in text - # env の VSCODE_IPC_HOOK_CLI は書き換えられていない - assert calls[0][1]["VSCODE_IPC_HOOK_CLI"].endswith("gone.sock") + # 死んだソケットは code に渡さない (remote-cli へ委譲して失敗するため) + assert "VSCODE_IPC_HOOK_CLI" not in calls[0][1] + + +class _FakeTmuxEnv: + """``tmux show-environment `` を変数ごとの出力で返すスタブ。""" + + def __init__(self, lines): + self.lines = lines + self.calls = [] + + def __call__(self, cmd, **kw): + self.calls.append(cmd) + return SimpleNamespace(stdout=self.lines.get(cmd[-1], ""), returncode=0) + + +def test_detect_context_tmux_removed_ssh_is_local(monkeypatch): + """SSH から立てた tmux に手元の端末から attach し直した状態。 + + ペインのシェルには ``SSH_*`` が残っているが、セッション環境の ``SSH_CONNECTION`` + は ``-SSH_CONNECTION`` (いまのクライアントは SSH でない) なので SSH と扱わない。 + """ + monkeypatch.setattr(opener.subprocess, "run", + _FakeTmuxEnv({"SSH_CONNECTION": "-SSH_CONNECTION\n"})) + ctx = opener.detect_context( + environ={"TMUX": "x", "SSH_CONNECTION": "192.0.2.1 1 192.0.2.2 22", + "SSH_CLIENT": "192.0.2.1 1 22"}, + isatty=True, system="Darwin", ipc_alive=False) + assert ctx.is_ssh is False + + +def test_detect_context_tmux_ssh_value_is_ssh(monkeypatch): + """セッション環境に ``SSH_CONNECTION`` があれば、ペインのシェルに無くても SSH。""" + monkeypatch.setattr( + opener.subprocess, "run", + _FakeTmuxEnv({"SSH_CONNECTION": "SSH_CONNECTION=192.0.2.1 1 192.0.2.2 22\n"})) + ctx = opener.detect_context(environ={"TMUX": "x"}, isatty=True, + system="Linux", ipc_alive=False) + assert ctx.is_ssh is True + + +@pytest.mark.parametrize("env_has_ssh", [True, False]) +def test_detect_context_tmux_unknown_falls_back_to_shell_env(monkeypatch, env_has_ssh): + """tmux が失敗したときはペインのシェルの変数で判定する (従来どおり)。""" + monkeypatch.setattr(opener.subprocess, "run", _FakeRun(returncode=1)) + environ = {"TMUX": "x"} + if env_has_ssh: + environ["SSH_TTY"] = "/dev/ttys001" + ctx = opener.detect_context(environ=environ, isatty=True, + system="Linux", ipc_alive=False) + assert ctx.is_ssh is env_has_ssh + + +def test_open_editor_reattached_tmux_on_local_launches(monkeypatch, tmp_path): + """SSH の名残と死んだ IPC ソケットを抱えたペインでも、手元の端末なら開く。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/local/bin/code") + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "adminer-dev-1") + monkeypatch.setattr(opener.subprocess, "run", _FakeTmuxEnv({ + "SSH_CONNECTION": "-SSH_CONNECTION\n", + "VSCODE_IPC_HOOK_CLI": "-VSCODE_IPC_HOOK_CLI\n", + })) + seen = {} + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"TMUX": "x", "SSH_CONNECTION": "192.0.2.1 1 192.0.2.2 22", + "VSCODE_IPC_HOOK_CLI": str(tmp_path / "gone.sock")}, + isatty=True, system="Darwin", + launcher=lambda cmd, env: seen.update(cmd=cmd, env=env), + ) + assert result == "launch" + assert "VSCODE_IPC_HOOK_CLI" not in seen["env"] + assert seen["cmd"][1] == "--folder-uri" + + +def test_open_editor_reattached_tmux_ignores_live_remote_ipc( + monkeypatch, listening_ipc_socket): + """Remote-SSH のウィンドウを開いたまま手元から attach し直した状態。 + + ペインの ``VSCODE_IPC_HOOK_CLI`` は以前の Remote-SSH クライアントへ繋がって生きて + いるが、いまのクライアントは手元なので採らない。採るとネスト authority の無い URI + がリモートのクライアントへ届き、ホスト上のコンテナを開けない。 + """ + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/local/bin/code") + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "adminer-dev-1") + monkeypatch.setattr(opener.subprocess, "run", _FakeTmuxEnv({ + "SSH_CONNECTION": "-SSH_CONNECTION\n", + "VSCODE_IPC_HOOK_CLI": "-VSCODE_IPC_HOOK_CLI\n", + })) + environ = {"TMUX": "x", "SSH_CONNECTION": "192.0.2.1 1 192.0.2.2 22", + "VSCODE_IPC_HOOK_CLI": listening_ipc_socket} + ctx = opener.detect_context(environ=environ, isatty=True, system="Darwin") + assert ctx.is_ssh is False + assert ctx.in_vscode is False + assert ctx.ipc_from_previous_client is True + seen = {} + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ=environ, isatty=True, system="Darwin", + launcher=lambda cmd, env: seen.update(cmd=cmd, env=env), + ) + assert result == "launch" + assert "VSCODE_IPC_HOOK_CLI" not in seen["env"] + + +def _make_code(dir_path): + """``dir_path/code`` に実行できる空のスクリプトを置く。""" + dir_path.mkdir(parents=True) + code = dir_path / "code" + code.write_text("#!/bin/sh\n") + code.chmod(0o755) + return str(dir_path) + + +def _reattached_environ(monkeypatch, ipc, path): + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "adminer-dev-1") + monkeypatch.setattr(opener.subprocess, "run", _FakeTmuxEnv({ + "SSH_CONNECTION": "-SSH_CONNECTION\n", + "VSCODE_IPC_HOOK_CLI": "-VSCODE_IPC_HOOK_CLI\n", + })) + return {"TMUX": "x", "SSH_CONNECTION": "192.0.2.1 1 192.0.2.2 22", + "VSCODE_IPC_HOOK_CLI": ipc, "PATH": path} + + +def test_open_editor_reattached_tmux_skips_remote_cli_code( + monkeypatch, tmp_path, listening_ipc_socket): + """IPC を外すときは PATH の先頭に残る remote-cli の code を選ばず、手元の code を使う。 + + remote-cli の code は IPC が無いと何も開かずに終わるため。起動する code の env の + PATH からも remote-cli を除く。 + """ + remote = _make_code( + tmp_path / ".vscode-server" / "bin" / "abc123" / "bin" / "remote-cli") + local = _make_code(tmp_path / "local" / "bin") + environ = _reattached_environ( + monkeypatch, listening_ipc_socket, os.pathsep.join([remote, local])) + seen = {} + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ=environ, isatty=True, system="Darwin", + launcher=lambda cmd, env: seen.update(cmd=cmd, env=env), + ) + assert result == "launch" + assert seen["env"]["PATH"] == local + assert "VSCODE_IPC_HOOK_CLI" not in seen["env"] + + +@pytest.mark.parametrize("dead", [False, True]) +def test_open_editor_dropped_ipc_with_only_remote_cli_skips( + monkeypatch, tmp_path, listening_ipc_socket, dead): + """IPC を外したのに PATH に remote-cli の code しか無ければ、起動せずにスキップする。 + + 以前の Remote-SSH の IPC (生きている) と、死んだソケットの両方。 + """ + remote = _make_code( + tmp_path / ".cursor-server" / "bin" / "abc123" / "bin" / "remote-cli") + ipc = str(tmp_path / "gone.sock") if dead else listening_ipc_socket + if dead: + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "adminer-dev-1") + environ = {"VSCODE_IPC_HOOK_CLI": ipc, "PATH": remote} + else: + environ = _reattached_environ(monkeypatch, ipc, remote) + calls = [] + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ=environ, isatty=True, system="Darwin", + launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "skip" + assert calls == [] + + +def test_open_editor_reattached_tmux_uses_session_ipc_of_local_client( + monkeypatch, tmp_path, listening_ipc_socket): + """手元の VS Code の端末から attach し直したなら、セッション環境の IPC を使う。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/local/bin/code") + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "adminer-dev-1") + monkeypatch.setattr(opener.subprocess, "run", _FakeTmuxEnv({ + "SSH_CONNECTION": "-SSH_CONNECTION\n", + "VSCODE_IPC_HOOK_CLI": f"VSCODE_IPC_HOOK_CLI={listening_ipc_socket}\n", + })) + seen = {} + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"TMUX": "x", "SSH_CONNECTION": "192.0.2.1 1 192.0.2.2 22", + "VSCODE_IPC_HOOK_CLI": str(tmp_path / "remote.sock")}, + isatty=True, system="Darwin", + launcher=lambda cmd, env: seen.update(cmd=cmd, env=env), + ) + assert result == "launch" + assert seen["env"]["VSCODE_IPC_HOOK_CLI"] == listening_ipc_socket + + +@pytest.fixture +def second_ipc_socket(): + """``listening_ipc_socket`` とは別の、listen 中の AF_UNIX ソケット。""" + import socket as _socket + import tempfile + d = tempfile.mkdtemp(dir="/tmp", prefix="dbipc") + path = os.path.join(d, "s.sock") + srv = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) + srv.bind(path) + srv.listen(1) + try: + yield path + finally: + srv.close() + shutil.rmtree(d, ignore_errors=True) + + +def _ssh_reattached_environ(monkeypatch, session_ipc_line, ipc): + """手元の VS Code で作った tmux に Remote-SSH から attach し直した状態。 + + ペインのシェルには ``SSH_*`` が無く、手元の VS Code の IPC が残る。セッション環境の + ``SSH_CONNECTION`` は、いまのクライアント (SSH) の値に更新されている。 + """ + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/local/bin/code") + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "adminer-dev-1") + monkeypatch.setattr(opener.subprocess, "run", _FakeTmuxEnv({ + "SSH_CONNECTION": "SSH_CONNECTION=192.0.2.1 1 192.0.2.2 22\n", + "VSCODE_IPC_HOOK_CLI": session_ipc_line, + })) + return {"TMUX": "x", "VSCODE_IPC_HOOK_CLI": ipc} + + +def test_open_editor_ssh_reattached_tmux_ignores_live_local_ipc( + monkeypatch, listening_ipc_socket): + """手元で作った tmux に SSH から attach し直したなら、生きた手元の IPC を使わない。 + + 使うと、SSH と判定して組んだネスト URI が Remote-SSH の接続を持たない手元の + VS Code へ届いて開けない。セッション環境に IPC が無ければ手元で叩くコマンドを示す。 + """ + environ = _ssh_reattached_environ( + monkeypatch, "-VSCODE_IPC_HOOK_CLI\n", listening_ipc_socket) + ctx = opener.detect_context(environ=environ, isatty=True, system="Linux") + assert ctx.is_ssh is True + assert ctx.in_vscode is False + assert ctx.ipc_from_previous_client is True + calls = [] + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ=environ, isatty=True, system="Linux", + launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "print_command" + assert calls == [] + + +def test_open_editor_ssh_reattached_tmux_uses_session_ipc( + monkeypatch, listening_ipc_socket, second_ipc_socket): + """手元で作った tmux に Remote-SSH の端末から attach し直したなら、セッション環境の IPC を使う。""" + environ = _ssh_reattached_environ( + monkeypatch, f"VSCODE_IPC_HOOK_CLI={second_ipc_socket}\n", + listening_ipc_socket) + seen = {} + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ=environ, isatty=True, system="Linux", + launcher=lambda cmd, env: seen.update(cmd=cmd, env=env), + ) + assert result == "launch" + assert seen["env"]["VSCODE_IPC_HOOK_CLI"] == second_ipc_socket + + +def test_detect_context_same_client_keeps_pane_ipc(monkeypatch, listening_ipc_socket): + """SSH 状態が食い違わなければ、ペインの生きた IPC をそのまま使う (従来どおり)。""" + monkeypatch.setattr(opener.subprocess, "run", _FakeTmuxEnv({ + "SSH_CONNECTION": "SSH_CONNECTION=192.0.2.1 1 192.0.2.2 22\n", + "VSCODE_IPC_HOOK_CLI": "-VSCODE_IPC_HOOK_CLI\n", + })) + ctx = opener.detect_context( + environ={"TMUX": "x", "SSH_CONNECTION": "192.0.2.1 1 192.0.2.2 22", + "VSCODE_IPC_HOOK_CLI": listening_ipc_socket}, + isatty=True, system="Linux") + assert ctx.is_ssh is True + assert ctx.in_vscode is True + assert ctx.ipc_socket == listening_ipc_socket + assert ctx.ipc_from_previous_client is False def test_detect_context_ipc_alive_override(): @@ -637,7 +925,8 @@ def test_resolve_workspace_returns_path(): def test_open_editor_opens_the_given_workspace_as_a_file(monkeypatch, tmp_path): """複数 repo 構成では workspace ファイルを --file-uri で開く""" calls = [] - monkeypatch.setattr(opener, "resolve_editor_cmd", lambda env=None: ["code"]) + monkeypatch.setattr(opener, "resolve_editor_cmd", + lambda env=None, path=None: ["code"]) monkeypatch.setattr(opener, "resolve_container_name", lambda *a, **kw: "myapp-dev-1") @@ -655,7 +944,8 @@ def test_open_editor_opens_the_given_workspace_as_a_file(monkeypatch, tmp_path): def test_open_editor_opens_the_folder_without_a_workspace(monkeypatch): calls = [] - monkeypatch.setattr(opener, "resolve_editor_cmd", lambda env=None: ["code"]) + monkeypatch.setattr(opener, "resolve_editor_cmd", + lambda env=None, path=None: ["code"]) monkeypatch.setattr(opener, "resolve_container_name", lambda *a, **kw: "myapp-dev-1")