From 24cf9cf1712974909c2a9aa53e805852eef3cfec Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 17:49:44 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(editor):=20Remote-SSH=20=E8=B7=A8?= =?UTF-8?q?=E3=83=9B=E3=82=B9=E3=83=88=E3=81=AE=20attach=20=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=20+=20up=20=E6=99=82=E3=81=AE=E3=82=BF=E3=83=BC?= =?UTF-8?q?=E3=83=9F=E3=83=8A=E3=83=AB=E8=87=AA=E5=8B=95=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=20(PLAN31=5F3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows VS Code → Remote-SSH(Mac) → Mac の Docker 上コンテナ、という構成で devbase up の自動オープンが「コンテナが存在しません」で失敗する問題を修正。 実機検証の結果、ネスト authority attached-container+...@ssh-remote+ は 実際にサポートされており、これを使うと docker ルックアップが ssh 先(コンテナの ある側)で行われ解決できる(PLAN31_3 §2.3/§2.4 の当初想定を訂正)。 - build_attach_uri に ssh_host / docker_context を追加しネスト URI を生成 - DEVBASE_EDITOR_SSH_HOST(明示) / DEVBASE_EDITOR_DOCKER_CONTEXT(既定 docker context show) ※ ssh ホスト別名は VS Code が ssh 先端末 env に渡さず自動取得不可のため明示必須 - DEVBASE_OPEN_TERMINAL(既定 ON): up 時に folderOpen タスク .vscode/tasks.json を docker exec で配置し、フォルダを開くと統合ターミナルを自動表示(既存はスキップ) - CLI: --open-terminal / --no-open-terminal を追加 - docs / PLAN31_3 を実態へ更新、テスト追加 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/user/environment-variables.md | 34 +++++++- issues/PLAN31_3_up-open-editor.md | 52 ++++++++---- lib/devbase/cli.py | 7 ++ lib/devbase/commands/container.py | 71 ++++++++++++++++- lib/devbase/editor/opener.py | 122 ++++++++++++++++++++++++++-- lib/devbase/env/keys.py | 4 + tests/cli/test_project_dispatch.py | 87 ++++++++++++++++++-- tests/editor/test_opener.py | 123 +++++++++++++++++++++++++++++ 8 files changed, 467 insertions(+), 33 deletions(-) diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 70662d1..72797e3 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -149,8 +149,22 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 | `DEVBASE_OPEN_EDITOR` | 真(`1`/`true`/`yes`/`on`)で `up` 後にエディタを開く(既定: OFF) | | `DEVBASE_EDITOR` | 起動コマンド(既定: `code`)。`cursor` / `code-insiders` 等も可 | | `DEVBASE_OPEN_INDEX` | scale 時に開く dev インスタンス番号(既定: `1`) | +| `DEVBASE_EDITOR_SSH_HOST` | Remote-SSH 跨ホスト構成での ssh-remote ホスト名(例 `mac2`)。下記「跨ホスト」参照 | +| `DEVBASE_EDITOR_DOCKER_CONTEXT` | 跨ホスト時に ssh 先で使う docker context(既定: ホストの `docker context show`) | +| `DEVBASE_OPEN_TERMINAL` | 真で `up` 後に folderOpen ターミナル用 `.vscode/tasks.json` を配置(**既定: ON**) | -都度の上書きは CLI フラグで行います: `devbase up --open` / `devbase up --no-open` / `devbase up --open-index N`(env より優先)。 +都度の上書きは CLI フラグで行います: `devbase up --open` / `--no-open` / `--open-index N` / `--open-terminal` / `--no-open-terminal`(env より優先)。 + +### 起動時に統合ターミナルを自動表示(`DEVBASE_OPEN_TERMINAL`) + +`up` 時に、開く dev コンテナのワークスペース(`/work/$GIT_REPO`)へ folderOpen タスク(`.vscode/tasks.json`)を `docker exec` で配置します(既存があれば変更しません)。VS Code はフォルダを開いた時にこのタスクで統合ターミナルを前面表示します。 + +VS Code 公式には「起動時にターミナルを開く」専用設定が無く、folderOpen タスクが唯一の方法です。なお自動実行には次の 2 つの **VS Code クライアント側ユーザー設定**が関わり、devbase からは制御できません(いずれも application/user スコープ専用): + +- **Workspace Trust**: 信頼していないフォルダではタスクは自動実行されません(初回は「フォルダを信頼」が必要)。 +- **`task.allowAutomaticTasks`**: 既定 `off` ではフォルダごとに 1 回「自動タスクを許可」を尋ねます。`on` にするとプロンプト無しで実行されます。 + +→ 実際は「初回のみ信頼(+許可)クリック、以降は自動でターミナルが開く」挙動になります。無効化は `DEVBASE_OPEN_TERMINAL=0` または `devbase up --no-open-terminal`。 ### 実行コンテキスト別の挙動 @@ -158,11 +172,25 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 |------|------| | ローカル端末(Mac/Linux) | ローカル VS Code が開く | | WSL 端末 | Windows 側 VS Code が開く(`code` ラッパ経由) | -| VS Code の Remote-SSH 統合ターミナル | **クライアント側(手元)の VS Code** が開く(`code` シムが委譲) | +| VS Code の Remote-SSH 統合ターミナル(同一ホストの Docker) | **クライアント側(手元)の VS Code** が開く(`code` シムが委譲) | +| VS Code の Remote-SSH 統合ターミナル(**跨ホスト**: ssh 先の Docker にコンテナ) | `DEVBASE_EDITOR_SSH_HOST` 設定時にネスト URI で開く(下記「跨ホスト」参照) | | 手元から素の SSH(VS Code 外)で接続中 | クライアントへ自動で開く公式手段が無いため、手元で実行する `code --folder-uri ...` コマンドを提示 | | CI / 非対話(非 TTY) / `code` 不在 | 理由を表示してスキップ(`up` 自体は成功) | -> SSH 越しに「手元の VS Code」を自動で開きたい場合は、手元の VS Code から **Remote-SSH で接続した統合ターミナル内**で `devbase up` を実行してください。そのターミナルの `code` はクライアント側 VS Code に委譲するため、リモートホスト上のコンテナへ接続した窓が手元に開きます。 +#### 跨ホスト(Windows VS Code → Remote-SSH → Mac のコンテナ) + +手元(例 Windows)の VS Code から Remote-SSH で別ホスト(例 Mac)へ入り、その統合ターミナルで `devbase up` を実行する構成では、コンテナは **ssh 先(Mac)の Docker** 上にあります。このとき `code` の開く要求はクライアント(Windows)へ委譲されるため、フラットな attach URI のままだと **クライアント側の Docker** を見に行きコンテナが見つかりません(「コンテナーにアタッチできません。すでに存在しません」)。 + +これを解決するには、ssh-remote ホスト名(手元 `~/.ssh/config` の `Host` 別名。VS Code はこの別名を ssh 先の端末 env に渡さないため自動取得不可)を明示します: + +```sh +# $DEVBASE_ROOT/env など(全プロジェクト共通にしたい場合) +DEVBASE_EDITOR_SSH_HOST=mac2 +``` + +これで devbase は `vscode-remote://attached-container+@ssh-remote+mac2/work/...`(必要に応じ payload に `settings.context` を埋める)というネスト URI を生成し、docker ルックアップが ssh 先(コンテナのある Mac)で行われて正しくアタッチします。docker context は `docker context show` から自動取得し、`DEVBASE_EDITOR_DOCKER_CONTEXT` で上書きできます。 + +> 同一ホスト構成(手元 Mac/Linux で直接、または ssh 先の Docker にコンテナが無い場合)では `DEVBASE_EDITOR_SSH_HOST` は不要で、従来どおりフラット URI で開きます。 ## ソースファイル変更検出 diff --git a/issues/PLAN31_3_up-open-editor.md b/issues/PLAN31_3_up-open-editor.md index dbbc553..505459d 100644 --- a/issues/PLAN31_3_up-open-editor.md +++ b/issues/PLAN31_3_up-open-editor.md @@ -41,11 +41,24 @@ vscode-remote://attached-container+/work/$GIT_REPO `` は **`{"containerName":"/<実コンテナ名>"}` を UTF-8 hex 化**した文字列。 (単純な名前の hex ではない点に注意。Docker 内部のコンテナ名は先頭 `/` 付き。) -### 2.3 ネスト authority は公式未サポート +### 2.3 ネスト authority(**実機で動作することを確認・当初想定を訂正**) -`ssh-remote+` と `attached-container+...` を 1 本の URI に合成する記法は -**存在しない**(microsoft/vscode#242489 は *Closed as not planned*)。 -→ 「リモートホスト上のコンテナ」を単発 `code` で直接指定する手段は無い。 +> ⚠️ 訂正(2026-06-13 実装時の実機検証)。当初は「合成記法は存在しない +> (microsoft/vscode#242489 *not planned*)」と記載していたが、**誤り**だった。 + +`attached-container+@ssh-remote+` という**ネスト authority は実際に +サポートされており動作する**(VS Code 1.124.2 / Dev Containers 0.459.1 で確認)。 +正常動作中の窓の resource URI を採取したところ: + +``` +vscode-remote://attached-container+@ssh-remote+mac2/work/... +hex = {"containerName":"/","settings":{"context":"desktop-linux"}} +``` + +`@ssh-remote+` を付けると docker ルックアップが **ssh 先(コンテナのある +ホスト)** で行われるため、跨ホスト(手元 Windows VS Code → ssh → Mac のコンテナ) +でも単発 `code --folder-uri` で直接アタッチできる。`settings.context` は ssh 先で +使う docker context を指定する。 ### 2.4 結論(実行コンテキスト別マトリクス) @@ -53,12 +66,16 @@ vscode-remote://attached-container+/work/$GIT_REPO |---|---|---| | Mac/Linux ローカル端末 | ✓ | ローカル `code` が attach URI を解決 | | WSL 端末 | ✓ (Windows VS Code) | `code` ラッパ→`code.exe`、Docker Desktop のコンテナへ attach | -| VS Code **Remote-SSH 統合端末**(リモート=Mac) | ✓ (クライアント側) | `code` シム + `VSCODE_IPC_HOOK_CLI`。シムは既にクライアントへ接続済みなのでネスト URI 不要で attached-container を解決し、**クライアント(Windows)に窓が開く** | -| plain SSH(WSL→ssh→Mac 等、VS Code 外) | ✗ → コマンド表示 | IPC hook 無し。公式にクライアントへ push 不可(§2.3)。手元で叩く `code` コマンドを提示するのが上限 | +| VS Code **Remote-SSH 統合端末**(リモート=Mac・**同一ホストの Docker**) | ✓ (クライアント側) | `code` シムが委譲。同一ホストの Docker にコンテナがある場合はフラット URI で解決 | +| VS Code **Remote-SSH 統合端末**(**跨ホスト**: ssh 先 Mac の Docker にコンテナ) | ✓ (要 `DEVBASE_EDITOR_SSH_HOST`) | フラット URI だとクライアント(Windows)の Docker を見て失敗。**ネスト URI `@ssh-remote+`(§2.3)で ssh 先の Docker を解決**。ssh ホスト名は env から取得不可のため明示設定が要る | +| plain SSH(WSL→ssh→Mac 等、VS Code 外) | ✗ → コマンド表示 | IPC hook 無し。手元で叩く `code` コマンドを提示するのが上限 | | CI / 非TTY / `code` 不在 | ✗ → info スキップ | エディタ起動の前提を満たさない | -→ ユーザ理想チェーンは **「手元 VS Code で Remote-SSH→Mac に入った統合ターミナルで -`devbase up`」の場合に自動成立**。plain ssh の場合は正直にコマンド提示で degrade する。 +→ 跨ホスト(手元 Windows VS Code → Remote-SSH→Mac で `devbase up`、コンテナは Mac の +Docker)が最頻ユースケース。**`DEVBASE_EDITOR_SSH_HOST`(例 `mac2`)の設定で自動成立**。 +ssh ホスト名(クライアント `~/.ssh/config` の Host 別名)は VS Code が ssh 先端末 env に +渡さない(`SSH_CONNECTION` は IP のみ)ため自動取得できず、明示が必須(実機調査で確認)。 +plain ssh はコマンド提示で degrade。 ## 3. 既存コード調査結果 @@ -156,12 +173,19 @@ env 解釈は既存 `_parse_env_assignment`(`container.py:121`)に合わせ `--no-open`/`DEVBASE_OPEN_EDITOR=0` で呼ばれないこと - 既存 706 passed を維持 -## 8. リスク・未確定 - -- **plain SSH では自動オープン不可**(§2.3 公式未サポート)。コマンド提示で degrade。 - この制約は README に明記する -- VS Code Remote-SSH 統合端末でのクライアント側 attach は実機検証が必要 - (`/ndf:investigation-rules`: 実機未検証の挙動は「推定」と明示) +## 8. リスク・未確定(実機検証で更新) + +- ~~VS Code Remote-SSH 統合端末でのクライアント側 attach は実機検証が必要~~ + → **検証済み(2026-06-13)**。跨ホストではフラット URI だと失敗し、ネスト URI + `@ssh-remote+` + `settings.context` で成立することを確認(§2.3/§2.4 を訂正)。 +- ssh ホスト名(`DEVBASE_EDITOR_SSH_HOST`)は env 自動取得不可のため**ユーザ明示が前提**。 + 未設定の跨ホストではフラット URI にフォールバックし、従来同様アタッチ失敗ダイアログが出る + (実害は無いが体験は劣化)。`$DEVBASE_ROOT/env` への 1 行設定を案内する。 +- **plain SSH(VS Code 外)では自動オープン不可**。コマンド提示で degrade(変更なし)。 +- 統合ターミナル自動表示は `.vscode/tasks.json`(folderOpen) 配置で実現。VS Code 公式に + 起動時ターミナル設定は無く(`hideOnStartup` は復元セッションの表示制御のみ)folderOpen + が唯一。自動実行は Workspace Trust と `task.allowAutomaticTasks`(共に user スコープ専用・ + devbase 制御外)に依存し、初回のみ承認クリックが要る。 - `code` ラッパの非ブロッキング起動が `up` プロセス終了をブロックしないこと確認 ## 9. 参考(一次情報) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 5248bdf..31c9702 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -112,6 +112,13 @@ def _add_open_args(parser): parser.add_argument('--open-index', dest='open_index', type=int, default=None, metavar='N', help='Container index to open (default: 1)') + parser.add_argument('--open-terminal', dest='open_terminal', action='store_true', + default=None, + help='Place .vscode/tasks.json so the integrated terminal ' + 'auto-opens on folder open (overrides DEVBASE_OPEN_TERMINAL)') + parser.add_argument('--no-open-terminal', dest='open_terminal', action='store_false', + help='Do not place the folderOpen terminal tasks.json ' + '(overrides DEVBASE_OPEN_TERMINAL)') return parser diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 6c617d1..3995c07 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -290,7 +290,8 @@ def _dispatch_lifecycle(args) -> int: 'up': lambda: cmd_up(project_name=project_name, scale=getattr(args, 'scale', None), open_editor=getattr(args, 'open_editor', None), - open_index=getattr(args, 'open_index', None)), + open_index=getattr(args, 'open_index', None), + open_terminal=getattr(args, 'open_terminal', None)), 'down': lambda: cmd_down(), 'login': lambda: cmd_login(index=getattr(args, 'index', '1')), 'ps': lambda: cmd_ps(all_containers=getattr(args, 'all', False)), @@ -412,9 +413,72 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) +def _maybe_place_terminal_task(project_name: str, open_flag: Optional[bool], + open_index: Optional[int], scale: int, + compose_file=None) -> None: + """`up` 後、開く dev コンテナの作業ディレクトリへ folderOpen ターミナル tasks.json を配置。 + + フォルダを開いた時に統合ターミナルを自動表示するための ``.vscode/tasks.json`` を、 + 対象 dev インスタンスのワークスペース (``/work/$GIT_REPO``) に置く。作業ディレクトリは + コンテナ内 (named volume) のためホストから直接書けず、起動済みコンテナへ ``docker exec`` + で書き込む。**既存の ``.vscode/tasks.json`` があれば一切触らない**。 + + 有効判定は ``open_flag`` (CLI ``--open-terminal``/``--no-open-terminal``) が優先、None なら + env ``DEVBASE_OPEN_TERMINAL`` (既定 ON)。配置失敗は warning に握り潰し ``up`` を倒さない。 + ``open_index`` は開くインスタンスに合わせる (範囲外は 1 へフォールバック)。 + """ + from devbase.editor import opener + + enabled = open_flag if open_flag is not None else opener.is_open_terminal_enabled() + if not enabled: + return + + if open_index is None: + raw = os.environ.get('DEVBASE_OPEN_INDEX') + try: + open_index = int(raw) if raw else 1 + except ValueError: + open_index = 1 + if not (1 <= open_index <= scale): + open_index = 1 + + if compose_file is None and _SCALE_COMPOSE_FILE.exists(): + compose_file = _SCALE_COMPOSE_FILE + + dev_service_name = get_dev_service_name() + container = opener.resolve_container_name(dev_service_name, project_name, + open_index, compose_file=compose_file) + workdir = opener.resolve_workdir(os.environ, project_name) + content = opener.build_folder_open_tasks_json() + + # 既存があれば書かず、無ければ stdin から書き込む (冪等)。workdir は引数で渡し + # シェル内クォートを避ける ($1)。 + script = ( + 'set -e; d="$1/.vscode"; mkdir -p "$d"; ' + 'if [ -e "$d/tasks.json" ]; then echo keep; ' + 'else cat > "$d/tasks.json"; echo placed; fi' + ) + try: + proc = subprocess.run( + ["docker", "exec", "-i", container, "sh", "-c", script, "_", workdir], + input=content, text=True, capture_output=True, timeout=15, + ) + except Exception as e: # noqa: BLE001 - 配置失敗で up を倒さない + logger.warning("ターミナル用 tasks.json の配置に失敗しましたが続行します: %s", e) + return + if proc.returncode != 0: + logger.warning("ターミナル用 tasks.json の配置に失敗しましたが続行します: %s", + (proc.stderr or "").strip()) + return + if (proc.stdout or "").strip() == "placed": + logger.info("[6/6] 統合ターミナル自動表示用 tasks.json を配置: %s/.vscode/tasks.json", + workdir) + + def cmd_up(project_name: str = None, scale: int = None, open_editor: Optional[bool] = None, - open_index: Optional[int] = None) -> int: + open_index: Optional[int] = None, + open_terminal: Optional[bool] = None) -> int: """Deploy containers with specified scale""" if project_name is None: project_name = get_project_name() @@ -481,6 +545,9 @@ def cmd_up(project_name: str = None, scale: int = None, if deploy_script.exists() and deploy_script.is_file(): _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) + # エディタを開く前に tasks.json を置く (開いた瞬間に folderOpen が効くように)。 + _maybe_place_terminal_task(project_name, open_terminal, open_index, scale, + compose_file=override_file) _maybe_open_editor(project_name, open_editor, open_index, scale, compose_file=override_file) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 28b019c..7edbf6b 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -104,6 +104,52 @@ def is_open_enabled(environ=None) -> bool: return value.strip().lower() in _TRUTHY +def is_open_terminal_enabled(environ=None) -> bool: + """``DEVBASE_OPEN_TERMINAL`` env が真か (**未設定は True = 既定 ON**)。 + + ``DEVBASE_OPEN_EDITOR`` (既定 OFF) と既定が逆である点に注意。up 時の tasks.json 配置は + 暴発リスクが低く、ユーザ要望で既定 ON とする (PLAN31_3)。 + """ + env = os.environ if environ is None else environ + value = env.get("DEVBASE_OPEN_TERMINAL") + if value is None: + return True + return value.strip().lower() in _TRUTHY + + +def build_folder_open_tasks_json() -> str: + """フォルダを開いた時に統合ターミナルを表示する folderOpen タスク (.vscode/tasks.json)。 + + VS Code 公式には「起動時にターミナルを開く」単独設定が無く (``hideOnStartup`` は復元 + された永続セッションを隠すか否かに過ぎず新規生成はしない)、``runOn: folderOpen`` の + タスクが新規ターミナルを出せる唯一の方法 (docs/terminal/*, docs/debugtest/tasks)。 + ``reveal: always`` でパネルを前面に出し、対話シェル (``$SHELL``) を起動する。 + + .. note:: 自動実行には2つの user 設定ゲートがあり devbase からは制御できない: + Workspace Trust (信頼済みフォルダのみ自動実行) と ``task.allowAutomaticTasks`` + (既定 off = フォルダ毎に1回許可確認)。いずれも application/user スコープ専用。 + """ + tasks = { + "version": "2.0.0", + "tasks": [ + { + "label": "devbase: open terminal", + "type": "shell", + "command": "${env:SHELL}", + "isBackground": True, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": True, + }, + "runOptions": {"runOn": "folderOpen"}, + } + ], + } + return json.dumps(tasks, indent=2, ensure_ascii=False) + "\n" + + def resolve_editor_cmd(environ=None) -> Optional[list]: """起動に使うエディタコマンド (argv list) を解決する。 @@ -143,16 +189,33 @@ def resolve_editor_display(environ=None) -> list: return ["code"] -def build_attach_uri(container_name: str, workdir: str) -> str: - """``vscode-remote://attached-container+/`` を組む。 +def build_attach_uri(container_name: str, workdir: str, + ssh_host: Optional[str] = None, + docker_context: Optional[str] = None) -> str: + """``vscode-remote://attached-container+[@ssh-remote+]/`` を組む。 + + ```` は ``{"containerName":"/"[,"settings":{"context":}]}`` + を UTF-8 hex 化したもの (Docker 内部のコンテナ名は先頭 ``/`` 付き)。 + + ``ssh_host`` を渡すと authority に ``@ssh-remote+`` を付ける。**Windows VS Code + → Remote-SSH() → Mac 上のコンテナ** のような跨ホスト構成では、フラットな + ``attached-container+...`` だけだと委譲先クライアント (Windows) のローカル Docker を + 見に行きコンテナが見つからない。``@ssh-remote+`` を付けると docker ルックアップが + ssh 先 (コンテナのある Mac) で行われ解決できる (実機検証済み。PLAN31_3 §2.3/§2.4 を + 更新)。``docker_context`` を渡すと payload に ``settings.context`` を埋め、ssh 先で + 使う docker context を明示する。 - ```` は ``{"containerName":"/"}`` を UTF-8 hex 化したもの - (Docker 内部のコンテナ名は先頭 ``/`` 付き)。 + いずれも省略すると従来のフラット URI (ローカル / WSL / Remote-SSH 同一ホスト) を返す。 """ - payload = json.dumps({"containerName": f"/{container_name}"}, separators=(",", ":")) - hexname = payload.encode("utf-8").hex() + payload: dict = {"containerName": f"/{container_name}"} + if docker_context: + payload["settings"] = {"context": docker_context} + hexname = json.dumps(payload, separators=(",", ":")).encode("utf-8").hex() + authority = f"attached-container+{hexname}" + if ssh_host: + authority += f"@ssh-remote+{ssh_host}" path = workdir if workdir.startswith("/") else f"/{workdir}" - return f"vscode-remote://attached-container+{hexname}{path}" + return f"vscode-remote://{authority}{path}" def _parse_compose_ps_name(stdout: str) -> Optional[str]: @@ -260,6 +323,43 @@ def resolve_workdir(environ=None, project_name: Optional[str] = None) -> str: return f"/work/{repo}" if repo else "/work" +def resolve_editor_ssh_host(environ=None) -> Optional[str]: + """Remote-SSH ネスト URI 用の ssh ホスト名 (``DEVBASE_EDITOR_SSH_HOST``)。 + + 値はクライアント (手元 VS Code) の ``~/.ssh/config`` の Host 別名 (例 ``mac2``)。 + VS Code はこの別名を Remote-SSH 先 (Mac) の端末 env に渡さない (SSH_CONNECTION は + IP のみ) ため自動取得できず、明示設定が要る (PLAN31_3 §2.4 / 実機調査)。未設定なら + None を返し、:func:`build_attach_uri` はフラット URI にフォールバックする。 + """ + env = os.environ if environ is None else environ + value = env.get("DEVBASE_EDITOR_SSH_HOST") + value = value.strip() if value else "" + return value or None + + +def resolve_docker_context(environ=None, runner: Optional[Callable] = None) -> Optional[str]: + """ssh 先で使う docker context を解決する。 + + ``DEVBASE_EDITOR_DOCKER_CONTEXT`` 明示があればそれ。無ければ devbase up を実行して + いるホスト (= コンテナのある Mac) の現在の docker context を ``docker context show`` + で取得する。docker 不在・非0・例外・空はすべて None (settings.context を付けない)。 + """ + env = os.environ if environ is None else environ + explicit = env.get("DEVBASE_EDITOR_DOCKER_CONTEXT") + if explicit and explicit.strip(): + return explicit.strip() + run = runner or subprocess.run + try: + proc = run(["docker", "context", "show"], + capture_output=True, text=True, timeout=10) + except Exception: # noqa: BLE001 - docker 不在等は best-effort + return None + if getattr(proc, "returncode", 1) != 0: + return None + out = (proc.stdout or "").strip() + return out or None + + _NO_EDITOR_REASON = ( "エディタ (code) が見つかりません。VS Code の `code` コマンドを PATH に " "通すか DEVBASE_EDITOR を設定してください" @@ -322,7 +422,13 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, container = resolve_container_name(dev_service_name, project_name, index, compose_file=compose_file) - uri = build_attach_uri(container, workdir) + # SSH コンテキストでのみネスト authority (@ssh-remote+host) を組む。ssh_host が + # 設定されていれば跨ホスト構成と見なし docker context も解決して埋める。非 SSH では + # 従来のフラット URI (ローカル/WSL/同一ホスト Remote-SSH) を維持する。 + ssh_host = resolve_editor_ssh_host(env) if ctx.is_ssh else None + docker_context = resolve_docker_context(env) if ssh_host else None + uri = build_attach_uri(container, workdir, + ssh_host=ssh_host, docker_context=docker_context) if plan.action == "skip": logger.info("エディタの自動オープンをスキップ: %s", plan.reason) diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index e8a8d36..0e9fe30 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -57,3 +57,7 @@ def gcp_credentials_key(profile: str) -> str: DEVBASE_OPEN_EDITOR = "DEVBASE_OPEN_EDITOR" # 真偽。up 後にエディタを開くか (既定 OFF) DEVBASE_EDITOR = "DEVBASE_EDITOR" # 任意。起動コマンド (既定 code) DEVBASE_OPEN_INDEX = "DEVBASE_OPEN_INDEX" # 任意。開く dev インスタンス番号 (既定 1) +# Remote-SSH 跨ホスト構成 (Windows VS Code → ssh → Mac のコンテナ) 用。 +DEVBASE_EDITOR_SSH_HOST = "DEVBASE_EDITOR_SSH_HOST" # 任意。ssh-remote ホスト名 (例 mac2) +DEVBASE_EDITOR_DOCKER_CONTEXT = "DEVBASE_EDITOR_DOCKER_CONTEXT" # 任意。ssh 先 docker context (既定: docker context show) +DEVBASE_OPEN_TERMINAL = "DEVBASE_OPEN_TERMINAL" # 真偽。up 後に folderOpen ターミナル tasks.json を配置 (既定 ON) diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 287fd5a..224ffff 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -340,15 +340,19 @@ def test_up_parser_open_flags_tri_state(): def test_lifecycle_propagates_open_args_to_cmd_up(monkeypatch): - """up の open_editor / open_index が cmd_up まで伝播する。""" + """up の open_editor / open_index / open_terminal が cmd_up まで伝播する。""" from devbase.commands import container captured = {} - monkeypatch.setattr(container, 'cmd_up', - lambda project_name=None, scale=None, open_editor=None, open_index=None: - captured.update(open_editor=open_editor, open_index=open_index) or 0) - args = _args(subcommand='up', scale=None, open_editor=True, open_index=2) + monkeypatch.setattr( + container, 'cmd_up', + lambda project_name=None, scale=None, open_editor=None, open_index=None, + open_terminal=None: captured.update( + open_editor=open_editor, open_index=open_index, + open_terminal=open_terminal) or 0) + args = _args(subcommand='up', scale=None, open_editor=True, open_index=2, + open_terminal=False) assert container._dispatch_lifecycle(args) == 0 - assert captured == {'open_editor': True, 'open_index': 2} + assert captured == {'open_editor': True, 'open_index': 2, 'open_terminal': False} def test_maybe_open_editor_disabled_by_default(monkeypatch): @@ -419,6 +423,77 @@ def test_maybe_open_editor_valid_index_within_scale(monkeypatch): assert called[0]['index'] == 2 +class _DockerProc: + """subprocess.run 互換スタブ (docker exec 用)。""" + def __init__(self, returncode=0, stdout="placed", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def test_maybe_place_terminal_task_disabled(monkeypatch): + """DEVBASE_OPEN_TERMINAL 無効時は docker exec を呼ばない。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_terminal_enabled', lambda environ=None: False) + calls = [] + monkeypatch.setattr(container.subprocess, 'run', + lambda *a, **k: calls.append(a) or _DockerProc()) + container._maybe_place_terminal_task('carmo', None, None, 1) + assert calls == [] + + +def test_maybe_place_terminal_task_flag_off_overrides_env(monkeypatch): + """open_flag=False なら env が ON でも置かない。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_terminal_enabled', lambda environ=None: True) + calls = [] + monkeypatch.setattr(container.subprocess, 'run', + lambda *a, **k: calls.append(a) or _DockerProc()) + container._maybe_place_terminal_task('carmo', False, None, 1) + assert calls == [] + + +def test_maybe_place_terminal_task_runs_docker_exec(monkeypatch): + """既定 ON で docker exec -i へ tasks.json を stdin 投入する。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_terminal_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'resolve_container_name', lambda *a, **k: 'carmo-dev-1') + monkeypatch.setattr(opener, 'resolve_workdir', lambda *a, **k: '/work/carmo') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + captured = {} + + def fake_run(cmd, **kw): + captured['cmd'] = cmd + captured['input'] = kw.get('input') + return _DockerProc(returncode=0, stdout="placed") + + monkeypatch.setattr(container.subprocess, 'run', fake_run) + container._maybe_place_terminal_task('carmo', None, 1, 1) + cmd = captured['cmd'] + assert cmd[:4] == ['docker', 'exec', '-i', 'carmo-dev-1'] + assert cmd[-1] == '/work/carmo' # workdir は $1 として末尾に渡す + assert '"runOn": "folderOpen"' in captured['input'] + + +def test_maybe_place_terminal_task_failure_does_not_raise(monkeypatch): + """docker exec が例外でも up を倒さない (握り潰す)。""" + from devbase.commands import container + from devbase.editor import opener + monkeypatch.setattr(opener, 'is_open_terminal_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'resolve_container_name', lambda *a, **k: 'carmo-dev-1') + monkeypatch.setattr(opener, 'resolve_workdir', lambda *a, **k: '/work/carmo') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + + def boom(*a, **k): + raise OSError("docker missing") + + monkeypatch.setattr(container.subprocess, 'run', boom) + container._maybe_place_terminal_task('carmo', None, 1, 1) # 例外が出なければ OK + + def test_maybe_open_editor_forwards_compose_file(monkeypatch): """compose_file 引数が open_editor まで伝播する (実コンテナ名問い合わせ用)。""" from devbase.commands import container diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 62be75d..7330617 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -129,6 +129,93 @@ def test_build_attach_uri_adds_leading_slash(): assert uri.endswith("/work/p") +def test_build_attach_uri_nested_ssh_remote_and_context(): + """ssh_host / docker_context 指定でネスト authority + settings.context を組む。""" + uri = opener.build_attach_uri("adminer-dev-1", "/work/adminer", + ssh_host="mac2", docker_context="desktop-linux") + prefix = "vscode-remote://attached-container+" + assert uri.startswith(prefix) + authority, _, path = uri[len(prefix):].partition("/") + assert path == "work/adminer" + hexpart, sep, ssh = authority.partition("@") + assert sep == "@" + assert ssh == "ssh-remote+mac2" + decoded = json.loads(bytes.fromhex(hexpart).decode("utf-8")) + assert decoded == {"containerName": "/adminer-dev-1", + "settings": {"context": "desktop-linux"}} + + +def test_build_attach_uri_ssh_host_only_no_settings(): + """docker_context 無しなら settings は付けず @ssh-remote のみ付く。""" + uri = opener.build_attach_uri("p-dev-1", "/work/p", ssh_host="mac2") + assert "@ssh-remote+mac2/work/p" in uri + hexpart = uri.split("attached-container+")[1].split("@")[0] + assert json.loads(bytes.fromhex(hexpart).decode()) == {"containerName": "/p-dev-1"} + + +# --------------------------------------------------------------------------- +# resolve_editor_ssh_host / resolve_docker_context +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value,expected", [ + (None, None), ("", None), (" ", None), ("mac2", "mac2"), (" mac2 ", "mac2"), +]) +def test_resolve_editor_ssh_host(value, expected): + env = {} if value is None else {"DEVBASE_EDITOR_SSH_HOST": value} + assert opener.resolve_editor_ssh_host(env) == expected + + +def test_resolve_docker_context_explicit_wins(): + assert opener.resolve_docker_context({"DEVBASE_EDITOR_DOCKER_CONTEXT": " desktop-linux "}) \ + == "desktop-linux" + + +def test_resolve_docker_context_from_docker_show(): + def runner(cmd, **kw): + assert cmd == ["docker", "context", "show"] + return _Proc(returncode=0, stdout="desktop-linux\n") + + assert opener.resolve_docker_context({}, runner=runner) == "desktop-linux" + + +def test_resolve_docker_context_none_when_docker_fails(): + assert opener.resolve_docker_context( + {}, runner=lambda cmd, **kw: _Proc(returncode=1, stdout="")) is None + + +def test_resolve_docker_context_none_when_docker_absent(): + def boom(cmd, **kw): + raise FileNotFoundError("docker") + + assert opener.resolve_docker_context({}, runner=boom) is None + + +# --------------------------------------------------------------------------- +# is_open_terminal_enabled (既定 ON) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value,expected", [ + (None, True), ("", False), ("0", False), ("false", False), ("no", False), + ("1", True), ("true", True), ("on", True), ("YES", True), +]) +def test_is_open_terminal_enabled(value, expected): + env = {} if value is None else {"DEVBASE_OPEN_TERMINAL": value} + assert opener.is_open_terminal_enabled(env) is expected + + +# --------------------------------------------------------------------------- +# build_folder_open_tasks_json +# --------------------------------------------------------------------------- + +def test_build_folder_open_tasks_json_is_valid_folderopen_task(): + data = json.loads(opener.build_folder_open_tasks_json()) + assert data["version"] == "2.0.0" + task = data["tasks"][0] + assert task["runOptions"]["runOn"] == "folderOpen" + assert task["presentation"]["reveal"] == "always" + assert task["type"] == "shell" + + # --------------------------------------------------------------------------- # resolve_container_name / resolve_workdir # --------------------------------------------------------------------------- @@ -311,6 +398,42 @@ def test_open_editor_launch_invokes_launcher(monkeypatch): assert cmd[2].endswith("/work/carmo") +def test_open_editor_launch_nested_uri_under_remote_ssh(monkeypatch): + """Remote-SSH (in_vscode + ssh) かつ DEVBASE_EDITOR_SSH_HOST 設定時はネスト URI で launch。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + # docker context show を実行させず固定値に差し替え + monkeypatch.setattr(opener, "resolve_docker_context", + lambda *a, **kw: "desktop-linux") + calls = [] + result = opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock", + "SSH_CONNECTION": "192.168.1.16 5 192.168.1.201 22", + "DEVBASE_EDITOR_SSH_HOST": "mac2"}, + isatty=True, launcher=lambda cmd, env: calls.append(cmd), + ) + assert result == "launch" + uri = calls[0][2] + assert "@ssh-remote+mac2/work/adminer" in uri + hexpart = uri.split("attached-container+")[1].split("@")[0] + decoded = json.loads(bytes.fromhex(hexpart).decode()) + assert decoded["containerName"] == "/adminer-dev-1" + assert decoded["settings"]["context"] == "desktop-linux" + + +def test_open_editor_flat_uri_when_ssh_host_unset(monkeypatch): + """Remote-SSH でも DEVBASE_EDITOR_SSH_HOST 未設定なら従来のフラット URI のまま。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + calls = [] + opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock", + "SSH_CONNECTION": "192.168.1.16 5 192.168.1.201 22"}, + isatty=True, launcher=lambda cmd, env: calls.append(cmd), + ) + assert "@ssh-remote" not in calls[0][2] + + def test_open_editor_skip_when_no_editor(monkeypatch): monkeypatch.setattr(opener.shutil, "which", lambda c: None) calls = [] From 2b745622a28135cdf3fd9c46125e3ed6aa2b8b2e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:04:46 +0000 Subject: [PATCH 02/11] =?UTF-8?q?feat(editor):=20ssh-remote=20=E3=83=9B?= =?UTF-8?q?=E3=82=B9=E3=83=88=E5=90=8D=E3=82=92=20~/.vscode-server=20?= =?UTF-8?q?=E3=81=8B=E3=82=89=E8=87=AA=E5=8B=95=E6=A4=9C=E5=87=BA=20(env?= =?UTF-8?q?=20=E5=A4=89=E6=95=B0=E4=B8=8D=E8=A6=81=E5=8C=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨ホスト Remote-SSH attach に必要な ssh-remote authority ラベル(例 mac2)を、 ssh 先 ~/.vscode-server の File History (entries.json の resource URI に残る ssh-remote%2B) から自動検出するようにし、DEVBASE_EDITOR_SSH_HOST の明示を 不要にした(明示は上書きとして残す)。 - IP / user@IP でのネスト attach は不可と実機確認(既存 ExecServer の authority と 一致が必須、"Parent authority found without ExecServer")。別名一致のみ有効 - resolve_editor_ssh_host: 明示 → ~/.vscode-server 自動検出 → None(フラット) の三段 - 複数 ssh-remote ホストは最新 mtime の entries.json を優先 - docs / PLAN31_3 を自動検出方式へ更新、テスト追加 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/user/environment-variables.md | 14 ++++-- issues/PLAN31_3_up-open-editor.md | 20 ++++++--- lib/devbase/editor/opener.py | 70 ++++++++++++++++++++++++++---- lib/devbase/env/keys.py | 2 +- tests/editor/test_opener.py | 55 ++++++++++++++++++++++- 5 files changed, 139 insertions(+), 22 deletions(-) diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 72797e3..4f3dac0 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -149,7 +149,7 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 | `DEVBASE_OPEN_EDITOR` | 真(`1`/`true`/`yes`/`on`)で `up` 後にエディタを開く(既定: OFF) | | `DEVBASE_EDITOR` | 起動コマンド(既定: `code`)。`cursor` / `code-insiders` 等も可 | | `DEVBASE_OPEN_INDEX` | scale 時に開く dev インスタンス番号(既定: `1`) | -| `DEVBASE_EDITOR_SSH_HOST` | Remote-SSH 跨ホスト構成での ssh-remote ホスト名(例 `mac2`)。下記「跨ホスト」参照 | +| `DEVBASE_EDITOR_SSH_HOST` | Remote-SSH 跨ホスト構成での ssh-remote ホスト名(例 `mac2`)。**通常は `~/.vscode-server` から自動検出**され不要。検出が外れる場合のみ明示。下記「跨ホスト」参照 | | `DEVBASE_EDITOR_DOCKER_CONTEXT` | 跨ホスト時に ssh 先で使う docker context(既定: ホストの `docker context show`) | | `DEVBASE_OPEN_TERMINAL` | 真で `up` 後に folderOpen ターミナル用 `.vscode/tasks.json` を配置(**既定: ON**) | @@ -181,16 +181,22 @@ VS Code 公式には「起動時にターミナルを開く」専用設定が無 手元(例 Windows)の VS Code から Remote-SSH で別ホスト(例 Mac)へ入り、その統合ターミナルで `devbase up` を実行する構成では、コンテナは **ssh 先(Mac)の Docker** 上にあります。このとき `code` の開く要求はクライアント(Windows)へ委譲されるため、フラットな attach URI のままだと **クライアント側の Docker** を見に行きコンテナが見つかりません(「コンテナーにアタッチできません。すでに存在しません」)。 -これを解決するには、ssh-remote ホスト名(手元 `~/.ssh/config` の `Host` 別名。VS Code はこの別名を ssh 先の端末 env に渡さないため自動取得不可)を明示します: +これを解決するには、ネスト URI `vscode-remote://attached-container+@ssh-remote+/work/...` を使い、docker ルックアップを ssh 先(コンテナのある Mac)で行わせます。`` は **手元 `~/.ssh/config` の `Host` 別名**(例 `mac2`)で、これは「今の VS Code 接続の authority ラベル」と完全一致する必要があります(ネスト attach は新規 ssh 接続を張らず既存接続を再利用するため。IP や `user@IP` は "Parent authority found without ExecServer" で不可)。 + +このラベルは VS Code が ssh 先の端末 env に渡さない(`SSH_CONNECTION` は IP のみ)ものの、**devbase は ssh 先(Mac)の `~/.vscode-server` の File History から自動検出**します。よって**通常は設定不要**です。docker context は `docker context show` から自動取得します。 + +自動検出が外れる場合(複数 ssh-remote ホストを使い分けている等)のみ明示します: ```sh # $DEVBASE_ROOT/env など(全プロジェクト共通にしたい場合) DEVBASE_EDITOR_SSH_HOST=mac2 +# 必要なら docker context も明示 +# DEVBASE_EDITOR_DOCKER_CONTEXT=desktop-linux ``` -これで devbase は `vscode-remote://attached-container+@ssh-remote+mac2/work/...`(必要に応じ payload に `settings.context` を埋める)というネスト URI を生成し、docker ルックアップが ssh 先(コンテナのある Mac)で行われて正しくアタッチします。docker context は `docker context show` から自動取得し、`DEVBASE_EDITOR_DOCKER_CONTEXT` で上書きできます。 +解決順は **`DEVBASE_EDITOR_SSH_HOST` 明示 → `~/.vscode-server` 自動検出 → フラット URI**。 -> 同一ホスト構成(手元 Mac/Linux で直接、または ssh 先の Docker にコンテナが無い場合)では `DEVBASE_EDITOR_SSH_HOST` は不要で、従来どおりフラット URI で開きます。 +> 同一ホスト構成(手元 Mac/Linux で直接、または ssh 先の Docker にコンテナが無い場合)では ssh-remote ホストは付かず、従来どおりフラット URI で開きます。 ## ソースファイル変更検出 diff --git a/issues/PLAN31_3_up-open-editor.md b/issues/PLAN31_3_up-open-editor.md index 505459d..cd15965 100644 --- a/issues/PLAN31_3_up-open-editor.md +++ b/issues/PLAN31_3_up-open-editor.md @@ -67,14 +67,17 @@ hex = {"containerName":"/","settings":{"context":"desktop-linux"}} | Mac/Linux ローカル端末 | ✓ | ローカル `code` が attach URI を解決 | | WSL 端末 | ✓ (Windows VS Code) | `code` ラッパ→`code.exe`、Docker Desktop のコンテナへ attach | | VS Code **Remote-SSH 統合端末**(リモート=Mac・**同一ホストの Docker**) | ✓ (クライアント側) | `code` シムが委譲。同一ホストの Docker にコンテナがある場合はフラット URI で解決 | -| VS Code **Remote-SSH 統合端末**(**跨ホスト**: ssh 先 Mac の Docker にコンテナ) | ✓ (要 `DEVBASE_EDITOR_SSH_HOST`) | フラット URI だとクライアント(Windows)の Docker を見て失敗。**ネスト URI `@ssh-remote+`(§2.3)で ssh 先の Docker を解決**。ssh ホスト名は env から取得不可のため明示設定が要る | +| VS Code **Remote-SSH 統合端末**(**跨ホスト**: ssh 先 Mac の Docker にコンテナ) | ✓ (host 自動検出) | フラット URI だとクライアント(Windows)の Docker を見て失敗。**ネスト URI `@ssh-remote+`(§2.3)で ssh 先の Docker を解決**。`` は **ssh 先 `~/.vscode-server` の File History から自動検出**(`DEVBASE_EDITOR_SSH_HOST` で上書き可)。IP/`user@IP` は既存 ExecServer 不一致で不可、別名一致が必須 | | plain SSH(WSL→ssh→Mac 等、VS Code 外) | ✗ → コマンド表示 | IPC hook 無し。手元で叩く `code` コマンドを提示するのが上限 | | CI / 非TTY / `code` 不在 | ✗ → info スキップ | エディタ起動の前提を満たさない | → 跨ホスト(手元 Windows VS Code → Remote-SSH→Mac で `devbase up`、コンテナは Mac の -Docker)が最頻ユースケース。**`DEVBASE_EDITOR_SSH_HOST`(例 `mac2`)の設定で自動成立**。 -ssh ホスト名(クライアント `~/.ssh/config` の Host 別名)は VS Code が ssh 先端末 env に -渡さない(`SSH_CONNECTION` は IP のみ)ため自動取得できず、明示が必須(実機調査で確認)。 +Docker)が最頻ユースケース。ssh ホスト名(クライアント `~/.ssh/config` の Host 別名)は +VS Code が ssh 先端末 env に渡さない(`SSH_CONNECTION` は IP のみ)が、**ssh 先 Mac の +`~/.vscode-server` の File History(resource URI の `ssh-remote%2B`)から自動検出** +できるため**通常は設定不要で自動成立**する。検出が外れる場合のみ `DEVBASE_EDITOR_SSH_HOST` +で上書き。IP/`user@IP` は「ネスト attach は新規 ssh 接続を張らず既存 ExecServer の authority +と一致が必須」のため不可(実機: "Parent authority found without ExecServer")。 plain ssh はコマンド提示で degrade。 ## 3. 既存コード調査結果 @@ -178,9 +181,12 @@ env 解釈は既存 `_parse_env_assignment`(`container.py:121`)に合わせ - ~~VS Code Remote-SSH 統合端末でのクライアント側 attach は実機検証が必要~~ → **検証済み(2026-06-13)**。跨ホストではフラット URI だと失敗し、ネスト URI `@ssh-remote+` + `settings.context` で成立することを確認(§2.3/§2.4 を訂正)。 -- ssh ホスト名(`DEVBASE_EDITOR_SSH_HOST`)は env 自動取得不可のため**ユーザ明示が前提**。 - 未設定の跨ホストではフラット URI にフォールバックし、従来同様アタッチ失敗ダイアログが出る - (実害は無いが体験は劣化)。`$DEVBASE_ROOT/env` への 1 行設定を案内する。 +- ssh ホスト名は env には来ないが **`~/.vscode-server` の File History から自動検出**する + ようにした(実機: ホストは `ssh-remote%2Bmac2` の 1 種のみで一意に取れた)。検出は VS Code + 内部データ依存のヒューリスティック(multi-host / バージョン差で外し得る)で、最新 mtime の + entries.json を優先し、外れる場合は `DEVBASE_EDITOR_SSH_HOST` 明示・最終的にフラット URI へ degrade。 +- IP / `user@IP` でのネスト attach は不可(既存 Remote-SSH 接続=ExecServer の authority ラベルと + 完全一致が必須。新規 ssh 接続は張らない)。実機で "Parent authority found without ExecServer" を確認。 - **plain SSH(VS Code 外)では自動オープン不可**。コマンド提示で degrade(変更なし)。 - 統合ターミナル自動表示は `.vscode/tasks.json`(folderOpen) 配置で実現。VS Code 公式に 起動時ターミナル設定は無く(`hideOnStartup` は復元セッションの表示制御のみ)folderOpen diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 7edbf6b..d245995 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -23,6 +23,7 @@ import json import os import platform +import re import shlex import shutil import subprocess @@ -37,6 +38,9 @@ # DEVBASE_OPEN_EDITOR を真と解釈する値 (大小無視) _TRUTHY = {"1", "true", "yes", "on"} +# resource URI 中の ssh-remote authority ラベルを拾う ('+' は URL エンコードで %2B)。 +_SSH_REMOTE_RE = re.compile(r"ssh-remote(?:\+|%2[Bb])([A-Za-z0-9._@-]+)") + @dataclass(frozen=True) class EditorContext: @@ -323,18 +327,66 @@ def resolve_workdir(environ=None, project_name: Optional[str] = None) -> str: return f"/work/{repo}" if repo else "/work" -def resolve_editor_ssh_host(environ=None) -> Optional[str]: - """Remote-SSH ネスト URI 用の ssh ホスト名 (``DEVBASE_EDITOR_SSH_HOST``)。 +def _detect_ssh_host_from_vscode(vscode_server_dir: str) -> Optional[str]: + """``~/.vscode-server`` の File History から ssh-remote authority ラベルを推測する。 + + Remote-SSH / attached-container 窓で開いたファイルの resource URI が + ``data/User/History/*/entries.json`` に ``ssh-remote%2B`` (URL エンコード) / + ``ssh-remote+`` 形で残るため、そこから ```` (= クライアントの接続ラベル。 + 例 ``mac2``) を回収する。複数ホストが見つかった場合は **最後に使われた (entries.json + の mtime が最新の) ホスト**を返す。見つからなければ None。 + + .. note:: VS Code 内部データ依存のヒューリスティックで、バージョン差や multi-host 運用で + 外し得る。確実性が要る場合は ``DEVBASE_EDITOR_SSH_HOST`` を明示する (本関数より優先)。 + """ + history = os.path.join(vscode_server_dir, "data", "User", "History") + if not os.path.isdir(history): + return None + best: dict = {} # host -> 最新 mtime + for root, _dirs, files in os.walk(history): + for name in files: + if name != "entries.json": # resource authority は entries.json に載る + continue + path = os.path.join(root, name) + try: + mtime = os.path.getmtime(path) + with open(path, encoding="utf-8", errors="ignore") as f: + text = f.read() + except OSError: + continue + for host in _SSH_REMOTE_RE.findall(text): + if host and (host not in best or mtime > best[host]): + best[host] = mtime + if not best: + return None + return max(best, key=best.get) + + +def resolve_editor_ssh_host(environ=None, + vscode_server_dir: Optional[str] = None) -> Optional[str]: + """Remote-SSH ネスト URI 用の ssh ホスト名 (authority ラベル) を解決する。 + + 優先順位: - 値はクライアント (手元 VS Code) の ``~/.ssh/config`` の Host 別名 (例 ``mac2``)。 - VS Code はこの別名を Remote-SSH 先 (Mac) の端末 env に渡さない (SSH_CONNECTION は - IP のみ) ため自動取得できず、明示設定が要る (PLAN31_3 §2.4 / 実機調査)。未設定なら - None を返し、:func:`build_attach_uri` はフラット URI にフォールバックする。 + 1. ``DEVBASE_EDITOR_SSH_HOST`` 明示 (最優先・確実) + 2. ``~/.vscode-server`` の File History からの自動推測 + (:func:`_detect_ssh_host_from_vscode`) + + ネスト attach は新規 ssh 接続を張らず **既存 Remote-SSH 接続 (ExecServer) の authority + ラベルと完全一致**する必要がある (実機確認: IP / user@IP は "Parent authority found + without ExecServer" で不可)。そのラベル (例 ``mac2``) はクライアント側の名前で SSH_CONNECTION + 等の env には現れない (IP のみ) ため、自動取得は VS Code が残す痕跡からの回収に頼る。 + どちらでも得られなければ None で :func:`build_attach_uri` はフラット URI に degrade する。 """ env = os.environ if environ is None else environ - value = env.get("DEVBASE_EDITOR_SSH_HOST") - value = value.strip() if value else "" - return value or None + explicit = env.get("DEVBASE_EDITOR_SSH_HOST") + if explicit and explicit.strip(): + return explicit.strip() + base = vscode_server_dir or os.path.expanduser("~/.vscode-server") + try: + return _detect_ssh_host_from_vscode(base) + except Exception: # noqa: BLE001 - 自動推測失敗で up を倒さない + return None def resolve_docker_context(environ=None, runner: Optional[Callable] = None) -> Optional[str]: diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index 0e9fe30..e3dddf2 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -58,6 +58,6 @@ def gcp_credentials_key(profile: str) -> str: DEVBASE_EDITOR = "DEVBASE_EDITOR" # 任意。起動コマンド (既定 code) DEVBASE_OPEN_INDEX = "DEVBASE_OPEN_INDEX" # 任意。開く dev インスタンス番号 (既定 1) # Remote-SSH 跨ホスト構成 (Windows VS Code → ssh → Mac のコンテナ) 用。 -DEVBASE_EDITOR_SSH_HOST = "DEVBASE_EDITOR_SSH_HOST" # 任意。ssh-remote ホスト名 (例 mac2) +DEVBASE_EDITOR_SSH_HOST = "DEVBASE_EDITOR_SSH_HOST" # 任意。ssh-remote ホスト名 (例 mac2)。通常は ~/.vscode-server から自動検出 DEVBASE_EDITOR_DOCKER_CONTEXT = "DEVBASE_EDITOR_DOCKER_CONTEXT" # 任意。ssh 先 docker context (既定: docker context show) DEVBASE_OPEN_TERMINAL = "DEVBASE_OPEN_TERMINAL" # 真偽。up 後に folderOpen ターミナル tasks.json を配置 (既定 ON) diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 7330617..37f0d78 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from dataclasses import dataclass import pytest @@ -17,6 +18,23 @@ class _Proc: stdout: str = "" +@pytest.fixture(autouse=True) +def _isolate_vscode_home(monkeypatch, tmp_path): + """``~/.vscode-server`` からの ssh host 自動推測がテスト実行環境に依存しないよう + HOME を空の tmp に隔離する (このリポジトリの dev コンテナ自体が実 .vscode-server を + 持つため、隔離しないと resolve_editor_ssh_host が実ホスト名を拾ってしまう)。""" + monkeypatch.setenv("HOME", str(tmp_path)) + + +def _write_history(base: str, subdir: str, content: str) -> str: + """``/data/User/History//entries.json`` を書いてパスを返す。""" + path = os.path.join(base, "data", "User", "History", subdir, "entries.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return path + + # --------------------------------------------------------------------------- # detect_context # --------------------------------------------------------------------------- @@ -160,11 +178,46 @@ def test_build_attach_uri_ssh_host_only_no_settings(): @pytest.mark.parametrize("value,expected", [ (None, None), ("", None), (" ", None), ("mac2", "mac2"), (" mac2 ", "mac2"), ]) -def test_resolve_editor_ssh_host(value, expected): +def test_resolve_editor_ssh_host_explicit_or_none(value, expected): + # HOME は autouse fixture で空 tmp に隔離済みのため自動推測は None。 env = {} if value is None else {"DEVBASE_EDITOR_SSH_HOST": value} assert opener.resolve_editor_ssh_host(env) == expected +def test_resolve_editor_ssh_host_autodetect_single(tmp_path): + base = str(tmp_path / ".vscode-server") + _write_history(base, "abc", json.dumps( + {"resource": "vscode-remote://attached-container%2Bxx@ssh-remote%2Bmac2/work/x"})) + assert opener.resolve_editor_ssh_host({}, vscode_server_dir=base) == "mac2" + + +def test_resolve_editor_ssh_host_autodetect_plus_form(tmp_path): + base = str(tmp_path / ".vscode-server") + _write_history(base, "abc", '"vscode-remote://ssh-remote+devbox/work/p"') + assert opener.resolve_editor_ssh_host({}, vscode_server_dir=base) == "devbox" + + +def test_resolve_editor_ssh_host_autodetect_picks_newest(tmp_path): + base = str(tmp_path / ".vscode-server") + old = _write_history(base, "a", "ssh-remote%2BmacOLD/work") + new = _write_history(base, "b", "ssh-remote%2BmacNEW/work") + os.utime(old, (1000, 1000)) + os.utime(new, (2000, 2000)) + assert opener.resolve_editor_ssh_host({}, vscode_server_dir=base) == "macNEW" + + +def test_resolve_editor_ssh_host_autodetect_none_when_absent(tmp_path): + assert opener.resolve_editor_ssh_host( + {}, vscode_server_dir=str(tmp_path / "nope")) is None + + +def test_resolve_editor_ssh_host_explicit_beats_autodetect(tmp_path): + base = str(tmp_path / ".vscode-server") + _write_history(base, "a", "ssh-remote%2Bauto/work") + assert opener.resolve_editor_ssh_host( + {"DEVBASE_EDITOR_SSH_HOST": "explicit"}, vscode_server_dir=base) == "explicit" + + def test_resolve_docker_context_explicit_wins(): assert opener.resolve_docker_context({"DEVBASE_EDITOR_DOCKER_CONTEXT": " desktop-linux "}) \ == "desktop-linux" From e2747d56576f935f4ca3e33177f1903015be24bb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:08:46 +0000 Subject: [PATCH 03/11] =?UTF-8?q?perf(editor):=20ssh=20host=20=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E6=A4=9C=E5=87=BA=E3=82=92=20mtime=20=E9=99=8D?= =?UTF-8?q?=E9=A0=86=E3=83=BB=E6=9C=80=E5=88=9D=E3=81=AE=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E3=81=A7=E6=89=93=E3=81=A1=E5=88=87=E3=82=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _detect_ssh_host_from_vscode が全 entries.json を読んでいたのを、mtime 降順で 新しいファイルから順に読み最初の ssh-remote 一致で即 return する実装へ変更。 History が巨大でも全読み込みを避け devbase up の遅延を防ぐ (gemini 指摘 / major)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/editor/opener.py | 38 ++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index d245995..72e25fa 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -342,24 +342,28 @@ def _detect_ssh_host_from_vscode(vscode_server_dir: str) -> Optional[str]: history = os.path.join(vscode_server_dir, "data", "User", "History") if not os.path.isdir(history): return None - best: dict = {} # host -> 最新 mtime + # entries.json 候補を mtime 降順で集め、**新しい方から 1 ファイルずつ読み、最初に + # ssh-remote ホストが見つかった時点で即 return** する (History が数千ファイルに + # 膨れても全読み込みを避け、devbase up の遅延を防ぐ)。mtime 収集は stat のみで安価。 + candidates = [] for root, _dirs, files in os.walk(history): - for name in files: - if name != "entries.json": # resource authority は entries.json に載る - continue - path = os.path.join(root, name) - try: - mtime = os.path.getmtime(path) - with open(path, encoding="utf-8", errors="ignore") as f: - text = f.read() - except OSError: - continue - for host in _SSH_REMOTE_RE.findall(text): - if host and (host not in best or mtime > best[host]): - best[host] = mtime - if not best: - return None - return max(best, key=best.get) + if "entries.json" not in files: # resource authority は entries.json に載る + continue + path = os.path.join(root, "entries.json") + try: + candidates.append((os.path.getmtime(path), path)) + except OSError: + continue + for _mtime, path in sorted(candidates, key=lambda t: t[0], reverse=True): + try: + with open(path, encoding="utf-8", errors="ignore") as f: + text = f.read() + except OSError: + continue + match = _SSH_REMOTE_RE.search(text) + if match: + return match.group(1) + return None def resolve_editor_ssh_host(environ=None, From 4d6f274844f3e8de8364f614217b2dd4edb04475 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:14:24 +0000 Subject: [PATCH 04/11] =?UTF-8?q?feat(editor):=20ssh=20host=20=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E6=A4=9C=E5=87=BA=E3=82=92=E8=A4=87=E6=95=B0=E3=82=A8?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=82=BF=E3=81=AE=E3=82=B5=E3=83=BC=E3=83=90?= =?UTF-8?q?=E3=83=BC=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=81=B8=E6=8B=A1=E5=BC=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEVBASE_EDITOR=cursor / code-insiders 等でも跨ホスト自動検出が効くよう、 ~/.vscode-server に加え ~/.cursor-server / ~/.vscode-server-insiders / ~/.vscodium-server / ~/.windsurf-server を横断し、全 entries.json から最新 mtime のホストを採用する (_detect_ssh_host_from_dirs)。gemini 指摘 / major。 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/user/environment-variables.md | 2 +- lib/devbase/editor/opener.py | 72 ++++++++++++++++++++---------- tests/editor/test_opener.py | 11 +++++ 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 4f3dac0..9ba8a6a 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -183,7 +183,7 @@ VS Code 公式には「起動時にターミナルを開く」専用設定が無 これを解決するには、ネスト URI `vscode-remote://attached-container+@ssh-remote+/work/...` を使い、docker ルックアップを ssh 先(コンテナのある Mac)で行わせます。`` は **手元 `~/.ssh/config` の `Host` 別名**(例 `mac2`)で、これは「今の VS Code 接続の authority ラベル」と完全一致する必要があります(ネスト attach は新規 ssh 接続を張らず既存接続を再利用するため。IP や `user@IP` は "Parent authority found without ExecServer" で不可)。 -このラベルは VS Code が ssh 先の端末 env に渡さない(`SSH_CONNECTION` は IP のみ)ものの、**devbase は ssh 先(Mac)の `~/.vscode-server` の File History から自動検出**します。よって**通常は設定不要**です。docker context は `docker context show` から自動取得します。 +このラベルは VS Code が ssh 先の端末 env に渡さない(`SSH_CONNECTION` は IP のみ)ものの、**devbase は ssh 先(Mac)の VS Code 系サーバーディレクトリ(`~/.vscode-server` / `~/.cursor-server` / `~/.vscode-server-insiders` 等)の File History から自動検出**します(`DEVBASE_EDITOR` で cursor 等を使う場合も横断)。よって**通常は設定不要**です。docker context は `docker context show` から自動取得します。 自動検出が外れる場合(複数 ssh-remote ホストを使い分けている等)のみ明示します: diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 72e25fa..e037e80 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -41,6 +41,16 @@ # resource URI 中の ssh-remote authority ラベルを拾う ('+' は URL エンコードで %2B)。 _SSH_REMOTE_RE = re.compile(r"ssh-remote(?:\+|%2[Bb])([A-Za-z0-9._@-]+)") +# ssh host 自動検出で探索する VS Code 系サーバーディレクトリ (DEVBASE_EDITOR で +# code / code-insiders / cursor / vscodium 等を使い分けても拾えるよう横断する)。 +_SERVER_DIR_CANDIDATES = ( + "~/.vscode-server", + "~/.vscode-server-insiders", + "~/.cursor-server", + "~/.vscodium-server", + "~/.windsurf-server", +) + @dataclass(frozen=True) class EditorContext: @@ -327,33 +337,36 @@ def resolve_workdir(environ=None, project_name: Optional[str] = None) -> str: return f"/work/{repo}" if repo else "/work" -def _detect_ssh_host_from_vscode(vscode_server_dir: str) -> Optional[str]: - """``~/.vscode-server`` の File History から ssh-remote authority ラベルを推測する。 +def _detect_ssh_host_from_dirs(server_dirs) -> Optional[str]: + """複数の VS Code 系サーバーディレクトリの File History を横断して ssh-remote + authority ラベルを推測する。 Remote-SSH / attached-container 窓で開いたファイルの resource URI が - ``data/User/History/*/entries.json`` に ``ssh-remote%2B`` (URL エンコード) / - ``ssh-remote+`` 形で残るため、そこから ```` (= クライアントの接続ラベル。 - 例 ``mac2``) を回収する。複数ホストが見つかった場合は **最後に使われた (entries.json - の mtime が最新の) ホスト**を返す。見つからなければ None。 + ``/data/User/History/*/entries.json`` に ``ssh-remote%2B`` (URL + エンコード) / ``ssh-remote+`` 形で残るため、そこから ```` (= クライアントの + 接続ラベル。例 ``mac2``) を回収する。 + + 全ディレクトリの ``entries.json`` 候補を **mtime 降順**で集め、**新しい方から 1 ファイル + ずつ読み、最初に ssh-remote ホストが見つかった時点で即 return** する (History が数千 + ファイルに膨れても全読み込みを避け、devbase up の遅延を防ぐ)。mtime 収集は stat のみで安価。 + 見つからなければ None。 .. note:: VS Code 内部データ依存のヒューリスティックで、バージョン差や multi-host 運用で 外し得る。確実性が要る場合は ``DEVBASE_EDITOR_SSH_HOST`` を明示する (本関数より優先)。 """ - history = os.path.join(vscode_server_dir, "data", "User", "History") - if not os.path.isdir(history): - return None - # entries.json 候補を mtime 降順で集め、**新しい方から 1 ファイルずつ読み、最初に - # ssh-remote ホストが見つかった時点で即 return** する (History が数千ファイルに - # 膨れても全読み込みを避け、devbase up の遅延を防ぐ)。mtime 収集は stat のみで安価。 - candidates = [] - for root, _dirs, files in os.walk(history): - if "entries.json" not in files: # resource authority は entries.json に載る - continue - path = os.path.join(root, "entries.json") - try: - candidates.append((os.path.getmtime(path), path)) - except OSError: + candidates = [] # (mtime, path) + for base in server_dirs: + history = os.path.join(base, "data", "User", "History") + if not os.path.isdir(history): continue + for root, _dirs, files in os.walk(history): + if "entries.json" not in files: # resource authority は entries.json に載る + continue + path = os.path.join(root, "entries.json") + try: + candidates.append((os.path.getmtime(path), path)) + except OSError: + continue for _mtime, path in sorted(candidates, key=lambda t: t[0], reverse=True): try: with open(path, encoding="utf-8", errors="ignore") as f: @@ -366,6 +379,11 @@ def _detect_ssh_host_from_vscode(vscode_server_dir: str) -> Optional[str]: return None +def _detect_ssh_host_from_vscode(vscode_server_dir: str) -> Optional[str]: + """単一サーバーディレクトリ版 (:func:`_detect_ssh_host_from_dirs` の薄ラッパ)。""" + return _detect_ssh_host_from_dirs([vscode_server_dir]) + + def resolve_editor_ssh_host(environ=None, vscode_server_dir: Optional[str] = None) -> Optional[str]: """Remote-SSH ネスト URI 用の ssh ホスト名 (authority ラベル) を解決する。 @@ -373,22 +391,28 @@ def resolve_editor_ssh_host(environ=None, 優先順位: 1. ``DEVBASE_EDITOR_SSH_HOST`` 明示 (最優先・確実) - 2. ``~/.vscode-server`` の File History からの自動推測 - (:func:`_detect_ssh_host_from_vscode`) + 2. VS Code 系サーバーディレクトリ (``~/.vscode-server`` / ``~/.cursor-server`` / + ``~/.vscode-server-insiders`` 等) の File History からの自動推測 + (:func:`_detect_ssh_host_from_dirs`) ネスト attach は新規 ssh 接続を張らず **既存 Remote-SSH 接続 (ExecServer) の authority ラベルと完全一致**する必要がある (実機確認: IP / user@IP は "Parent authority found without ExecServer" で不可)。そのラベル (例 ``mac2``) はクライアント側の名前で SSH_CONNECTION 等の env には現れない (IP のみ) ため、自動取得は VS Code が残す痕跡からの回収に頼る。 どちらでも得られなければ None で :func:`build_attach_uri` はフラット URI に degrade する。 + + ``vscode_server_dir`` はテスト用の単一ディレクトリ差し替え口 (指定時はそれだけを探索)。 """ env = os.environ if environ is None else environ explicit = env.get("DEVBASE_EDITOR_SSH_HOST") if explicit and explicit.strip(): return explicit.strip() - base = vscode_server_dir or os.path.expanduser("~/.vscode-server") + if vscode_server_dir is not None: + server_dirs = [vscode_server_dir] + else: + server_dirs = [os.path.expanduser(d) for d in _SERVER_DIR_CANDIDATES] try: - return _detect_ssh_host_from_vscode(base) + return _detect_ssh_host_from_dirs(server_dirs) except Exception: # noqa: BLE001 - 自動推測失敗で up を倒さない return None diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 37f0d78..cc33ed5 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -211,6 +211,17 @@ def test_resolve_editor_ssh_host_autodetect_none_when_absent(tmp_path): {}, vscode_server_dir=str(tmp_path / "nope")) is None +def test_detect_ssh_host_across_multiple_server_dirs(tmp_path): + """cursor-server / vscode-server を横断し最新 mtime のホストを返す。""" + vsc = str(tmp_path / ".vscode-server") + cur = str(tmp_path / ".cursor-server") + old = _write_history(vsc, "a", "ssh-remote%2BmacOLD/work") + new = _write_history(cur, "b", "ssh-remote%2BmacNEW/work") + os.utime(old, (1000, 1000)) + os.utime(new, (2000, 2000)) + assert opener._detect_ssh_host_from_dirs([vsc, cur]) == "macNEW" + + def test_resolve_editor_ssh_host_explicit_beats_autodetect(tmp_path): base = str(tmp_path / ".vscode-server") _write_history(base, "a", "ssh-remote%2Bauto/work") From 2d6622d4f17234b570733af36b6ca6c989dcc389 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:19:12 +0000 Subject: [PATCH 05/11] =?UTF-8?q?perf(editor):=20ssh=20host=20=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E6=A4=9C=E5=87=BA=E3=81=AE=20entries.json=20=E8=B5=B0?= =?UTF-8?q?=E6=9F=BB=E3=82=92=E4=B8=8A=E9=99=90=20200=20=E4=BB=B6=E3=81=AB?= =?UTF-8?q?=E5=88=B6=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 無マッチ時に全 entries.json を read するのを避けるため、mtime 降順で新しい方から 最大 200 件のみ内容を読む (該当ホストは直近接続のファイルに載るため安全)。gemini 指摘 / minor。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/editor/opener.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index e037e80..e3d84ff 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -51,6 +51,10 @@ "~/.windsurf-server", ) +# ssh host 自動検出で内容を読む entries.json の上限 (mtime 降順で新しい方から)。 +# 該当ホストは直近接続のファイルにあるため、無マッチ時に全件 read するのを防ぐ。 +_HISTORY_SCAN_LIMIT = 200 + @dataclass(frozen=True) class EditorContext: @@ -367,7 +371,8 @@ def _detect_ssh_host_from_dirs(server_dirs) -> Optional[str]: candidates.append((os.path.getmtime(path), path)) except OSError: continue - for _mtime, path in sorted(candidates, key=lambda t: t[0], reverse=True): + newest_first = sorted(candidates, key=lambda t: t[0], reverse=True) + for _mtime, path in newest_first[:_HISTORY_SCAN_LIMIT]: try: with open(path, encoding="utf-8", errors="ignore") as f: text = f.read() From 52e3a41628a43a293aab4a13488018f1d1a3e4d6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:25:35 +0000 Subject: [PATCH 06/11] =?UTF-8?q?perf(editor):=20open=5Feditor=20=E3=81=AE?= =?UTF-8?q?=20skip=20=E7=B5=8C=E8=B7=AF=E3=82=92=20URI=20=E8=A7=A3?= =?UTF-8?q?=E6=B1=BA=E5=89=8D=E3=81=AB=20early=20return?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skip 判定 (非TTY/CI・code 不在等) でも resolve_container_name(docker compose ps) や resolve_docker_context(docker context show) を呼んでいたのを、skip を先に return して 無駄な外部コマンド実行を回避。gemini 指摘 / minor。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/editor/opener.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index e3d84ff..fea3185 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -505,6 +505,12 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, display = resolve_editor_display(env) # print 用 (必ず非 None) plan = decide_action(ctx, editor_available=bool(editor)) + # skip は URI 解決の前に early return する。skip 経路 (非 TTY/CI・code 不在等) で + # docker compose ps / docker context show 等の外部コマンドを無駄に叩かないため。 + if plan.action == "skip": + logger.info("エディタの自動オープンをスキップ: %s", plan.reason) + return "skip" + container = resolve_container_name(dev_service_name, project_name, index, compose_file=compose_file) # SSH コンテキストでのみネスト authority (@ssh-remote+host) を組む。ssh_host が @@ -515,10 +521,6 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, uri = build_attach_uri(container, workdir, ssh_host=ssh_host, docker_context=docker_context) - if plan.action == "skip": - logger.info("エディタの自動オープンをスキップ: %s", plan.reason) - return "skip" - if plan.action == "print_command": # 提示コマンドは手元 (ローカル) で実行する前提。ローカルに code が無くても # 提示できるよう display (which 非依存) を用いる。 From 2a7c0667b96e458a23f93e0dcf3111ad5e635d6d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:29:59 +0000 Subject: [PATCH 07/11] =?UTF-8?q?feat(editor):=20DEVBASE=5FEDITOR=5FSSH=5F?= =?UTF-8?q?HOST/DOCKER=5FCONTEXT=20=E3=81=AE=E7=A9=BA=E6=96=87=E5=AD=97?= =?UTF-8?q?=E3=81=A7=E3=82=AA=E3=83=97=E3=83=88=E3=82=A2=E3=82=A6=E3=83=88?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env が空文字 ("") の場合を「明示的オプトアウト」として扱い、ssh host は自動推測を スキップしてフラット URI 強制、docker context は settings.context を付けない。 判定を `is not None` に変更 (定義されていれば値を尊重し、空なら None)。gemini 指摘 / major。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/editor/opener.py | 12 ++++++++---- tests/editor/test_opener.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index fea3185..1bf137b 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -410,8 +410,10 @@ def resolve_editor_ssh_host(environ=None, """ env = os.environ if environ is None else environ explicit = env.get("DEVBASE_EDITOR_SSH_HOST") - if explicit and explicit.strip(): - return explicit.strip() + if explicit is not None: + # 明示設定を最優先。空文字 ("") は **自動推測のオプトアウト** (= None → + # フラット URI 強制) として扱い、`~/.vscode-server` 探索へ進ませない。 + return explicit.strip() or None if vscode_server_dir is not None: server_dirs = [vscode_server_dir] else: @@ -431,8 +433,10 @@ def resolve_docker_context(environ=None, runner: Optional[Callable] = None) -> O """ env = os.environ if environ is None else environ explicit = env.get("DEVBASE_EDITOR_DOCKER_CONTEXT") - if explicit and explicit.strip(): - return explicit.strip() + if explicit is not None: + # 空文字 ("") は明示的オプトアウト (settings.context を付けない) として扱い、 + # `docker context show` を呼ばない。 + return explicit.strip() or None run = runner or subprocess.run try: proc = run(["docker", "context", "show"], diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index cc33ed5..c72c6fa 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -229,6 +229,25 @@ def test_resolve_editor_ssh_host_explicit_beats_autodetect(tmp_path): {"DEVBASE_EDITOR_SSH_HOST": "explicit"}, vscode_server_dir=base) == "explicit" +def test_resolve_editor_ssh_host_empty_string_opts_out(tmp_path): + """空文字は自動推測のオプトアウト (None) として扱い、history 探索しない。""" + base = str(tmp_path / ".vscode-server") + _write_history(base, "a", "ssh-remote%2Bauto/work") + assert opener.resolve_editor_ssh_host( + {"DEVBASE_EDITOR_SSH_HOST": ""}, vscode_server_dir=base) is None + assert opener.resolve_editor_ssh_host( + {"DEVBASE_EDITOR_SSH_HOST": " "}, vscode_server_dir=base) is None + + +def test_resolve_docker_context_empty_string_opts_out(): + """空文字は settings.context を付けないオプトアウト。runner を呼ばない。""" + def boom(cmd, **kw): + raise AssertionError("docker context show should not run") + + assert opener.resolve_docker_context( + {"DEVBASE_EDITOR_DOCKER_CONTEXT": ""}, runner=boom) is None + + def test_resolve_docker_context_explicit_wins(): assert opener.resolve_docker_context({"DEVBASE_EDITOR_DOCKER_CONTEXT": " desktop-linux "}) \ == "desktop-linux" From 8b7001e66f691860a2c264bb93cf1c18dee12d48 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:37:50 +0000 Subject: [PATCH 08/11] =?UTF-8?q?perf(editor):=20=E3=82=B3=E3=83=B3?= =?UTF-8?q?=E3=83=86=E3=83=8A=E5=90=8D=E8=A7=A3=E6=B1=BA=E3=82=92=201=20?= =?UTF-8?q?=E5=9B=9E=E3=81=AB=E9=9B=86=E7=B4=84=E3=81=97=20docker=20compos?= =?UTF-8?q?e=20ps=20=E3=81=AE=E4=BA=8C=E9=87=8D=E5=AE=9F=E8=A1=8C=E3=82=92?= =?UTF-8?q?=E5=9B=9E=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _maybe_place_terminal_task が解決した実コンテナ名を返し、_maybe_open_editor → open_editor へ container_name として渡して resolve_container_name(docker compose ps) の重複呼び出しを除去。gemini 指摘 / minor。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/container.py | 26 ++++++++++++++++++-------- lib/devbase/editor/opener.py | 9 ++++++--- tests/cli/test_project_dispatch.py | 4 +++- tests/editor/test_opener.py | 19 +++++++++++++++++++ 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 3995c07..0e6ef92 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -358,7 +358,7 @@ def _auto_snapshot() -> None: def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, - compose_file=None) -> None: + compose_file=None, container_name: Optional[str] = None) -> None: """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env @@ -371,6 +371,10 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], ``compose_file`` は実コンテナ名問い合わせ用の override compose。``up`` 起動時と 同じファイルを渡さないと ``{dev}-{index}`` サービスが見えず実名取得に失敗する。 未指定なら ``.docker-compose.scale.yml`` が存在すればそれ、無ければ None。 + + ``container_name`` が渡されれば :func:`opener.open_editor` 内の + ``resolve_container_name`` (= ``docker compose ps``) をスキップして再利用する + (``_maybe_place_terminal_task`` が既に解決済みの名前を使い回し二重実行を避ける)。 """ from devbase.editor import opener @@ -408,6 +412,7 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], workdir=workdir, index=open_index, compose_file=compose_file, + container_name=container_name, ) except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) @@ -415,7 +420,7 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], def _maybe_place_terminal_task(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, - compose_file=None) -> None: + compose_file=None) -> Optional[str]: """`up` 後、開く dev コンテナの作業ディレクトリへ folderOpen ターミナル tasks.json を配置。 フォルダを開いた時に統合ターミナルを自動表示するための ``.vscode/tasks.json`` を、 @@ -426,12 +431,15 @@ def _maybe_place_terminal_task(project_name: str, open_flag: Optional[bool], 有効判定は ``open_flag`` (CLI ``--open-terminal``/``--no-open-terminal``) が優先、None なら env ``DEVBASE_OPEN_TERMINAL`` (既定 ON)。配置失敗は warning に握り潰し ``up`` を倒さない。 ``open_index`` は開くインスタンスに合わせる (範囲外は 1 へフォールバック)。 + + 解決した実コンテナ名を返す (無効時は None)。直後の :func:`_maybe_open_editor` へ渡して + ``resolve_container_name`` (= ``docker compose ps``) の二重実行を避けるため。 """ from devbase.editor import opener enabled = open_flag if open_flag is not None else opener.is_open_terminal_enabled() if not enabled: - return + return None if open_index is None: raw = os.environ.get('DEVBASE_OPEN_INDEX') @@ -465,14 +473,15 @@ def _maybe_place_terminal_task(project_name: str, open_flag: Optional[bool], ) except Exception as e: # noqa: BLE001 - 配置失敗で up を倒さない logger.warning("ターミナル用 tasks.json の配置に失敗しましたが続行します: %s", e) - return + return container # 名前解決は済んでいるのでエディタ側で再利用させる if proc.returncode != 0: logger.warning("ターミナル用 tasks.json の配置に失敗しましたが続行します: %s", (proc.stderr or "").strip()) - return + return container if (proc.stdout or "").strip() == "placed": logger.info("[6/6] 統合ターミナル自動表示用 tasks.json を配置: %s/.vscode/tasks.json", workdir) + return container def cmd_up(project_name: str = None, scale: int = None, @@ -546,10 +555,11 @@ def cmd_up(project_name: str = None, scale: int = None, _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) # エディタを開く前に tasks.json を置く (開いた瞬間に folderOpen が効くように)。 - _maybe_place_terminal_task(project_name, open_terminal, open_index, scale, - compose_file=override_file) + # 解決済みコンテナ名を editor 側へ渡し docker compose ps の二重実行を避ける。 + dev_container = _maybe_place_terminal_task(project_name, open_terminal, open_index, + scale, compose_file=override_file) _maybe_open_editor(project_name, open_editor, open_index, scale, - compose_file=override_file) + compose_file=override_file, container_name=dev_container) logger.info("=== Deploy completed successfully ===") return 0 diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 1bf137b..d8facad 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -493,7 +493,8 @@ def _launch(cmd: list, env: dict) -> None: def open_editor(*, project_name: str, dev_service_name: str, workdir: str, - index: int = 1, compose_file=None, environ=None, + index: int = 1, compose_file=None, container_name: Optional[str] = None, + environ=None, isatty: Optional[bool] = None, system: Optional[str] = None, launcher: Optional[Callable[[list, dict], None]] = None) -> str: """dev コンテナへ接続した VS Code を開く / コマンド提示 / スキップする。 @@ -502,6 +503,8 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / ``system`` は :func:`detect_context` への差し替え口 (テスト用)。``compose_file`` は実コンテナ名問い合わせ時に起動と同じ override compose を ``-f`` で渡すため。 + ``container_name`` が渡されれば :func:`resolve_container_name` (= ``docker compose + ps``) をスキップしてそれを使う (呼び出し側で解決済みの名前を使い回す)。 """ env = os.environ if environ is None else environ ctx = detect_context(env, isatty=isatty, system=system) @@ -515,8 +518,8 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, logger.info("エディタの自動オープンをスキップ: %s", plan.reason) return "skip" - container = resolve_container_name(dev_service_name, project_name, index, - compose_file=compose_file) + container = container_name or resolve_container_name( + dev_service_name, project_name, index, compose_file=compose_file) # SSH コンテキストでのみネスト authority (@ssh-remote+host) を組む。ssh_host が # 設定されていれば跨ホスト構成と見なし docker context も解決して埋める。非 SSH では # 従来のフラット URI (ローカル/WSL/同一ホスト Remote-SSH) を維持する。 diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 224ffff..6b6ea4b 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -471,11 +471,13 @@ def fake_run(cmd, **kw): return _DockerProc(returncode=0, stdout="placed") monkeypatch.setattr(container.subprocess, 'run', fake_run) - container._maybe_place_terminal_task('carmo', None, 1, 1) + result = container._maybe_place_terminal_task('carmo', None, 1, 1) cmd = captured['cmd'] assert cmd[:4] == ['docker', 'exec', '-i', 'carmo-dev-1'] assert cmd[-1] == '/work/carmo' # workdir は $1 として末尾に渡す assert '"runOn": "folderOpen"' in captured['input'] + # 解決済みコンテナ名を返し editor 側で再利用させる (docker compose ps 二重実行回避) + assert result == 'carmo-dev-1' def test_maybe_place_terminal_task_failure_does_not_raise(monkeypatch): diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index c72c6fa..4f7a696 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -517,6 +517,25 @@ def test_open_editor_flat_uri_when_ssh_host_unset(monkeypatch): assert "@ssh-remote" not in calls[0][2] +def test_open_editor_uses_given_container_name(monkeypatch): + """container_name を渡すと resolve_container_name を呼ばずそれを使う。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + + def boom(*a, **k): + raise AssertionError("resolve_container_name should not be called") + + monkeypatch.setattr(opener, "resolve_container_name", boom) + calls = [] + opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + container_name="preresolved-dev-1", environ={}, isatty=True, + launcher=lambda cmd, env: calls.append(cmd), + ) + uri = calls[0][2] + hexpart = uri.split("attached-container+")[1].split("/work")[0] + assert json.loads(bytes.fromhex(hexpart).decode())["containerName"] == "/preresolved-dev-1" + + def test_open_editor_skip_when_no_editor(monkeypatch): monkeypatch.setattr(opener.shutil, "which", lambda c: None) calls = [] From 6a6b0e4a4d00b81597be17885b2918a5d5c4347e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:43:14 +0000 Subject: [PATCH 09/11] =?UTF-8?q?fix(editor):=20folderOpen=20=E3=82=BF?= =?UTF-8?q?=E3=83=BC=E3=83=9F=E3=83=8A=E3=83=AB=E3=82=BF=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E3=82=92=20SHELL=20=E6=9C=AA=E8=A8=AD=E5=AE=9A=E3=81=A7?= =?UTF-8?q?=E3=82=82=E8=B5=B7=E5=8B=95=E5=8F=AF=E8=83=BD=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_folder_open_tasks_json を type:shell + ${env:SHELL} から type:process + /bin/sh -lc 'exec "${SHELL:-/bin/sh}"' へ変更。SHELL 未設定コンテナで command が 空になりタスクが即失敗する問題を修正。codex 指摘 / major。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/editor/opener.py | 10 +++++++--- tests/editor/test_opener.py | 5 ++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index d8facad..46a0336 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -141,7 +141,10 @@ def build_folder_open_tasks_json() -> str: VS Code 公式には「起動時にターミナルを開く」単独設定が無く (``hideOnStartup`` は復元 された永続セッションを隠すか否かに過ぎず新規生成はしない)、``runOn: folderOpen`` の タスクが新規ターミナルを出せる唯一の方法 (docs/terminal/*, docs/debugtest/tasks)。 - ``reveal: always`` でパネルを前面に出し、対話シェル (``$SHELL``) を起動する。 + ``reveal: always`` でパネルを前面に出し対話シェルを起動する。``type: process`` で + ``/bin/sh -lc 'exec "${SHELL:-/bin/sh}"'`` を直接起動し、``$SHELL`` 未設定のコンテナでも + ``/bin/sh`` にフォールバックして必ずシェルが立ち上がるようにする (``type: shell`` + + ``${env:SHELL}`` だと未設定時に command が空になりタスクが即失敗する)。 .. note:: 自動実行には2つの user 設定ゲートがあり devbase からは制御できない: Workspace Trust (信頼済みフォルダのみ自動実行) と ``task.allowAutomaticTasks`` @@ -152,8 +155,9 @@ def build_folder_open_tasks_json() -> str: "tasks": [ { "label": "devbase: open terminal", - "type": "shell", - "command": "${env:SHELL}", + "type": "process", + "command": "/bin/sh", + "args": ["-lc", 'exec "${SHELL:-/bin/sh}"'], "isBackground": True, "problemMatcher": [], "presentation": { diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 4f7a696..f722379 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -296,7 +296,10 @@ def test_build_folder_open_tasks_json_is_valid_folderopen_task(): task = data["tasks"][0] assert task["runOptions"]["runOn"] == "folderOpen" assert task["presentation"]["reveal"] == "always" - assert task["type"] == "shell" + # SHELL 未設定でも /bin/sh にフォールバックして必ずシェルを起動する (空 command 回避) + assert task["type"] == "process" + assert task["command"] == "/bin/sh" + assert "SHELL:-/bin/sh" in " ".join(task["args"]) # --------------------------------------------------------------------------- From 0ab0840ac914a72d4fe74a9041c14e861add87f2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:50:34 +0000 Subject: [PATCH 10/11] =?UTF-8?q?fix(editor):=20plain=20SSH=20=E3=81=A7?= =?UTF-8?q?=E3=81=AF=20ssh=20host=20=E8=87=AA=E5=8B=95=E6=A4=9C=E5=87=BA?= =?UTF-8?q?=E3=82=92=E6=8A=91=E6=AD=A2=20+=20open=5Findex=20=E9=9B=86?= =?UTF-8?q?=E7=B4=84=20+=20scandir=20=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plain SSH (VS Code 外) は既存 ExecServer 不在でネスト URI が動かないため、自動検出は in_vscode の時だけ有効化 (resolve_editor_ssh_host に auto_detect 引数追加)。plain SSH は 明示設定時のみネスト URI を採用。codex 指摘 / major - open_index の env フォールバック+範囲チェックを _resolve_open_index に集約 (重複排除)。gemini / minor - ssh host 自動検出の History 走査を os.walk → os.scandir (固定深さ 1 階層) に変更。gemini / minor Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/container.py | 46 ++++++++++++++++--------------- lib/devbase/editor/opener.py | 30 +++++++++++++------- tests/editor/test_opener.py | 17 ++++++++++++ 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 0e6ef92..99f9ace 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -356,6 +356,28 @@ def _auto_snapshot() -> None: logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) +def _resolve_open_index(open_index: Optional[int], scale: int) -> int: + """開く dev インスタンス番号を解決する (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 + + ``1..scale`` の範囲外 (0・負数・``scale`` 超過) は存在しないコンテナを指し原因不明な + 起動失敗を招くため、警告を出して 1 へフォールバックする。:func:`_maybe_place_terminal_task` + と :func:`_maybe_open_editor` で共有し env フォールバック・範囲チェックの重複を避ける。 + """ + if open_index is None: + raw = os.environ.get('DEVBASE_OPEN_INDEX') + try: + open_index = int(raw) if raw else 1 + except ValueError: + open_index = 1 + if not (1 <= open_index <= scale): + logger.warning( + "open index %d is out of range (1..%d); falling back to 1", + open_index, scale, + ) + open_index = 1 + return open_index + + def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, compose_file=None, container_name: Optional[str] = None) -> None: @@ -382,20 +404,7 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], if not enabled: return - if open_index is None: - raw = os.environ.get('DEVBASE_OPEN_INDEX') - try: - open_index = int(raw) if raw else 1 - except ValueError: - open_index = 1 - - # 起動済みインスタンス範囲 (1..scale) の検証。範囲外は既定 (1) へフォールバック。 - if not (1 <= open_index <= scale): - logger.warning( - "open index %d is out of range (1..%d); falling back to 1", - open_index, scale, - ) - open_index = 1 + open_index = _resolve_open_index(open_index, scale) # 実コンテナ名問い合わせ用の compose file: 明示指定がなければ override が # 存在すればそれを使う (起動時と同じ file を docker compose ps へ渡す)。 @@ -441,14 +450,7 @@ def _maybe_place_terminal_task(project_name: str, open_flag: Optional[bool], if not enabled: return None - if open_index is None: - raw = os.environ.get('DEVBASE_OPEN_INDEX') - try: - open_index = int(raw) if raw else 1 - except ValueError: - open_index = 1 - if not (1 <= open_index <= scale): - open_index = 1 + open_index = _resolve_open_index(open_index, scale) if compose_file is None and _SCALE_COMPOSE_FILE.exists(): compose_file = _SCALE_COMPOSE_FILE diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 46a0336..bacc38e 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -365,12 +365,15 @@ def _detect_ssh_host_from_dirs(server_dirs) -> Optional[str]: candidates = [] # (mtime, path) for base in server_dirs: history = os.path.join(base, "data", "User", "History") - if not os.path.isdir(history): + # ローカル履歴は History//entries.json の固定深さなので、os.walk で + # 全階層を再帰せず os.scandir で 1 階層下のみ走査して I/O を抑える。 + try: + with os.scandir(history) as it: + subdirs = [e.path for e in it if e.is_dir()] + except OSError: continue - for root, _dirs, files in os.walk(history): - if "entries.json" not in files: # resource authority は entries.json に載る - continue - path = os.path.join(root, "entries.json") + for sub in subdirs: + path = os.path.join(sub, "entries.json") # resource authority はここに載る try: candidates.append((os.path.getmtime(path), path)) except OSError: @@ -394,7 +397,8 @@ def _detect_ssh_host_from_vscode(vscode_server_dir: str) -> Optional[str]: def resolve_editor_ssh_host(environ=None, - vscode_server_dir: Optional[str] = None) -> Optional[str]: + vscode_server_dir: Optional[str] = None, + auto_detect: bool = True) -> Optional[str]: """Remote-SSH ネスト URI 用の ssh ホスト名 (authority ラベル) を解決する。 優先順位: @@ -411,6 +415,9 @@ def resolve_editor_ssh_host(environ=None, どちらでも得られなければ None で :func:`build_attach_uri` はフラット URI に degrade する。 ``vscode_server_dir`` はテスト用の単一ディレクトリ差し替え口 (指定時はそれだけを探索)。 + ``auto_detect`` を False にすると 2 (自動推測) を行わず明示設定のみで判定する。plain SSH + (VS Code 外) は既存 ExecServer を前提にできずネスト URI が動かないため、呼び出し側 + (:func:`open_editor`) は ``in_vscode`` の時だけ ``auto_detect=True`` で呼ぶ。 """ env = os.environ if environ is None else environ explicit = env.get("DEVBASE_EDITOR_SSH_HOST") @@ -418,6 +425,8 @@ def resolve_editor_ssh_host(environ=None, # 明示設定を最優先。空文字 ("") は **自動推測のオプトアウト** (= None → # フラット URI 強制) として扱い、`~/.vscode-server` 探索へ進ませない。 return explicit.strip() or None + if not auto_detect: + return None if vscode_server_dir is not None: server_dirs = [vscode_server_dir] else: @@ -524,10 +533,11 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, container = container_name or resolve_container_name( dev_service_name, project_name, index, compose_file=compose_file) - # SSH コンテキストでのみネスト authority (@ssh-remote+host) を組む。ssh_host が - # 設定されていれば跨ホスト構成と見なし docker context も解決して埋める。非 SSH では - # 従来のフラット URI (ローカル/WSL/同一ホスト Remote-SSH) を維持する。 - ssh_host = resolve_editor_ssh_host(env) if ctx.is_ssh else None + # SSH コンテキストでのみネスト authority (@ssh-remote+host) を組む。自動推測は + # VS Code Remote-SSH 統合端末 (in_vscode) の時だけ有効にする — plain SSH (VS Code 外) + # は既存 ExecServer を前提にできずネスト URI が動かないため、明示設定時のみ採用する。 + ssh_host = (resolve_editor_ssh_host(env, auto_detect=ctx.in_vscode) + if ctx.is_ssh else None) docker_context = resolve_docker_context(env) if ssh_host else None uri = build_attach_uri(container, workdir, ssh_host=ssh_host, docker_context=docker_context) diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index f722379..155a10c 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -211,6 +211,23 @@ def test_resolve_editor_ssh_host_autodetect_none_when_absent(tmp_path): {}, vscode_server_dir=str(tmp_path / "nope")) is None +def test_resolve_editor_ssh_host_auto_detect_false_skips_history(tmp_path): + """auto_detect=False (plain SSH 相当) は history を見ず None (明示が無ければ)。""" + base = str(tmp_path / ".vscode-server") + _write_history(base, "a", "ssh-remote%2Bmac2/work") + assert opener.resolve_editor_ssh_host( + {}, vscode_server_dir=base, auto_detect=False) is None + # auto_detect=True なら拾える (対比) + assert opener.resolve_editor_ssh_host( + {}, vscode_server_dir=base, auto_detect=True) == "mac2" + + +def test_resolve_editor_ssh_host_auto_detect_false_honors_explicit(tmp_path): + """auto_detect=False でも明示設定は尊重する。""" + assert opener.resolve_editor_ssh_host( + {"DEVBASE_EDITOR_SSH_HOST": "mac2"}, auto_detect=False) == "mac2" + + def test_detect_ssh_host_across_multiple_server_dirs(tmp_path): """cursor-server / vscode-server を横断し最新 mtime のホストを返す。""" vsc = str(tmp_path / ".vscode-server") From 049bf78758a9eb978a94e39b95b2683889356ad0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 19:55:30 +0000 Subject: [PATCH 11/11] =?UTF-8?q?docs(editor):=20opener=20=E3=83=A2?= =?UTF-8?q?=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=20docstring=20=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E6=85=8B=E3=81=B8=E6=9B=B4=E6=96=B0=20(=E3=83=8D?= =?UTF-8?q?=E3=82=B9=E3=83=88=20authority=20=E3=82=B5=E3=83=9D=E3=83=BC?= =?UTF-8?q?=E3=83=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 冒頭 docstring が「ネスト authority は公式未サポート」と旧設計のまま残り実装・PLAN・ docs と矛盾していたのを、attached-container+...@ssh-remote+ 実サポート + 自動検出 + plain SSH degrade の現設計へ更新。codex 指摘 (round 4/5/10 で line 13 が diff 外のため inline 投稿 422 となり取りこぼしていた件) / minor。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/editor/opener.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index bacc38e..7992664 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -10,9 +10,15 @@ 応じた正しいクライアントへ開ける。 - コンテナ attach URI は ``{"containerName":"/<実コンテナ名>"}`` を hex 化した authority を持つ (:func:`build_attach_uri`)。 -- ``ssh-remote+host`` と ``attached-container+...`` を 1 本に合成する記法は - 公式未サポート (microsoft/vscode#242489)。よって VS Code 外の plain SSH では - クライアントへ push できず、手元で叩くコマンドを提示する degrade に留める。 +- **跨ホスト (手元 VS Code → Remote-SSH(host) → ssh 先の Docker 上コンテナ) では + ネスト authority ``attached-container+...@ssh-remote+`` を用いる**。これは + 実機で動作する (VS Code 1.124 / Dev Containers 0.459 で確認。当初 microsoft/vscode#242489 + を「未サポート」と解釈していたが誤りだった)。```` は手元クライアントの ssh 接続 + ラベルで、env には現れないため ssh 先の ``~/.vscode-server`` 等の File History から + 自動検出する (:func:`resolve_editor_ssh_host`)。``settings.context`` で ssh 先 docker + context を指定する。 +- VS Code 外の plain SSH は既存 ExecServer を前提にできずネスト URI が動かないため、 + 自動検出は行わず (明示設定時のみ)、手元で叩くコマンドを提示する degrade に留める。 本モジュールの関数は実 docker / VS Code を必要とせず、``environ`` 等を引数で 差し替えてテストできるよう副作用を :func:`open_editor` に集約している。