diff --git a/.gitignore b/.gitignore index 5d86a4c1..c78a3dba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ talos/secrets.plain.yaml # **平文のまま置かないこと。** talos/registries.yaml は SOPS 済みのものをコミットする # (talos/README.md「fj.doany.io の資格情報」)。書きかけの平文はこの名前に逃がす。 talos/registries.plain.yaml +# 平文の kubeconfig / talosconfig も置かない。talos/kubeconfig.yaml は SOPS 済みのものをコミットする +# (tools/README.md「クラスタの資格情報」)。talosctl kubeconfig と gen config がこの名前で書き出す +kubeconfig +talosconfig # pulumi/ の道具(bun.lock で固定してある) pulumi/node_modules/ diff --git a/.sops.yaml b/.sops.yaml index e985698c..a36835b8 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -18,3 +18,8 @@ creation_rules: # 選り分ける意味が無い(k3s 期の /etc/rancher/k3s/registries.yaml の移し先)。 - path_regex: ^talos/registries\.yaml$ age: age1sgnnkn67wpt4wgjsff4c36w3cmjkk557ng504e4aczx2lzls9y6ssy0x9r + # talos/kubeconfig.yaml(管理者。`talosctl kubeconfig` の出力)も丸ごと。 + # 中身は証明書と鍵がほとんどで、選り分ける意味が無い。tools/t.ps1 の kubectl がその場で復号して使う + # (tools/README.md「クラスタの資格情報」)。 + - path_regex: ^talos/kubeconfig\.yaml$ + age: age1sgnnkn67wpt4wgjsff4c36w3cmjkk557ng504e4aczx2lzls9y6ssy0x9r diff --git a/README.md b/README.md index 19e41b94..d5bfa882 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Argo CD が git を読む GitHub App 鍵、cert-manager の Cloudflare トーク ```shell # 鍵はリポジトリの .home/(git に入らない)に置く。コマンドは tools/t.ps1 で動かす(tools/README.md) tools/t.ps1 sh -c 'mkdir -p ~/.config/sops/age && age -d -o ~/.config/sops/age/keys.txt recovery/sops-age.key.age' -tools/t.ps1 sops -d bootstrap/infisical/secrets.yaml | tools/t.ps1 kubectl apply -f - +tools/t.ps1 sops -d bootstrap/infisical/secrets.yaml | tools/t.ps1 --admin kubectl apply -f - ``` ## ホスト名の付け方 diff --git a/apps/renovate/README.md b/apps/renovate/README.md index 80aa8082..9056ee0c 100644 --- a/apps/renovate/README.md +++ b/apps/renovate/README.md @@ -19,7 +19,7 @@ GitHub の schedule は 15〜40 分遅れ、丸ごと飛ばされることもあ tools/t.ps1 kubectl -n renovate get jobs tools/t.ps1 kubectl -n renovate logs job/<名前> # 待たずに 1 回回す -tools/t.ps1 kubectl -n renovate create job --from=cronjob/renovate-5ym renovate-5ym-manual +tools/t.ps1 --admin kubectl -n renovate create job --from=cronjob/renovate-5ym renovate-5ym-manual ``` ## 秘密 diff --git a/talos/README.md b/talos/README.md index 05882e7d..b79dae00 100644 --- a/talos/README.md +++ b/talos/README.md @@ -45,6 +45,9 @@ talosctl -n 10.0.0.2 -e 10.0.0.2 --talosconfig /tmp/talos-config/talosconfig boo talosctl -n 10.0.0.2 -e 10.0.0.2 --talosconfig /tmp/talos-config/talosconfig kubeconfig ``` +ふだんの kubectl / talosctl は `tools/t.ps1` が資格情報を用意する。管理者の kubeconfig は SOPS で +`talos/kubeconfig.yaml` に置く(手順は [../tools/README.md](../tools/README.md)「クラスタの資格情報」)。 + `clusterconfig/` と平文の秘密は `.gitignore` 済み。 **作り直すとき**(まっさらから組む場合だけ): diff --git a/tools/Dockerfile b/tools/Dockerfile index 32036bf6..a54d1ffc 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -29,7 +29,7 @@ ARG PULUMI_VERSION=v3.270.0 ARG CF_VERSION=1.0.0-beta.18 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl bzip2 git openssh-client less \ + && apt-get install -y --no-install-recommends ca-certificates curl bzip2 git openssh-client less openssl \ && rm -rf /var/lib/apt/lists/* # **入れるものは全部、公式が出しているチェックサムと照らし合わせる**(2026-10-07)。sops は age 鍵を、infisical は @@ -55,16 +55,18 @@ RUN a=$TARGETARCH; p=$a; [ "$a" = amd64 ] && p=x64; \ ok "$gh/restic/restic/releases/download/${RESTIC_VERSION}/SHA256SUMS" "restic_${RESTIC_VERSION#v}_linux_$a.bz2"; \ get "$gh/pulumi/pulumi/releases/download/${PULUMI_VERSION}/pulumi-${PULUMI_VERSION}-linux-$p.tar.gz"; \ ok "$gh/pulumi/pulumi/releases/download/${PULUMI_VERSION}/pulumi-${PULUMI_VERSION#v}-checksums.txt" "pulumi-${PULUMI_VERSION}-linux-$p.tar.gz"; \ - install -m 755 kubectl /usr/local/bin/kubectl; \ - install -m 755 "talosctl-linux-$a" /usr/local/bin/talosctl; \ - tar -xzf "helm-${HELM_VERSION}-linux-$a.tar.gz" --strip-components=1 -C /usr/local/bin "linux-$a/helm"; \ + # kubectl・talosctl・helm の本物は libexec に置く。/usr/local/bin のほうは creds.sh(下) + mkdir -p /usr/local/libexec/gitops; \ + install -m 755 kubectl /usr/local/libexec/gitops/kubectl; \ + install -m 755 "talosctl-linux-$a" /usr/local/libexec/gitops/talosctl; \ + tar -xzf "helm-${HELM_VERSION}-linux-$a.tar.gz" --strip-components=1 -C /usr/local/libexec/gitops "linux-$a/helm"; \ install -m 755 "sops-${SOPS_VERSION}.linux.$a" /usr/local/bin/sops; \ tar -xzf "cli_${INFISICAL_VERSION#v}_linux_$a.tar.gz" -C /usr/local/bin infisical; \ bunzip2 -c "restic_${RESTIC_VERSION#v}_linux_$a.bz2" > /usr/local/bin/restic; \ # Pulumi は本体と bun・Node.js の言語ホストだけ。ほかの言語のぶんは 200MB 近いので入れない。 # bun の言語ホストが無いと runtime: bun のプロジェクトで SDK を作れない (pulumi install。2026-10-07 まで抜けていた) tar -xzf "pulumi-${PULUMI_VERSION}-linux-$p.tar.gz" --strip-components=1 -C /usr/local/bin pulumi/pulumi pulumi/pulumi-language-bun pulumi/pulumi-language-nodejs; \ - chmod +x /usr/local/bin/*; \ + chmod +x /usr/local/bin/* /usr/local/libexec/gitops/*; \ cd / && rm -r /tmp/dl # age はチェックサムのファイルを出していない(sigsum の証明だけ)。Go のモジュールとして入れ、Go の公開の @@ -74,5 +76,9 @@ COPY --from=age /go/bin/age /go/bin/age-keygen /usr/local/bin/ COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun RUN npm install -g "cf@${CF_VERSION}" && npm cache clean --force +# kubectl・talosctl・helm はクラスタに繋ぐときだけ、暗号化した資格情報を tmpfs に戻して渡す(creds.sh) +COPY --chmod=755 creds.sh /usr/local/bin/creds.sh +RUN for t in kubectl talosctl helm; do ln -s creds.sh "/usr/local/bin/$t"; done + # HOME はリポジトリの .home/(compose.yaml) WORKDIR /repo diff --git a/tools/README.md b/tools/README.md index 6b412789..730477e2 100644 --- a/tools/README.md +++ b/tools/README.md @@ -9,6 +9,8 @@ Linux 用の `tools/t`(docker compose)は、手元が Windows に移ったので tools/t.ps1 cf r2 buckets list tools/t.ps1 sops -d bootstrap/infisical/secrets.yaml $env:INFISICAL_PROFILE = 'info@doany.io--doa'; tools/t.ps1 infisical secrets --env prod --path /matrix/matrix +tools/t.ps1 kubectl get pods -A # クラスタは読み取り用で繋ぐ +tools/t.ps1 --admin kubectl apply -f x.yaml # 書くときだけ管理者(下の「クラスタの資格情報」) tools/t.ps1 # 引数なしならシェルに入る ``` @@ -17,7 +19,7 @@ tools/t.ps1 # 引数なしならシェルに入る | cf | Cloudflare(R2 など) | | sops / age | `bootstrap/` と `talos/` の暗号化したファイル | | infisical | 秘密の読み書き | -| kubectl / talosctl / helm | クラスタ(Talos の移行後は手元から直接) | +| kubectl / talosctl / helm | クラスタ(資格情報は下の「クラスタの資格情報」) | | restic | R2 のバックアップの中身を見る・戻す | | pulumi / bun | `pulumi/`(ふだんは CI が流す) | @@ -29,20 +31,56 @@ tools/t.ps1 # 引数なしならシェルに入る - [../compose.yaml](../compose.yaml) の `tools` を `wslc-compose run` で動かす(`winget` で入る wslc-compose)。リポジトリは `/repo` に置く - **ログインと鍵はリポジトリの `.home/` に置く**(コンテナの中の `HOME`。`.gitignore` 済み)。手元のホームは汚さない。 cf のログイン(`.config/cloudflare`)、sops の age 鍵(`.config/sops/age/keys.txt`)、infisical のログイン - (`.infisical` と `infisical-keyring`)、pulumi(`.pulumi`)、kubectl / talosctl(`.kube` / `.talos`)がここに入る。 - kubeconfig と talosconfig は今は無い(k3s の間はノードに ssh して kubectl を使うため。Talos に移ったら置く)。 + (`.infisical` と `infisical-keyring`)、pulumi(`.pulumi`)がここに入る。 + クラスタの資格情報はここにも置かない(下の「クラスタの資格情報」)。 `.home/` は本人だけが読める ACL にする - **`git clean -fdx` で消える**(無視しているファイルも消すため)。age 鍵は `recovery/sops-age.key.age` から戻せる (README「Infisical より下の層」)、ほかはログインし直せば戻る - Dockerfile を変えると、次の `tools/t.ps1` でイメージを作り直す(変わっていなければキャッシュで一瞬) - パイプやリダイレクトのときは TTY を付けない(`tools/t.ps1` が見分ける) -- PowerShell でパイプしたもの(`tools/t.ps1 sops -d … | tools/t.ps1 kubectl apply -f -`)は、`t.ps1` がコンテナの標準入力に流し直す。 +- PowerShell でパイプしたもの(`tools/t.ps1 sops -d … | tools/t.ps1 --admin kubectl apply -f -`)は、`t.ps1` がコンテナの標準入力に流し直す。 行を LF でつないだ UTF-8 にして渡す(PowerShell のまま流すと CRLF になり、5.1 では日本語が `?` に化けるため、 base64 で包んでコンテナの中で戻す)。PowerShell のパイプは行(文字列)単位なので、バイナリは通らない。 バイナリはファイルに書いてリポジトリの中から読ませる - `t.ps1` は **BOM 付きの UTF-8** にしてある。BOM が無いと Windows PowerShell 5.1 が Shift_JIS として読み、 日本語のコメントで構文が壊れる(2026-10-07 まで 5.1 では動かなかった) +## クラスタの資格情報 + +**kubectl・talosctl・helm は、クラスタに繋ぐときだけ資格情報をコンテナの中で作るか復号して渡す**(2026-10-17。 +[creds.sh](creds.sh))。置き場はコンテナの `/dev/shm`(tmpfs)で、コンテナと一緒に消える。 +**平文は手元のディスクにも `.home/` にも残らない。** age 鍵(上の「仕組み」)が無いと止まる。 + +**既定は読み取り用。管理者は `tools/t.ps1 --admin …` と書いたときだけ。** 書く操作(`apply`・`delete`・ +`create job` など)は `--admin` を付ける。`--admin` はシェルにも効く(`tools/t.ps1 --admin`)。 +**誤操作を防ぐための分け方で、権限の境界ではない**(どちらも同じ age 鍵から作れる)。 + +| | 読み取り用(既定) | 管理者(`--admin`) | +| --- | --- | --- | +| kubectl / helm | その場で作る。`talos/secrets.yaml` の Kubernetes の CA で、グループ `entra:viewer` の証明書(1 日で切れる) | `talos/kubeconfig.yaml`(SOPS 済み)を復号する | +| talosctl | その場で作る。Talos の CA で `os:reader` の証明書(1 日で切れる) | 同じく `os:admin` | + +- 読み取り用の権限は [`../bootstrap/apiserver/rbac.yaml`](../bootstrap/apiserver/rbac.yaml) の `entra:viewer` + (組み込みの `view` と `readonly-extra`。ログは読めて、Secret は読めない)。Headlamp で Entra の viewer が持つものと同じ +- **helm はリリースの情報を Secret に持つ**ので、`helm list` や `helm status` も `--admin` を付ける +- **talosconfig は保存しない**([../docs/decisions.md](../docs/decisions.md)「手で持つもの」)。`secrets.yaml` から毎回作る +- 管理者の kubeconfig だけはファイルで持つ。Talos が出す正式なもの(`talosctl kubeconfig`)を使い、証明書は 1 年で切れる。 + 中身は `secrets.yaml` の CA で作れるものなので、置いても開けられる範囲は今と変わらない +- 行き先はどれも `10.0.0.2`(`talos/render.sh` と同じ)。`KUBECONFIG` / `TALOSCONFIG` や `--kubeconfig` / `--talosconfig` / + `--server` を自分で渡したときは何もしない。繋がないもの(`kubectl kustomize`、`helm template`、`talosctl gen`、`--client` など)も同じ + +**管理者の kubeconfig の入れ方**(作り直すときも同じ。切れる前に): + +```powershell +tools/t.ps1 --admin sh -c 'talosctl kubeconfig /dev/shm/k && sops -e --filename-override talos/kubeconfig.yaml /dev/shm/k > talos/kubeconfig.yaml' +tools/t.ps1 --admin kubectl get nodes # 確かめる +git add talos/kubeconfig.yaml # 暗号化済み(`sops:` がある)ことを見てからコミットする +``` + +`talosctl kubeconfig` が書き出す先も暗号化の入力も `/dev/shm` なので、平文は一度もディスクに出ない。 +規則は [`../.sops.yaml`](../.sops.yaml)。平文の `kubeconfig` / `talosconfig` という名前のファイルは `.gitignore` 済みで、 +`creds.sh` も暗号化されていない `talos/kubeconfig.yaml` は使わずに止まる。 + ## wslc の制限 - **wslc は host ネットワークに対応していない。** ブラウザのログインは手元の `localhost:<ポート>` に結果を送ってくるので、 diff --git a/tools/creds.sh b/tools/creds.sh new file mode 100755 index 00000000..77c2d265 --- /dev/null +++ b/tools/creds.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# kubectl / talosctl / helm の入口(tools/README.md「クラスタの資格情報」)。本物は /usr/local/libexec/gitops/ にあり、 +# このスクリプトがその名前で呼ばれる。クラスタに繋ぐときだけ資格情報をコンテナの /dev/shm(tmpfs)に作り、 +# KUBECONFIG / TALOSCONFIG で渡す。**平文はディスクに書かない。** /dev/shm はコンテナと一緒に消える(t.ps1 は --rm)。 +# +# 既定は読み取り用。管理者は `tools/t.ps1 --admin …`(GITOPS_ADMIN=1)のときだけ。 +# kubectl / helm 読み取り用は talos/secrets.yaml の CA でその場で証明書を作る(1 日で切れる)。 +# 管理者は talos/kubeconfig.yaml(SOPS 済み。talosctl kubeconfig の出力)を復号する +# talosctl 同じく CA でその場で作る(os:reader / os:admin)。talosconfig は保存しない +# (docs/decisions.md「手で持つもの」) +set -eu +tool=${0##*/} +real=/usr/local/libexec/gitops/$tool +shm=/dev/shm/gitops + +die() { echo "$tool: $*" >&2; exit 1; } + +# 最初の引数らしいもの(フラグを飛ばす)。`kubectl -n x get` だと x を拾うが、そのときは「繋ぐ」側に倒れるだけ +# (helm も「繋がないと分かっているもの」以外は繋ぐ側にしてある)。`--` より後ろはコマンドの引数なので見ない +sub= +for a in "$@"; do case $a in --) break ;; -*) ;; *) sub=$a; break ;; esac; done + +# 繋がないもの(版を見る・描くだけ)と、資格情報を自分で渡しているものはそのまま通す。CI(tools.yml)もここを通る +pass() { exec "$real" "$@"; } +for a in "$@"; do + case $a in + --) break ;; + -h | --help | --client | --kubeconfig | --kubeconfig=* | --talosconfig | --talosconfig=* | -s | --server | --server=*) pass "$@" ;; + esac +done +case $tool in +kubectl) + [ -z "${KUBECONFIG:-}" ] || pass "$@" + case $sub in '' | kustomize | completion | help | plugin | options) pass "$@" ;; esac + ;; +helm) + [ -z "${KUBECONFIG:-}" ] || pass "$@" + case $sub in '' | template | lint | package | create | show | inspect | pull | fetch | push | repo | registry | search | dependency | dep | plugin | env | version | completion | verify | help) pass "$@" ;; esac + ;; +talosctl) + [ -z "${TALOSCONFIG:-}" ] || pass "$@" + case $sub in '' | gen | completion | help | docs | validate) pass "$@" ;; esac + ;; +esac + +if [ "${GITOPS_ADMIN:-}" = 1 ]; then role=admin; else role=readonly; fi +mkdir -p -m 700 "$shm" +cd /repo +nokey() { [ -s ~/.config/sops/age/keys.txt ] || die "age の鍵が無い(README「Infisical より下の層」の 1 行目で .home に置く)"; } + +# talos/secrets.yaml の CA($1 = os か k8s)で、件名 $2 のクライアント証明書をその場で作り、$3 に置く。 +# 鍵 $4(ed25519 / EC)。1 日で切れる。ノードの時計が少し遅れていても通るように 5 分前から有効にする。 +# 作業中は CA の鍵も $w(tmpfs)に出るので、作り終えたらすぐ消す。結果は $3.crt / $3.key / $3.ca +mint() { + nokey + w=$(mktemp -d "$shm/w.XXXXXX") + # `( … ) || …` だと中の set -e が効かないので、終了コードを受けてから見る。パイプは最後の終了コードしか + # 返さないので、復号したものは一度ファイルに出す + set +e + ( + set -e + umask 077 + for k in crt key; do + sops -d --extract "[\"certs\"][\"$1\"][\"$k\"]" talos/secrets.yaml >"$w/ca.$k.b64" + base64 -d "$w/ca.$k.b64" >"$w/ca.$k" + done + # Talos は ed25519 の鍵に独自の見出しを付けている。中身は PKCS#8 なので見出しだけ直せば openssl が読める + sed -i 's/ED25519 PRIVATE KEY/PRIVATE KEY/' "$w/ca.key" + case $4 in + ed25519) openssl genpkey -algorithm ed25519 -out "$w/c.key" ;; + *) openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$w/c.key" ;; + esac + printf 'keyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n' >"$w/ext" + openssl req -new -key "$w/c.key" -subj "$2" -out "$w/c.csr" 2>/dev/null + openssl x509 -req -in "$w/c.csr" -CA "$w/ca.crt" -CAkey "$w/ca.key" -extfile "$w/ext" \ + -not_before "$(date -u -d '-5 min' +%Y%m%d%H%M%SZ)" -days 1 -out "$w/c.crt" 2>/dev/null + for k in crt key; do base64 -w0 "$w/c.$k" >"$3.$k"; done + base64 -w0 "$w/ca.crt" >"$3.ca" + [ -s "$3.crt" ] && [ -s "$3.key" ] && [ -s "$3.ca" ] + ) + rc=$? + set -e + rm -rf "$w" + [ $rc = 0 ] || { rm -f "$3".*; die "証明書を作れなかった(talos/secrets.yaml を age の鍵で開けるか)"; } +} + +# 名前と行き先は talos/render.sh の gen config と同じ(doany、https://10.0.0.2:6443) +if [ "$tool" = talosctl ]; then + out=$shm/talosconfig-$role + if [ ! -s "$out" ]; then + if [ $role = admin ]; then os=os:admin; else os=os:reader; fi + mint os "/O=$os" "$out.c.$$" ed25519 + (umask 077 && printf 'context: doany\ncontexts:\n doany:\n endpoints: [10.0.0.2]\n nodes: [10.0.0.2]\n ca: %s\n crt: %s\n key: %s\n' \ + "$(cat "$out.c.$$.ca")" "$(cat "$out.c.$$.crt")" "$(cat "$out.c.$$.key")" >"$out.$$" && mv "$out.$$" "$out") + rm -f "$out.c.$$".* + fi + export TALOSCONFIG="$out" + exec "$real" "$@" +fi + +out=$shm/kubeconfig-$role +if [ ! -s "$out" ] && [ $role = readonly ]; then + # 読み取り用は bootstrap/apiserver/rbac.yaml の entra:viewer(組み込みの view + readonly-extra。Secret は読めない)。 + # Headlamp で Entra の viewer が持つ権限と同じにするため、同じグループ名を使う + mint k8s "/O=entra:viewer/CN=tools-readonly" "$out.c.$$" ec + (umask 077 && printf 'apiVersion: v1\nkind: Config\nclusters:\n- name: doany\n cluster:\n server: https://10.0.0.2:6443\n certificate-authority-data: %s\ncontexts:\n- name: readonly@doany\n context:\n cluster: doany\n user: readonly\ncurrent-context: readonly@doany\nusers:\n- name: readonly\n user:\n client-certificate-data: %s\n client-key-data: %s\n' \ + "$(cat "$out.c.$$.ca")" "$(cat "$out.c.$$.crt")" "$(cat "$out.c.$$.key")" >"$out.$$" && mv "$out.$$" "$out") + rm -f "$out.c.$$".* +elif [ ! -s "$out" ]; then + f=talos/kubeconfig.yaml + [ -f $f ] || die "$f が無い。作り方は tools/README.md「クラスタの資格情報」" + grep -q '^sops:' $f || die "$f が暗号化されていない。git に入れる前に tools/README.md の手順で暗号化する" + nokey + (umask 077 && sops -d $f >"$out.$$" && mv "$out.$$" "$out") || { rm -f "$out.$$"; die "$f を復号できなかった"; } +fi +export KUBECONFIG="$out" +exec "$real" "$@" diff --git a/tools/t.ps1 b/tools/t.ps1 index e30a1787..c45dec30 100644 --- a/tools/t.ps1 +++ b/tools/t.ps1 @@ -1,6 +1,8 @@ # 運用のコマンドを wslc (WSL コンテナ) で動かす。中身は compose.yaml の tools を wslc-compose run する。例: # tools/t.ps1 infisical login --domain https://il.doany.io/api # tools/t.ps1 sops -d bootstrap/infisical/secrets.yaml +# tools/t.ps1 kubectl get pods -A # クラスタは読み取り用で繋ぐ +# tools/t.ps1 --admin kubectl apply -f x # 書くときだけ管理者 (tools/README.md「クラスタの資格情報」) # 引数が無ければシェルに入る。Dockerfile を変えたら次の実行で作り直す (変わっていなければキャッシュで一瞬)。 # # wslc の制限: @@ -126,9 +128,15 @@ function Test-NulStdin { $piped = $MyInvocation.ExpectingInput $opts = @('--rm') if ($piped -or [Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { $opts += '-T' } +# 先頭の --admin は t.ps1 が受け取る。コンテナの中の kubectl / talosctl / helm が管理者の資格情報を使う (tools/creds.sh) +$rest = @($args) +if ($rest.Count -and $rest[0] -eq '--admin') { + $opts += '-e', 'GITOPS_ADMIN=1' + $rest = @($rest | Select-Object -Skip 1) +} # 引数が 1 つのときも配列にする (if の値は 1 要素の配列が中身に展開され、$cmd[0] が 1 文字目になる。 # 2026-10-07 まで `'x' | tools/t.ps1 cat` が `c: not found` で落ちていた) -$cmd = @(if ($args.Count) { $args } else { 'bash' }) +$cmd = @(if ($rest.Count) { $rest } else { 'bash' }) $relay = $null $browserLogin = $cmd.Count -ge 2 -and $cmd[0] -eq 'infisical' -and $cmd[1] -eq 'login' -and