From 528250e420771f344e2a97128a05ccfebfd16b5c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 3 Sep 2026 10:11:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(container):=20gemini=20=E3=81=AE=20Vertex?= =?UTF-8?q?=20AI=20=E5=BC=B7=E5=88=B6=E3=82=92=E3=82=84=E3=82=81=E3=80=81?= =?UTF-8?q?=E8=B5=B7=E5=8B=95=E5=AE=9A=E7=BE=A9=E3=82=92=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E3=81=B8=E5=87=BA=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit containers/base/Dockerfile が .bashrc へ書く alias が、全コンテナで GOOGLE_GENAI_USE_VERTEXAI=true を無条件に強制していた。Vertex を使わない プロジェクト (GOOGLE_CLOUD_PROJECT を空にした with-ai-dev / project-trygroup-prd) でも Vertex 経路へ倒れ、gemini が使えなくなる。 - 起動定義を containers/base/ai-cli-aliases.sh へ出し、Dockerfile は COPY と .bashrc からの読み込み 1 行だけにした。インラインの echo はテストできない - gemini から GOOGLE_GENAI_USE_VERTEXAI の前置を落とした。認証方式は環境の 同変数で選ぶ。GOOGLE_CLOUD_PROJECT は gcloud や BigQuery でも使う変数であり 認証方式の opt-in ではないため、推論には使わない - 全 6 定義から "$@" を落とした。alias の "$@" は alias の引数ではなく シェルの位置パラメータへ展開され、引数を渡す働きをしていない - claudb に command を挟んだ。挟まないと claude の alias まで展開され --dangerously-skip-permissions が 2 度渡る (実コンテナで再現を確認) - Docker を起動せず振る舞いを固定するテストを 37 件追加した 移行 (実施済み): 共通へ GOOGLE_GENAI_USE_VERTEXAI=true を設定し、Vertex を 使わない 2 プロジェクトの env で空に打ち消した。projects/*/env は devbase-ext リポジトリの実体のため、この PR には含まれない。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9tinzTAbF1LVEYvfUgzKx --- containers/base/Dockerfile | 13 +- containers/base/ai-cli-aliases.sh | 27 ++++ docs/user/container-operations.md | 46 ++++++ issues/PLAN50_gemini-vertex-alias.md | 107 +++++++++++++ tests/containers/test_ai_cli_aliases.py | 200 ++++++++++++++++++++++++ 5 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 containers/base/ai-cli-aliases.sh create mode 100644 tests/containers/test_ai_cli_aliases.py diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index f984c034..80757f78 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -199,16 +199,13 @@ RUN set -eux; \ ~/.local/share/pnpm \ ~/.claude.json.backup* +# AI CLI の起動定義。~/.bashrc へ直接書かずファイルにしているのは、Docker を +# 起動せずに tests/containers/test_ai_cli_aliases.py で振る舞いを固定するため。 +COPY --chmod=0644 ai-cli-aliases.sh /etc/devbase/ai-cli-aliases.sh + RUN set -eux; \ - # AI CLI aliases echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; \ - echo 'alias claude='\''claude --dangerously-skip-permissions "$@"'\''' >> ~/.bashrc; \ - echo 'alias claudb='\''CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude --dangerously-skip-permissions "$@"'\''' >> ~/.bashrc; \ - echo 'alias gemini='\''GOOGLE_GENAI_USE_VERTEXAI=true gemini --yolo "$@"'\''' >> ~/.bashrc; \ - echo 'alias codex='\''codex --dangerously-bypass-approvals-and-sandbox "$@"'\''' >> ~/.bashrc; \ - echo 'alias kiro='\''kiro-cli chat --trust-all-tools "$@"'\''' >> ~/.bashrc; \ - echo 'alias agy='\''agy --dangerously-skip-permissions "$@"'\''' >> ~/.bashrc; \ - echo 'complete -o default claudb kiro' >> ~/.bashrc; \ + echo '. /etc/devbase/ai-cli-aliases.sh' >> ~/.bashrc; \ git config --global credential.helper store; \ # Configure Claude Code hooks mkdir -p ~/.claude; \ diff --git a/containers/base/ai-cli-aliases.sh b/containers/base/ai-cli-aliases.sh new file mode 100644 index 00000000..e21ac82b --- /dev/null +++ b/containers/base/ai-cli-aliases.sh @@ -0,0 +1,27 @@ +# AI CLI の起動定義。対話シェルの ~/.bashrc から読み込まれる。 +# +# ここに置くのは alias と補完の登録だけで、副作用を持つ処理は書かない。 +# ~/.bashrc へ直接書き出すのをやめてファイルにしているのは、Docker を起動せずに +# tests/containers/test_ai_cli_aliases.py で振る舞いを固定するためである。 +# +# 定義に "$@" は書かない。alias の "$@" は alias の引数ではなくシェルの位置 +# パラメータへ展開されるため、引数を渡す働きをしない。引数は alias の展開で +# 末尾へ付く。 +# +# 認証方式を決める環境変数もここでは設定しない。プロジェクトが env で選んだ値を +# 起動定義が上書きしてしまうため (PLAN50)。gemini の Vertex AI / OAuth の +# 切り替えは GOOGLE_GENAI_USE_VERTEXAI で行う。 + +# 各 CLI は開発コンテナの中でだけ使う前提のため、確認プロンプトを省くオプションを +# 既定で付ける。コンテナの外へ影響しない。 +alias claude='claude --dangerously-skip-permissions' +# `command` を挟むのは claude の alias を展開させないため。挟まないと +# --dangerously-skip-permissions が 2 度渡り、claude 側の定義を変えたときに +# claudb まで一緒に変わる。 +alias claudb='CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 command claude --dangerously-skip-permissions' +alias gemini='gemini --yolo' +alias codex='codex --dangerously-bypass-approvals-and-sandbox' +alias kiro='kiro-cli chat --trust-all-tools' +alias agy='agy --dangerously-skip-permissions' + +complete -o default claudb kiro diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index c8624eb1..12ac6d55 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -389,6 +389,52 @@ gemini "テストを書いて" codex "リファクタリングして" ``` +## AI CLI の起動定義 + +コンテナの対話シェルでは、各 AI CLI が確認プロンプトを省くオプション付きで起動します。 +定義は `/etc/devbase/ai-cli-aliases.sh` にあり、`~/.bashrc` から読み込まれます。 + +| コマンド | 起動するもの | +|---------|-------------| +| `claude` | `claude --dangerously-skip-permissions` | +| `claudb` | `claude --dangerously-skip-permissions`(Amazon Bedrock 経由。`CLAUDE_CODE_USE_BEDROCK=1` / `AWS_REGION=us-west-2` を前置) | +| `gemini` | `gemini --yolo` | +| `codex` | `codex --dangerously-bypass-approvals-and-sandbox` | +| `kiro` | `kiro-cli chat --trust-all-tools` | +| `agy` | `agy --dangerously-skip-permissions` | + +引数はそのまま後ろへ渡ります(`gemini "テストを書いて"` は `gemini --yolo "テストを書いて"`)。 +素の CLI を使いたいときは `command gemini ...` のように `command` を前置します。 + +### gemini の認証方式 + +**起動定義は認証方式を決めません。** 環境変数 `GOOGLE_GENAI_USE_VERTEXAI` で選びます。 + +| 設定 | 経路 | +|------|------| +| `GOOGLE_GENAI_USE_VERTEXAI=true` | Vertex AI(`GOOGLE_CLOUD_PROJECT` と ADC が要る) | +| 未設定・空 | `~/.gemini/settings.json` の `selectedType` に従う(`oauth-personal` など) | + +Vertex AI を既定にするなら共通の設定へ入れます。 + +```bash +devbase env set GOOGLE_GENAI_USE_VERTEXAI=true +``` + +Vertex AI を使わないプロジェクト(別会社のアカウントで OAuth ログインするなど)は、 +`projects//env` で空にして共通の値を打ち消します。`GOOGLE_CLOUD_PROJECT` と同じやり方です。 + +``` +GOOGLE_GENAI_USE_VERTEXAI= +GOOGLE_CLOUD_PROJECT= +``` + +> **`GOOGLE_CLOUD_PROJECT` は認証方式を選ぶ変数ではありません。** gcloud や BigQuery でも使う +> プロジェクト指定なので、OAuth を使いながら別の用途で設定していても Vertex へは切り替わりません。 + +`~/.gemini` はアカウントグループのボリューム(`/persistent/group/.gemini`)にあるため、 +OAuth のログインはコンテナを作り直しても残ります。 + ## tmux(ターミナル)の既定設定 コンテナ内の tmux には、devbase 共通の既定設定 `/etc/tmux.conf` が入っています diff --git a/issues/PLAN50_gemini-vertex-alias.md b/issues/PLAN50_gemini-vertex-alias.md index d3162d02..f64a3b78 100644 --- a/issues/PLAN50_gemini-vertex-alias.md +++ b/issues/PLAN50_gemini-vertex-alias.md @@ -261,3 +261,110 @@ BigQuery などの目的で同じ変数を設定した瞬間、意図せず Vert 設定した瞬間に Vertex へ倒れるため。移行の節を追加した) - 追加: **AC5 / AC7 — すべての起動定義から `"$@"` を落とす** (2026-09-03、利用者の指示による。alias では意図した働きをしていないため) + +--- + +# 実装計画 + +## 関連リンク + +- 設計 PR [#148](https://github.com/devbasex/devbase/pull/148)(マージ済み。本ファイルの前半 2 節) + +## モード + +`standard`。全コンテナの `gemini` の振る舞いが変わる。触るのは `containers/base` の 1 領域。 + +## 目的と非目的 + +達成したい状態: + +- 起動定義が認証方式を決めない。`GOOGLE_GENAI_USE_VERTEXAI` を設定する記述がどこにも無い +- 全 6 定義から `"$@"` が消える +- 起動定義が Docker 抜きでテストできる場所にある + +やらないこと: + +- `GOOGLE_CLOUD_PROJECT` がプロジェクトの空上書きを無視してコンテナへ漏れる件の調査 +- `~/.gemini/settings.json` の管理 +- 他の AI CLI の起動オプションの変更(`"$@"` の除去を除く) + +## 受け入れ条件 + +本ファイル前半の AC1〜AC12 をそのまま使う。 + +## 修正対象 + +| ファイル | 変更 | +| --- | --- | +| `containers/base/ai-cli-aliases.sh` | 新規。起動定義の唯一の置き場 | +| `containers/base/Dockerfile` | 上記を `COPY` し `.bashrc` から読み込ませる。インラインの `echo` 群(202-209 行)を落とす | +| `tests/containers/test_ai_cli_aliases.py` | 新規 | +| `docs/user/container-operations.md` | AI CLI の起動定義と、gemini の認証方式の選び方を書く | + +## タスク分解 + +### Task 1: 起動定義をファイルへ出す(振る舞いを変えない) + +- **対象ファイル:** `containers/base/ai-cli-aliases.sh`、`containers/base/Dockerfile`、`tests/containers/test_ai_cli_aliases.py` +- **変更内容:** インラインの `echo ... >> ~/.bashrc` 群と同じ内容を `ai-cli-aliases.sh` へ移す。**この段階では `GOOGLE_GENAI_USE_VERTEXAI=true` も `"$@"` もそのまま残す。** Dockerfile は `COPY --chmod=0644 ai-cli-aliases.sh /etc/devbase/ai-cli-aliases.sh` と、`.bashrc` へ読み込み行を 1 行足す形にする。 +- **満たす受け入れ条件:** AC9 の土台(テストできる場所へ移す) +- **進め方:** 移した内容が現行と一致することを固定するテストを先に書く。振る舞いを変えないため、テストは移行前後で同じ結果になる。 + +### Task 2: `GOOGLE_GENAI_USE_VERTEXAI` の強制をやめる + +- **対象ファイル:** `containers/base/ai-cli-aliases.sh`、`tests/containers/test_ai_cli_aliases.py` +- **変更内容:** `gemini` の定義から `GOOGLE_GENAI_USE_VERTEXAI=true` の前置を落とす。 +- **満たす受け入れ条件:** AC1、AC2、AC3 +- **進め方:** 環境の `GOOGLE_GENAI_USE_VERTEXAI` がそのまま子プロセスへ届くこと(設定時/未設定時)を固定する失敗するテストを先に書く。 + +### Task 3: 全定義から `"$@"` を落とす + +- **対象ファイル:** `containers/base/ai-cli-aliases.sh`、`tests/containers/test_ai_cli_aliases.py` +- **変更内容:** 6 定義すべての `"$@"` を削除する。 +- **満たす受け入れ条件:** AC4、AC5、AC6、AC7 +- **進め方:** 引数がそのままの順序で実体へ届くことを 6 定義分 parametrize で固定し、定義文字列に `$@` が無いことも確認する。 + +### Task 4: ドキュメント + +- **対象ファイル:** `docs/user/container-operations.md` +- **変更内容:** AI CLI の起動定義の一覧と、gemini の認証方式を `GOOGLE_GENAI_USE_VERTEXAI` で選ぶこと、プロジェクト単位で打ち消せることを書く。 +- **満たす受け入れ条件:** AC11 +- **進め方:** ドキュメントのみ。 + +### Task 5: 移行 + +- **対象:** 利用者の環境(リポジトリの差分ではない) +- **変更内容:** `devbase env set GOOGLE_GENAI_USE_VERTEXAI=true` と、`with-ai-dev` / `project-trygroup-prd` の `env` への空上書き。 +- **満たす受け入れ条件:** AC12 +- **進め方:** 実施の可否を利用者に確認し、結果を完了報告に残す。 + +## 影響範囲 + +- 全コンテナの対話シェル。ベースイメージの再ビルドとコンテナ再作成で初めて反映される +- `containers/general` `go` `php` `php85` `latex` `bi-tools` `trygroup` は `FROM devbase-base` のため、base の再ビルド後に各イメージも焼き直しが要る + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 移行を忘れて既存の Vertex 利用者が OAuth へ倒れる | AC12 で完了報告に実施状況を残す。CHANGELOG にも移行手順を書く | +| `.bashrc` からの読み込み行が対話シェル以外で悪さをする | 読み込むファイルは alias 定義と `complete` のみで、副作用を持たない | +| `"$@"` の除去で引数が欠ける | AC7 で 6 定義すべての引数の届き方をテストで固定する | + +## 切り戻し手順 + +コード変更のみ。PR の revert で戻る。移行で共通機密へ入れた `GOOGLE_GENAI_USE_VERTEXAI` は、 +revert 後も残るが害はない(旧定義が同じ値を前置していたため)。 + +## 完了の定義 + +- [ ] AC1〜AC12 をすべて満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run pytest tests/ -q` が通る +- [ ] `shellcheck --severity=error containers/base/ai-cli-aliases.sh` が通る +- [ ] `python -m compileall -q lib bin` が通る + +## 範囲を広げた判断 + +- `claudb` が `claude` の alias まで展開し `--dangerously-skip-permissions` を 2 度渡していた + (実コンテナで再現を確認)。書き換えている行そのものが原因で、放置するとテストが重複を + 「正」として固定するため範囲に入れ、`command claude` を挟んで解消した(2026-09-03) diff --git a/tests/containers/test_ai_cli_aliases.py b/tests/containers/test_ai_cli_aliases.py new file mode 100644 index 00000000..4432172c --- /dev/null +++ b/tests/containers/test_ai_cli_aliases.py @@ -0,0 +1,200 @@ +"""AI CLI の起動定義 (PLAN50) + +``containers/base/ai-cli-aliases.sh`` を対話シェル相当 (``shopt -s expand_aliases``) で +source し、PATH の先頭へ置いたスタブが受け取る引数と環境を突き合わせる。Docker には +依存しない (``tests/containers/test_entrypoint_*.py`` と同じ方式)。 + +固定する契約: + +- 起動定義は認証方式を決めない。``GOOGLE_GENAI_USE_VERTEXAI`` を設定する記述を持たず、 + 環境の値をそのまま素通しする +- 定義に ``$@`` を含まない。引数は alias の展開で末尾へ付く +- 各 CLI が起動する実体と固定オプションが変わらない +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ALIASES = Path(__file__).resolve().parents[2] / "containers" / "base" / "ai-cli-aliases.sh" + + +def _statements() -> str: + """コメントと空行を除いた、実際に実行される行だけを返す。 + + 「書かない」ことを確かめる assertion がコメントに反応すると、なぜ書かないのかを + 説明した注記まで禁じることになる。 + """ + return "\n".join( + line for line in ALIASES.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + +#: 定義名 -> (実体, 固定オプション) +LAUNCHERS = { + "claude": ("claude", ["--dangerously-skip-permissions"]), + "claudb": ("claude", ["--dangerously-skip-permissions"]), + "gemini": ("gemini", ["--yolo"]), + "codex": ("codex", ["--dangerously-bypass-approvals-and-sandbox"]), + "kiro": ("kiro-cli", ["chat", "--trust-all-tools"]), + "agy": ("agy", ["--dangerously-skip-permissions"]), +} + +#: 起動時に前置される環境変数。認証方式を選ぶものはここに現れてはならない。 +EXPECTED_PREFIXED_ENV = { + "claudb": {"CLAUDE_CODE_USE_BEDROCK": "1", "AWS_REGION": "us-west-2"}, +} + + +def _stub_dir(tmp_path: Path, *names: str) -> Path: + """受け取った引数と環境を出力するスタブを PATH 用ディレクトリへ作る。""" + d = tmp_path / "bin" + d.mkdir(exist_ok=True) + for name in names: + stub = d / name + stub.write_text( + "#!/bin/sh\n" + 'printf "ARGS:%s\\n" "$*"\n' + 'printf "VERTEXAI:%s\\n" "${GOOGLE_GENAI_USE_VERTEXAI-}"\n' + 'printf "BEDROCK:%s\\n" "${CLAUDE_CODE_USE_BEDROCK-}"\n' + 'printf "AWSREGION:%s\\n" "${AWS_REGION-}"\n' + ) + stub.chmod(0o755) + return d + + +def _run(script: str, tmp_path: Path, env: dict | None = None, + stubs: tuple[str, ...] = ()) -> subprocess.CompletedProcess: + """対話シェル相当で起動定義を読み込み ``script`` を実行する。""" + bin_dir = _stub_dir(tmp_path, *stubs) if stubs else tmp_path / "bin" + base = { + k: v for k, v in os.environ.items() + if not k.startswith(("GOOGLE_", "CLAUDE_", "AWS_", "DEVBASE_")) + } + base["PATH"] = f"{bin_dir}:{base.get('PATH', '')}" + base.update(env or {}) + return subprocess.run( + ["bash", "-c", f'shopt -s expand_aliases; . "{ALIASES}"\n{script}'], + capture_output=True, text=True, env=base, + ) + + +def _field(result: subprocess.CompletedProcess, key: str) -> str | None: + for line in result.stdout.splitlines(): + if line.startswith(f"{key}:"): + return line[len(key) + 1:] + return None + + +# =========================================================================== +# AC1: 認証方式を決める記述を持たない +# =========================================================================== + +def test_aliases_file_does_not_set_vertexai(): + """起動定義に GOOGLE_GENAI_USE_VERTEXAI を書かない。 + + 書くと、プロジェクトが環境で選んだ認証方式を起動定義が上書きしてしまう。 + """ + assert "GOOGLE_GENAI_USE_VERTEXAI" not in _statements() + + +# =========================================================================== +# AC2 / AC3: 環境を素通しする +# =========================================================================== + +@pytest.mark.parametrize("given,expected", [ + ({"GOOGLE_GENAI_USE_VERTEXAI": "true"}, "true"), + ({"GOOGLE_GENAI_USE_VERTEXAI": "false"}, "false"), + ({"GOOGLE_GENAI_USE_VERTEXAI": ""}, ""), + ({}, ""), +]) +def test_gemini_passes_through_vertexai_env(tmp_path, given, expected): + """gemini は GOOGLE_GENAI_USE_VERTEXAI を足しも引きもしない。""" + result = _run("gemini", tmp_path, env=given, stubs=("gemini",)) + + assert _field(result, "VERTEXAI") == expected + + +# =========================================================================== +# AC4 / AC7: 引数がそのまま届く +# =========================================================================== + +@pytest.mark.parametrize("name", sorted(LAUNCHERS)) +def test_launcher_forwards_arguments(tmp_path, name): + """固定オプションの後ろに、渡した引数がその順序で並ぶ。""" + real, options = LAUNCHERS[name] + result = _run(f"{name} alpha beta", tmp_path, stubs=(real,)) + + assert _field(result, "ARGS") == " ".join([*options, "alpha", "beta"]) + + +@pytest.mark.parametrize("name", sorted(LAUNCHERS)) +def test_launcher_runs_without_arguments(tmp_path, name): + """引数なしでも固定オプションだけで起動する。""" + real, options = LAUNCHERS[name] + result = _run(name, tmp_path, stubs=(real,)) + + assert _field(result, "ARGS") == " ".join(options) + + +# =========================================================================== +# AC5: 定義に $@ を含まない +# =========================================================================== + +@pytest.mark.parametrize("name", sorted(LAUNCHERS)) +def test_definition_has_no_positional_parameters(tmp_path, name): + """alias の "$@" はシェルの位置パラメータに展開され、引数を渡す働きをしない。""" + result = _run(f'printf "ARGS:%s\\n" "$(alias {name})"', tmp_path) + definition = _field(result, "ARGS") + + # 定義が読めていないと "$@" を含まないことが自明に成り立つため、先に確かめる + assert definition, f"{name} の定義を読めていない: {result.stderr}" + assert "$@" not in definition + + +def test_aliases_file_has_no_positional_parameters(): + assert "$@" not in _statements() + + +# =========================================================================== +# AC6: 実体と前置する環境が変わらない +# =========================================================================== + +@pytest.mark.parametrize("name", sorted(LAUNCHERS)) +def test_launcher_invokes_expected_binary(tmp_path, name): + """定義名ではなく、想定した実体を起動する (kiro -> kiro-cli など)。""" + real, _ = LAUNCHERS[name] + # 実体のスタブだけを置く。別の名前を呼んでいれば command not found になる。 + result = _run(name, tmp_path, stubs=(real,)) + + assert result.returncode == 0, result.stderr + assert _field(result, "ARGS") is not None + + +@pytest.mark.parametrize("name", sorted(LAUNCHERS)) +def test_launcher_prefixes_only_expected_env(tmp_path, name): + """Bedrock を選ぶ claudb 以外は、環境を前置しない。""" + real, _ = LAUNCHERS[name] + expected = EXPECTED_PREFIXED_ENV.get(name, {}) + result = _run(name, tmp_path, stubs=(real,)) + + assert _field(result, "BEDROCK") == expected.get("CLAUDE_CODE_USE_BEDROCK", "") + assert _field(result, "AWSREGION") == expected.get("AWS_REGION", "") + + +# =========================================================================== +# AC8: 補完の登録 +# =========================================================================== + +def test_completion_is_registered(tmp_path): + result = _run('complete -p claudb kiro 2>&1 | tr "\\n" " " | sed "s/^/ARGS:/"', tmp_path) + out = _field(result, "ARGS") or "" + + # `complete -p` は未登録だと "not found" を返す。登録の有無をここで分ける + assert "not found" not in out, out + assert "claudb" in out + assert "kiro" in out