From b911e9a8129dbe881cd8bfda2fba9babfda45241 Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Tue, 13 Oct 2026 11:47:49 +0900 Subject: [PATCH 1/6] feat(ghtkn): add business-only ghtkn config with agent backend Prompt for the shared GitHub App client ID in business mode, render ~/.config/ghtkn/ghtkn.yaml with the agent backend, and install ghtkn through the global aqua config. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 1 + .gitignore | 3 --- CLAUDE.md | 3 ++- home/.chezmoi.yaml.tmpl | 6 ++++++ home/.chezmoiignore | 1 + home/dot_config/aquaproj-aqua/aqua.yaml.tmpl | 1 + home/dot_config/ghtkn/ghtkn.yaml.tmpl | 6 ++++++ 7 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 home/dot_config/ghtkn/ghtkn.yaml.tmpl diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d92d2dc..a62be5a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -226,6 +226,7 @@ jobs: name: "Test User" email: "test@example.com" work_email: "test@work.example.com" + ghtkn_client_id: "Iv1.test" EOF - name: Test chezmoi init (dry-run) diff --git a/.gitignore b/.gitignore index a48acbe..b15a79b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,6 @@ home/dot_local/share/devbox/global/default/devbox.lock .claude/ .firecrawl/ -# Work-only ghtkn GitHub App configuration must not enter this public repository -home/dot_config/ghtkn/ - # Python tooling caches (pytest harness under tests/skills/) __pycache__/ .pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md index 228cd98..5b7aa12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ just diff # Show pending chezmoi changes | `.business_use` | `true` if `BUSINESS_USE=1` was set during init | | `.osid` | OS identifier (e.g., `darwin`, `linux-ubuntu`) | | `.name`, `.email`, `.work_email` | User info prompted on first run | +| `.ghtkn_client_id` | ghtkn GitHub App client ID (business only) | ### Template Testing @@ -41,7 +42,7 @@ just diff # Show pending chezmoi changes ```bash tmpdir=$(mktemp -d) && mkdir -p "$tmpdir/chezmoi" -printf 'data:\n name: "Test"\n email: "t@example.com"\n work_email: "t@work.example.com"\n' > "$tmpdir/chezmoi/chezmoi.yaml" +printf 'data:\n name: "Test"\n email: "t@example.com"\n work_email: "t@work.example.com"\n ghtkn_client_id: "Iv1.test"\n' > "$tmpdir/chezmoi/chezmoi.yaml" XDG_CONFIG_HOME="$tmpdir" BUSINESS_USE=1 chezmoi init --source=. --dry-run # business mode XDG_CONFIG_HOME="$tmpdir" env -u BUSINESS_USE chezmoi init --source=. --dry-run # personal mode ``` diff --git a/home/.chezmoi.yaml.tmpl b/home/.chezmoi.yaml.tmpl index 1257b0d..de2d2b5 100644 --- a/home/.chezmoi.yaml.tmpl +++ b/home/.chezmoi.yaml.tmpl @@ -10,6 +10,7 @@ {{- $email := "" -}} {{- $workEmail := "" -}} {{- $signingKey := "" -}} +{{- $ghtknClientId := "" -}} {{- if and (not $businessUse) $hasOp -}} {{- $name = onepasswordRead "op://Dotfiles/Git/name" -}} @@ -22,6 +23,10 @@ {{- end -}} {{- end -}} +{{- if $businessUse -}} +{{- $ghtknClientId = promptStringOnce . "ghtkn_client_id" "ghtkn GitHub App client ID (C-FO shared app)" -}} +{{- end -}} + {{- if and $hasOp (eq .chezmoi.os "darwin") -}} {{- $signingKey = onepasswordRead "op://Dotfiles/Git/signing_key" -}} {{- end -}} @@ -36,6 +41,7 @@ data: email: {{ $email | quote }} work_email: {{ $workEmail | quote }} signing_key: {{ $signingKey | quote }} + ghtkn_client_id: {{ $ghtknClientId | quote }} # Auto tmux on terminal startup auto_tmux: true diff --git a/home/.chezmoiignore b/home/.chezmoiignore index 389b21a..e46b3ee 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -30,6 +30,7 @@ homebrew/ .chezmoiscripts/generate-fdev-ca-bundle.sh .config/git/freee .config/colima/ +.config/ghtkn/ homebrew/Brewfile.work .claude/skills/acli/ {{ end }} diff --git a/home/dot_config/aquaproj-aqua/aqua.yaml.tmpl b/home/dot_config/aquaproj-aqua/aqua.yaml.tmpl index 7102aeb..b881a3f 100644 --- a/home/dot_config/aquaproj-aqua/aqua.yaml.tmpl +++ b/home/dot_config/aquaproj-aqua/aqua.yaml.tmpl @@ -12,6 +12,7 @@ packages: - name: helm/helm@v3.17.0 - name: helmfile/helmfile@v0.171.0 - name: hashicorp/terraform@v1.14.3 + - name: suzuki-shunsuke/ghtkn@v0.4.1 {{- else }} packages: [] diff --git a/home/dot_config/ghtkn/ghtkn.yaml.tmpl b/home/dot_config/ghtkn/ghtkn.yaml.tmpl new file mode 100644 index 0000000..a233fd4 --- /dev/null +++ b/home/dot_config/ghtkn/ghtkn.yaml.tmpl @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/ghtkn/main/json-schema/ghtkn.json +backend: + type: agent +apps: + - name: c-fo + client_id: {{ .ghtkn_client_id | quote }} From 74c0296f7143453e277291e10e4bd60b685cd126 Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Tue, 13 Oct 2026 11:47:57 +0900 Subject: [PATCH 2/6] feat(zsh): route gh through ghtkn agent and drop aqua-token-sync Business gh runs under `ghtkn exec -e GH_TOKEN` so the token only lives in the child env. aqua switches from the keyring token to AQUA_GHTKN_ENABLED. Co-Authored-By: Claude Opus 5.5 --- .../zsh/features/aqua-token-sync.zsh | 31 --- home/dot_config/zsh/features/ghtkn-gh.zsh | 30 +++ home/dot_zshenv.tmpl | 10 +- home/private_dot_zshrc.tmpl | 2 +- tests/zsh/aqua-token-sync.test.sh | 162 -------------- tests/zsh/ghtkn-gh.test.sh | 199 ++++++++++++++++++ 6 files changed, 235 insertions(+), 199 deletions(-) delete mode 100644 home/dot_config/zsh/features/aqua-token-sync.zsh create mode 100644 home/dot_config/zsh/features/ghtkn-gh.zsh delete mode 100644 tests/zsh/aqua-token-sync.test.sh create mode 100644 tests/zsh/ghtkn-gh.test.sh diff --git a/home/dot_config/zsh/features/aqua-token-sync.zsh b/home/dot_config/zsh/features/aqua-token-sync.zsh deleted file mode 100644 index ad3036c..0000000 --- a/home/dot_config/zsh/features/aqua-token-sync.zsh +++ /dev/null @@ -1,31 +0,0 @@ -# aqua-token-sync.zsh — refresh aqua's keyring GitHub token from gh -# Provides: aqua-token-sync -# Requires: gh, aqua -# Side-effects: writes gh's OAuth token into the OS keychain via `aqua token set` - -function aqua-token-sync { - emulate -L zsh - setopt local_options no_xtrace - - if (( ! $+commands[gh] || ! $+commands[aqua] )); then - print -u2 -- "aqua-token-sync: gh and aqua are required" - return 127 - fi - - local token - token="$(command gh auth token 2>/dev/null)" || { - print -u2 -- "aqua-token-sync: gh auth token failed — run \`gh auth login\` first" - return 1 - } - if [[ -z "$token" ]]; then - print -u2 -- "aqua-token-sync: gh returned an empty token" - return 1 - fi - - if print -r -- "$token" | command aqua token set --stdin; then - print -- "aqua-token-sync: keyring token updated from gh" - else - print -u2 -- "aqua-token-sync: aqua token set failed" - return 1 - fi -} diff --git a/home/dot_config/zsh/features/ghtkn-gh.zsh b/home/dot_config/zsh/features/ghtkn-gh.zsh new file mode 100644 index 0000000..377e9d3 --- /dev/null +++ b/home/dot_config/zsh/features/ghtkn-gh.zsh @@ -0,0 +1,30 @@ +# ghtkn-gh.zsh — run gh with a short-lived GitHub App token from the ghtkn agent +# Provides: gh +# Requires: gh, ghtkn (agent unlocked) +# Side-effects: runs gh through `ghtkn exec` unless GH_TOKEN or GITHUB_TOKEN is set + +if (( $+aliases[gh] )); then + unalias gh +fi + +function gh { + emulate -L zsh + setopt local_options no_xtrace + + if [[ -n ${GH_TOKEN:-} || -n ${GITHUB_TOKEN:-} ]]; then + command gh "$@" + return + fi + + if (( ! $+commands[ghtkn] )); then + print -u2 -- "gh: ghtkn is required when no explicit GitHub token is set" + return 127 + fi + + command ghtkn exec -e GH_TOKEN -- gh "$@" + local exit_status=$? + if (( exit_status == 111 )); then + print -u2 -- "gh: no ghtkn token available — run \`ghtkn agent unlock --enable-refresh\`" + fi + return $exit_status +} diff --git a/home/dot_zshenv.tmpl b/home/dot_zshenv.tmpl index b557b20..7ac2ee1 100644 --- a/home/dot_zshenv.tmpl +++ b/home/dot_zshenv.tmpl @@ -164,11 +164,11 @@ export PATH="$XDG_DATA_HOME/aquaproj-aqua/bin:$PATH" export BUSINESS_USE=1 export COLIMA_HOME="$XDG_CONFIG_HOME/colima" -# aqua reads its GitHub token from macOS Keychain — some C-FO repos resolve a -# private aqua-registry, which needs an SSO-authorized token. Store it with -# `aqua-token-sync` (features/aqua-token-sync.zsh); rerun after gh re-login -# rotates the token. A GITHUB_TOKEN env var takes precedence and disables this. -export AQUA_KEYRING_ENABLED=true +# aqua gets its GitHub token from the ghtkn agent (some C-FO repos resolve a +# private aqua-registry). The agent must be unlocked after each login with +# `ghtkn agent unlock --enable-refresh`. GITHUB_TOKEN, AQUA_GITHUB_TOKEN and +# AQUA_KEYRING_ENABLED must stay unset or aqua ignores ghtkn. +export AQUA_GHTKN_ENABLED=true # Corporate TLS inspection CA: wire palo-root.pem (in Homebrew's cert store) # into common SSL env vars so curl/Node/Python/Ruby-OpenSSL trust certificates diff --git a/home/private_dot_zshrc.tmpl b/home/private_dot_zshrc.tmpl index a005aa3..32e286e 100644 --- a/home/private_dot_zshrc.tmpl +++ b/home/private_dot_zshrc.tmpl @@ -28,7 +28,7 @@ # ============================================================================= {{ include "dot_config/zsh/features/ghq-fzf.zsh" }} {{- if .business_use }} -{{ include "dot_config/zsh/features/aqua-token-sync.zsh" }} +{{ include "dot_config/zsh/features/ghtkn-gh.zsh" }} {{- end }} {{ include "dot_config/zsh/features/git-branch-fzf.zsh" }} {{ include "dot_config/zsh/features/branch-cleanup.zsh" }} diff --git a/tests/zsh/aqua-token-sync.test.sh b/tests/zsh/aqua-token-sync.test.sh deleted file mode 100644 index 03481c5..0000000 --- a/tests/zsh/aqua-token-sync.test.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2016 -# Verify the business-only aqua-token-sync helper without consulting real credentials. -set -euo pipefail - -unset GH_TOKEN GITHUB_TOKEN - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -repo_root="$(cd "$script_dir/../.." && pwd -P)" -feature_source="$repo_root/home/dot_config/zsh/features/aqua-token-sync.zsh" -zshrc_source="$repo_root/home/private_dot_zshrc.tmpl" - -for required_command in chezmoi chmod grep mkdir zsh; do - command -v "$required_command" >/dev/null 2>&1 || { - echo "required command not found: $required_command" >&2 - exit 1 - } -done - -test_root="$(mktemp -d "${TMPDIR:-/tmp}/dots-aqua-token-sync.XXXXXX")" -cleanup() { - rm -rf -- "$test_root" -} -trap cleanup EXIT - -fail() { - echo "FAIL: $1" >&2 - exit 1 -} - -pass() { - echo "PASS: $1" -} - -fake_bin="$test_root/bin" -gh_only_bin="$test_root/gh-only-bin" -aqua_log="$test_root/aqua.log" -stdout_log="$test_root/stdout.log" -trace_log="$test_root/zsh.trace" -mkdir -p "$fake_bin" "$gh_only_bin" - -cat >"$fake_bin/gh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -case "${GH_MODE:-success}" in - success) - printf '%s\n' "fake-gh-token" - ;; - empty) - ;; - fail) - exit 4 - ;; - *) - exit 64 - ;; -esac -EOF - -cat >"$fake_bin/aqua" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -{ - printf 'args=%s\n' "$*" - printf 'stdin=%s\n' "$(cat)" -} >>"${AQUA_LOG:?}" -EOF - -chmod +x "$fake_bin/gh" "$fake_bin/aqua" -cp "$fake_bin/gh" "$gh_only_bin/gh" - -run_sync() { - env -u GH_TOKEN -u GITHUB_TOKEN \ - "PATH=$1:/usr/bin:/bin" \ - "FEATURE_SOURCE=$feature_source" \ - "AQUA_LOG=$aqua_log" \ - "GH_MODE=${2:-success}" \ - zsh -f -c ' - source "$FEATURE_SOURCE" - aqua-token-sync - ' -} - -run_sync "$fake_bin" success >"$stdout_log" -grep -Fxq "args=token set --stdin" "$aqua_log" || - fail "helper did not invoke aqua token set --stdin" -grep -Fxq "stdin=fake-gh-token" "$aqua_log" || - fail "helper did not pipe the gh token into aqua" -grep -Fq "fake-gh-token" "$stdout_log" && - fail "token appeared on the helper's stdout" -grep -Fq "keyring token updated" "$stdout_log" || - fail "helper did not report success" -pass "gh token reaches aqua via stdin and never stdout" - -: >"$aqua_log" -env -u GH_TOKEN -u GITHUB_TOKEN \ - "PATH=$fake_bin:/usr/bin:/bin" \ - "FEATURE_SOURCE=$feature_source" \ - "AQUA_LOG=$aqua_log" \ - zsh -f -c ' - set -x - source "$FEATURE_SOURCE" - aqua-token-sync - ' >/dev/null 2>"$trace_log" - -if grep -Fq "fake-gh-token" "$trace_log"; then - fail "token appeared in zsh xtrace output" -fi -pass "helper suppresses token-bearing xtrace output" - -: >"$aqua_log" -run_sync "$fake_bin" fail >/dev/null 2>&1 && fail "gh failure unexpectedly succeeded" -[[ ! -s "$aqua_log" ]] || - fail "helper called aqua after gh failed" -pass "gh failure prevents storing a token" - -: >"$aqua_log" -run_sync "$fake_bin" empty >/dev/null 2>&1 && fail "empty gh token unexpectedly succeeded" -[[ ! -s "$aqua_log" ]] || - fail "helper called aqua with an empty token" -pass "empty gh token prevents storing a token" - -: >"$aqua_log" -rc=0 -run_sync "$gh_only_bin" success >/dev/null 2>&1 || rc=$? -[[ "$rc" -eq 127 ]] || - fail "missing aqua did not return 127 (got $rc)" -pass "missing aqua returns 127" - -render_root="$test_root/rendered" -chezmoi_config="$test_root/chezmoi.json" -mkdir -p "$render_root" -printf '{}\n' >"$chezmoi_config" - -render_profile() { - local profile="$1" - local business_use="$2" - local rendered="$render_root/$profile.zshrc" - local data - - data="{\"business_use\":$business_use,\"auto_tmux\":false,\"homebrew_prefix\":\"/opt/homebrew\"}" - env -u GH_TOKEN -u GITHUB_TOKEN \ - chezmoi \ - --config "$chezmoi_config" \ - --config-format json \ - --source "$repo_root" \ - --override-data "$data" \ - execute-template --file "$zshrc_source" >"$rendered" - zsh -n "$rendered" -} - -render_profile personal false -render_profile business true - -if grep -Fq "aqua-token-sync" "$render_root/personal.zshrc"; then - fail "personal zshrc unexpectedly contains aqua-token-sync" -fi -grep -Fq "aqua-token-sync" "$render_root/business.zshrc" || - fail "business zshrc is missing aqua-token-sync" -pass "helper is rendered only for the business profile" - -echo "All aqua-token-sync tests passed." diff --git a/tests/zsh/ghtkn-gh.test.sh b/tests/zsh/ghtkn-gh.test.sh new file mode 100644 index 0000000..b5f982e --- /dev/null +++ b/tests/zsh/ghtkn-gh.test.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +# Verify the business-only gh wrapper and ghtkn config rendering without consulting real credentials. +set -euo pipefail + +# Never inherit or inspect credentials from the shell running this test. +unset GH_TOKEN GITHUB_TOKEN + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/../.." && pwd -P)" +feature_source="$repo_root/home/dot_config/zsh/features/ghtkn-gh.zsh" +zshrc_source="$repo_root/home/private_dot_zshrc.tmpl" + +for required_command in chezmoi chmod cp grep mkdir zsh; do + command -v "$required_command" >/dev/null 2>&1 || { + echo "required command not found: $required_command" >&2 + exit 1 + } +done + +test_root="$(mktemp -d "${TMPDIR:-/tmp}/dots-ghtkn-gh.XXXXXX")" +cleanup() { + rm -rf -- "$test_root" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +pass() { + echo "PASS: $1" +} + +fake_bin="$test_root/bin" +gh_only_bin="$test_root/gh-only-bin" +ghtkn_log="$test_root/ghtkn.log" +gh_log="$test_root/gh.log" +stderr_log="$test_root/stderr.log" +mkdir -p "$fake_bin" "$gh_only_bin" + +cat >"$fake_bin/ghtkn" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"${GHTKN_LOG:?}" +[[ "${GHTKN_MODE:-success}" == "no-token" ]] && exit 111 +# Mirror `ghtkn exec -e GH_TOKEN -- cmd args...`: the token reaches the child env only. +shift 3 +GH_TOKEN="fake-ghtkn-token" exec "$@" +EOS + +cat >"$fake_bin/gh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail +{ + printf 'argc=%s\n' "$#" + for arg in "$@"; do + printf 'arg=%s\n' "$arg" + done + printf 'GH_TOKEN=%s\n' "${GH_TOKEN-}" + printf 'GITHUB_TOKEN=%s\n' "${GITHUB_TOKEN-}" +} >>"${GH_LOG:?}" +EOS + +chmod +x "$fake_bin/ghtkn" "$fake_bin/gh" +cp "$fake_bin/gh" "$gh_only_bin/gh" + +reset_logs() { + : >"$ghtkn_log" + : >"$gh_log" + : >"$stderr_log" +} + +run_wrapper() { + local path_dir="$1" + shift + env -u GH_TOKEN -u GITHUB_TOKEN \ + "PATH=$path_dir:/usr/bin:/bin" \ + "FEATURE_SOURCE=$feature_source" \ + "GHTKN_LOG=$ghtkn_log" \ + "GH_LOG=$gh_log" \ + "$@" +} + +reset_logs +run_wrapper "$fake_bin" zsh -f -c ' + source "$FEATURE_SOURCE" + gh api "/repos/o/r" --jq ".name | length" + [[ ! -v GH_TOKEN ]] +' 2>"$stderr_log" +grep -Fxq "exec -e GH_TOKEN -- gh api /repos/o/r --jq .name | length" "$ghtkn_log" || + fail "wrapper did not call ghtkn exec -e GH_TOKEN -- gh " +grep -Fxq "argc=4" "$gh_log" || fail "gh received the wrong argument count" +grep -Fxq "arg=.name | length" "$gh_log" || + fail "wrapper did not preserve an argument containing spaces" +grep -Fxq "GH_TOKEN=fake-ghtkn-token" "$gh_log" || + fail "gh did not receive the token from ghtkn exec" +pass "normal path runs gh through ghtkn exec and keeps the token out of the shell" + +reset_logs +env -u GITHUB_TOKEN "GH_TOKEN=caller-token" \ + "PATH=$fake_bin:/usr/bin:/bin" "FEATURE_SOURCE=$feature_source" \ + "GHTKN_LOG=$ghtkn_log" "GH_LOG=$gh_log" \ + zsh -f -c 'source "$FEATURE_SOURCE"; gh repo view' +[[ ! -s "$ghtkn_log" ]] || fail "wrapper called ghtkn despite an explicit GH_TOKEN" +grep -Fxq "GH_TOKEN=caller-token" "$gh_log" || + fail "wrapper did not preserve the explicit GH_TOKEN" +pass "explicit GH_TOKEN bypasses ghtkn" + +reset_logs +env -u GH_TOKEN "GITHUB_TOKEN=caller-token" \ + "PATH=$fake_bin:/usr/bin:/bin" "FEATURE_SOURCE=$feature_source" \ + "GHTKN_LOG=$ghtkn_log" "GH_LOG=$gh_log" \ + zsh -f -c 'source "$FEATURE_SOURCE"; gh repo view' +[[ ! -s "$ghtkn_log" ]] || fail "wrapper called ghtkn despite an explicit GITHUB_TOKEN" +grep -Fxq "GITHUB_TOKEN=caller-token" "$gh_log" || + fail "wrapper did not preserve the explicit GITHUB_TOKEN" +pass "explicit GITHUB_TOKEN bypasses ghtkn" + +reset_logs +run_wrapper "$fake_bin" zsh -f -c ' + alias gh="print aliased" + source "$FEATURE_SOURCE" + (( ! $+aliases[gh] )) + eval "gh api /user" +' +grep -Fxq "arg=/user" "$gh_log" || fail "pre-existing gh alias shadowed the wrapper" +pass "pre-existing gh alias does not shadow the wrapper" + +reset_logs +rc=0 +run_wrapper "$gh_only_bin" zsh -f -c ' + source "$FEATURE_SOURCE" + gh api /user +' 2>"$stderr_log" || rc=$? +[[ "$rc" -eq 127 ]] || fail "missing ghtkn did not return 127 (got $rc)" +[[ ! -s "$gh_log" ]] || fail "wrapper ran gh without ghtkn" +pass "missing ghtkn returns 127 and does not run gh" + +reset_logs +rc=0 +run_wrapper "$fake_bin" GHTKN_MODE=no-token zsh -f -c ' + source "$FEATURE_SOURCE" + gh api /user +' 2>"$stderr_log" || rc=$? +[[ "$rc" -eq 111 ]] || fail "ghtkn exit 111 was not propagated (got $rc)" +grep -Fq "ghtkn agent unlock --enable-refresh" "$stderr_log" || + fail "no unlock hint on stderr after ghtkn exit 111" +[[ ! -s "$gh_log" ]] || fail "wrapper ran gh after ghtkn exited 111" +pass "ghtkn exit 111 is propagated with an unlock hint" + +render_root="$test_root/rendered" +chezmoi_config="$test_root/chezmoi.json" +mkdir -p "$render_root" +printf '{}\n' >"$chezmoi_config" + +chezmoi_isolated() { + local business_use="$1" + shift + env -u GH_TOKEN -u GITHUB_TOKEN \ + chezmoi \ + --config "$chezmoi_config" \ + --config-format json \ + --source "$repo_root" \ + --destination "$test_root/destination" \ + --override-data "{\"business_use\":$business_use,\"auto_tmux\":false,\"homebrew_prefix\":\"/opt/homebrew\",\"ghtkn_client_id\":\"Iv1.test\"}" \ + "$@" +} + +chezmoi_isolated false execute-template --file "$zshrc_source" >"$render_root/personal.zshrc" +chezmoi_isolated true execute-template --file "$zshrc_source" >"$render_root/business.zshrc" +zsh -n "$render_root/personal.zshrc" +zsh -n "$render_root/business.zshrc" + +if grep -Fq "gh: ghtkn is required" "$render_root/personal.zshrc"; then + fail "personal zshrc unexpectedly contains the ghtkn gh wrapper" +fi +grep -Fq "gh: ghtkn is required" "$render_root/business.zshrc" || + fail "business zshrc is missing the ghtkn gh wrapper" +pass "wrapper is rendered only for the business profile" + +chezmoi_isolated true execute-template --file "$repo_root/home/dot_config/ghtkn/ghtkn.yaml.tmpl" >"$render_root/ghtkn.yaml" +grep -Fxq ' client_id: "Iv1.test"' "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml did not render the client id" +grep -Fxq " type: agent" "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml does not select the agent backend" +pass "ghtkn.yaml renders the client id with the agent backend" + +chezmoi_isolated false managed >"$render_root/managed-personal.txt" +chezmoi_isolated true managed >"$render_root/managed-business.txt" +if grep -Eq '^\.config/ghtkn(/|$)' "$render_root/managed-personal.txt"; then + fail "personal profile manages a ghtkn config target" +fi +grep -Fxq ".config/ghtkn/ghtkn.yaml" "$render_root/managed-business.txt" || + fail "business profile does not manage .config/ghtkn/ghtkn.yaml" +pass "ghtkn config is managed only for the business profile" + +echo "All ghtkn gh wrapper tests passed." From b9458b433779a17e3298211855ca93d65c016cbe Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Tue, 13 Oct 2026 11:48:47 +0900 Subject: [PATCH 3/6] feat(ghtkn): run the ghtkn agent under launchd on business macOS A LaunchAgent starts the agent through aqua and an onchange script reloads it when the plist or the ghtkn version changes. The agent restarts locked, so the script reminds the operator to unlock it. Co-Authored-By: Claude Opus 5.5 --- home/.chezmoiignore | 2 ++ .../run_onchange_after_load-ghtkn-agent.sh.tmpl | 16 ++++++++++++++++ tests/zsh/ghtkn-gh.test.sh | 15 +++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 home/.chezmoiscripts/run_onchange_after_load-ghtkn-agent.sh.tmpl diff --git a/home/.chezmoiignore b/home/.chezmoiignore index e46b3ee..2e0de56 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -31,6 +31,8 @@ homebrew/ .config/git/freee .config/colima/ .config/ghtkn/ +Library/LaunchAgents/local.ghtkn-agent.plist +.chezmoiscripts/load-ghtkn-agent.sh homebrew/Brewfile.work .claude/skills/acli/ {{ end }} diff --git a/home/.chezmoiscripts/run_onchange_after_load-ghtkn-agent.sh.tmpl b/home/.chezmoiscripts/run_onchange_after_load-ghtkn-agent.sh.tmpl new file mode 100644 index 0000000..a424b7d --- /dev/null +++ b/home/.chezmoiscripts/run_onchange_after_load-ghtkn-agent.sh.tmpl @@ -0,0 +1,16 @@ +{{- if and .business_use (eq .chezmoi.os "darwin") -}} +#!/bin/bash +# plist hash: {{ include "Library/LaunchAgents/local.ghtkn-agent.plist.tmpl" | sha256sum }} +# ghtkn version: {{ include "dot_config/aquaproj-aqua/aqua.yaml.tmpl" | regexFind "suzuki-shunsuke/ghtkn@v[0-9.]+" }} +set -euo pipefail + +if ! command -v aqua >/dev/null 2>&1; then + echo "aqua not found — skipping ghtkn agent load" >&2 + exit 0 +fi + +label="local.ghtkn-agent" +launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/$label.plist" +echo "ghtkn agent restarted locked — run: ghtkn agent unlock --enable-refresh" +{{- end }} diff --git a/tests/zsh/ghtkn-gh.test.sh b/tests/zsh/ghtkn-gh.test.sh index b5f982e..64b493e 100644 --- a/tests/zsh/ghtkn-gh.test.sh +++ b/tests/zsh/ghtkn-gh.test.sh @@ -196,4 +196,19 @@ grep -Fxq ".config/ghtkn/ghtkn.yaml" "$render_root/managed-business.txt" || fail "business profile does not manage .config/ghtkn/ghtkn.yaml" pass "ghtkn config is managed only for the business profile" +chezmoi_isolated false ignored >"$render_root/ignored-personal.txt" +ghtkn_ignored_targets=(".config/ghtkn" ".chezmoiscripts/load-ghtkn-agent.sh") +# Non-darwin hosts ignore the whole Library/ tree, so the plist is not listed on its own there. +grep -Fxq "Library" "$render_root/ignored-personal.txt" || + ghtkn_ignored_targets+=("Library/LaunchAgents/local.ghtkn-agent.plist") +for ghtkn_target in "${ghtkn_ignored_targets[@]}"; do + grep -Fxq "$ghtkn_target" "$render_root/ignored-personal.txt" || + fail "personal profile does not ignore $ghtkn_target" +done +chezmoi_isolated true ignored >"$render_root/ignored-business.txt" +if grep -Fq "ghtkn" "$render_root/ignored-business.txt"; then + fail "business profile ignores a ghtkn target" +fi +pass "ghtkn agent targets are ignored only for the personal profile" + echo "All ghtkn gh wrapper tests passed." From 7771dd177510b6adbd4cdead29975e9c1d1a77bc Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Tue, 13 Oct 2026 11:54:48 +0900 Subject: [PATCH 4/6] feat(ghtkn): configure per-permission GitHub Apps from a local client ID map Client IDs are carried forward from the local chezmoi.yaml (data.ghtkn_client_ids) and never enter the repo. ghtkn.yaml renders only apps with an ID, aqua selects C-FO/read through GHTKN_APP, and gh uses C-FO/write. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 3 +- CLAUDE.md | 14 ++++---- home/.chezmoi.yaml.tmpl | 10 +++--- home/dot_config/ghtkn/ghtkn.yaml.tmpl | 34 +++++++++++++++++-- home/dot_config/zsh/features/ghtkn-gh.zsh | 2 +- home/dot_zshenv.tmpl | 4 ++- tests/zsh/ghtkn-gh.test.sh | 41 ++++++++++++++++++----- 7 files changed, 82 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a62be5a..f490d88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -226,7 +226,8 @@ jobs: name: "Test User" email: "test@example.com" work_email: "test@work.example.com" - ghtkn_client_id: "Iv1.test" + ghtkn_client_ids: + read: "Iv1.test" EOF - name: Test chezmoi init (dry-run) diff --git a/CLAUDE.md b/CLAUDE.md index 5b7aa12..207dde8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,12 +27,12 @@ just diff # Show pending chezmoi changes ### Key Data Variables (defined in `home/.chezmoi.yaml.tmpl`) -| Variable | Description | -| -------------------------------- | ---------------------------------------------- | -| `.business_use` | `true` if `BUSINESS_USE=1` was set during init | -| `.osid` | OS identifier (e.g., `darwin`, `linux-ubuntu`) | -| `.name`, `.email`, `.work_email` | User info prompted on first run | -| `.ghtkn_client_id` | ghtkn GitHub App client ID (business only) | +| Variable | Description | +| -------------------------------- | ----------------------------------------------- | +| `.business_use` | `true` if `BUSINESS_USE=1` was set during init | +| `.osid` | OS identifier (e.g., `darwin`, `linux-ubuntu`) | +| `.name`, `.email`, `.work_email` | User info prompted on first run | +| `.ghtkn_client_ids` | ghtkn App client IDs, filled by hand (business) | ### Template Testing @@ -42,7 +42,7 @@ just diff # Show pending chezmoi changes ```bash tmpdir=$(mktemp -d) && mkdir -p "$tmpdir/chezmoi" -printf 'data:\n name: "Test"\n email: "t@example.com"\n work_email: "t@work.example.com"\n ghtkn_client_id: "Iv1.test"\n' > "$tmpdir/chezmoi/chezmoi.yaml" +printf 'data:\n name: "Test"\n email: "t@example.com"\n work_email: "t@work.example.com"\n ghtkn_client_ids: {read: "Iv1.test"}\n' > "$tmpdir/chezmoi/chezmoi.yaml" XDG_CONFIG_HOME="$tmpdir" BUSINESS_USE=1 chezmoi init --source=. --dry-run # business mode XDG_CONFIG_HOME="$tmpdir" env -u BUSINESS_USE chezmoi init --source=. --dry-run # personal mode ``` diff --git a/home/.chezmoi.yaml.tmpl b/home/.chezmoi.yaml.tmpl index de2d2b5..b513f60 100644 --- a/home/.chezmoi.yaml.tmpl +++ b/home/.chezmoi.yaml.tmpl @@ -10,7 +10,7 @@ {{- $email := "" -}} {{- $workEmail := "" -}} {{- $signingKey := "" -}} -{{- $ghtknClientId := "" -}} +{{- $ghtknClientIds := dict -}} {{- if and (not $businessUse) $hasOp -}} {{- $name = onepasswordRead "op://Dotfiles/Git/name" -}} @@ -23,8 +23,8 @@ {{- end -}} {{- end -}} -{{- if $businessUse -}} -{{- $ghtknClientId = promptStringOnce . "ghtkn_client_id" "ghtkn GitHub App client ID (C-FO shared app)" -}} +{{- if hasKey . "ghtkn_client_ids" -}} +{{- $ghtknClientIds = .ghtkn_client_ids -}} {{- end -}} {{- if and $hasOp (eq .chezmoi.os "darwin") -}} @@ -41,7 +41,9 @@ data: email: {{ $email | quote }} work_email: {{ $workEmail | quote }} signing_key: {{ $signingKey | quote }} - ghtkn_client_id: {{ $ghtknClientId | quote }} + + # ghtkn GitHub App client IDs (none/read/git/write), filled by hand in the local chezmoi.yaml + ghtkn_client_ids: {{ $ghtknClientIds | toJson }} # Auto tmux on terminal startup auto_tmux: true diff --git a/home/dot_config/ghtkn/ghtkn.yaml.tmpl b/home/dot_config/ghtkn/ghtkn.yaml.tmpl index a233fd4..1519cb5 100644 --- a/home/dot_config/ghtkn/ghtkn.yaml.tmpl +++ b/home/dot_config/ghtkn/ghtkn.yaml.tmpl @@ -1,6 +1,34 @@ +{{- $ids := dict -}} +{{- if hasKey . "ghtkn_client_ids" -}} +{{- $ids = .ghtkn_client_ids -}} +{{- end -}} +{{- $none := get $ids "none" -}} +{{- $read := get $ids "read" -}} +{{- $git := get $ids "git" -}} +{{- $write := get $ids "write" -}} # yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/ghtkn/main/json-schema/ghtkn.json +# ghtkn - https://github.com/suzuki-shunsuke/ghtkn +min_expiration: 30m # 有効期限が 30m 以下になったら更新される +clipboard: + enable: true # device flow で one-time code が clipboard にコピーされて便利 backend: - type: agent + type: agent # access token の更新を自動化したい場合 apps: - - name: c-fo - client_id: {{ .ghtkn_client_id | quote }} +{{- if not (or $none $read $git $write) }} []{{ end }} +{{- if $none }} + - name: C-FO/none # 先頭にこの App を持ってくることでデフォルトで secure にする + client_id: {{ $none | quote }} +{{- end }} +{{- if $read }} + - name: C-FO/read # read 権限のみ + client_id: {{ $read | quote }} +{{- end }} +{{- if $git }} + - name: C-FO/git # Git Credential Helper 用 + client_id: {{ $git | quote }} + git_owner: C-FO # Git Credential Helper により、 C-FO 配下のリポジトリに push/pull する際はこれが使用される +{{- end }} +{{- if $write }} + - name: C-FO/write # 開発に必要な基本的な権限 + client_id: {{ $write | quote }} +{{- end }} diff --git a/home/dot_config/zsh/features/ghtkn-gh.zsh b/home/dot_config/zsh/features/ghtkn-gh.zsh index 377e9d3..c719b93 100644 --- a/home/dot_config/zsh/features/ghtkn-gh.zsh +++ b/home/dot_config/zsh/features/ghtkn-gh.zsh @@ -21,7 +21,7 @@ function gh { return 127 fi - command ghtkn exec -e GH_TOKEN -- gh "$@" + command ghtkn exec -e GH_TOKEN:C-FO/write -- gh "$@" local exit_status=$? if (( exit_status == 111 )); then print -u2 -- "gh: no ghtkn token available — run \`ghtkn agent unlock --enable-refresh\`" diff --git a/home/dot_zshenv.tmpl b/home/dot_zshenv.tmpl index 7ac2ee1..dadf674 100644 --- a/home/dot_zshenv.tmpl +++ b/home/dot_zshenv.tmpl @@ -167,8 +167,10 @@ export COLIMA_HOME="$XDG_CONFIG_HOME/colima" # aqua gets its GitHub token from the ghtkn agent (some C-FO repos resolve a # private aqua-registry). The agent must be unlocked after each login with # `ghtkn agent unlock --enable-refresh`. GITHUB_TOKEN, AQUA_GITHUB_TOKEN and -# AQUA_KEYRING_ENABLED must stay unset or aqua ignores ghtkn. +# AQUA_KEYRING_ENABLED must stay unset or aqua ignores ghtkn. GHTKN_APP picks +# the read app, since the first app (C-FO/none) cannot read the registry. export AQUA_GHTKN_ENABLED=true +export GHTKN_APP=C-FO/read # Corporate TLS inspection CA: wire palo-root.pem (in Homebrew's cert store) # into common SSL env vars so curl/Node/Python/Ruby-OpenSSL trust certificates diff --git a/tests/zsh/ghtkn-gh.test.sh b/tests/zsh/ghtkn-gh.test.sh index 64b493e..027a8f9 100644 --- a/tests/zsh/ghtkn-gh.test.sh +++ b/tests/zsh/ghtkn-gh.test.sh @@ -45,8 +45,8 @@ cat >"$fake_bin/ghtkn" <<'EOS' set -euo pipefail printf '%s\n' "$*" >>"${GHTKN_LOG:?}" [[ "${GHTKN_MODE:-success}" == "no-token" ]] && exit 111 -# Mirror `ghtkn exec -e GH_TOKEN -- cmd args...`: the token reaches the child env only. -shift 3 +# Mirror `ghtkn exec -e GH_TOKEN: -- cmd args...`: the token reaches the child env only. +shift 4 GH_TOKEN="fake-ghtkn-token" exec "$@" EOS @@ -89,8 +89,8 @@ run_wrapper "$fake_bin" zsh -f -c ' gh api "/repos/o/r" --jq ".name | length" [[ ! -v GH_TOKEN ]] ' 2>"$stderr_log" -grep -Fxq "exec -e GH_TOKEN -- gh api /repos/o/r --jq .name | length" "$ghtkn_log" || - fail "wrapper did not call ghtkn exec -e GH_TOKEN -- gh " +grep -Fxq "exec -e GH_TOKEN:C-FO/write -- gh api /repos/o/r --jq .name | length" "$ghtkn_log" || + fail "wrapper did not call ghtkn exec -e GH_TOKEN:C-FO/write -- gh " grep -Fxq "argc=4" "$gh_log" || fail "gh received the wrong argument count" grep -Fxq "arg=.name | length" "$gh_log" || fail "wrapper did not preserve an argument containing spaces" @@ -155,6 +155,8 @@ chezmoi_config="$test_root/chezmoi.json" mkdir -p "$render_root" printf '{}\n' >"$chezmoi_config" +client_ids_json='{"none":"Iv1.none","read":"Iv1.read","git":"Iv1.git","write":"Iv1.write"}' + chezmoi_isolated() { local business_use="$1" shift @@ -164,7 +166,7 @@ chezmoi_isolated() { --config-format json \ --source "$repo_root" \ --destination "$test_root/destination" \ - --override-data "{\"business_use\":$business_use,\"auto_tmux\":false,\"homebrew_prefix\":\"/opt/homebrew\",\"ghtkn_client_id\":\"Iv1.test\"}" \ + --override-data "{\"business_use\":$business_use,\"auto_tmux\":false,\"homebrew_prefix\":\"/opt/homebrew\",\"ghtkn_client_ids\":$client_ids_json}" \ "$@" } @@ -181,11 +183,32 @@ grep -Fq "gh: ghtkn is required" "$render_root/business.zshrc" || pass "wrapper is rendered only for the business profile" chezmoi_isolated true execute-template --file "$repo_root/home/dot_config/ghtkn/ghtkn.yaml.tmpl" >"$render_root/ghtkn.yaml" -grep -Fxq ' client_id: "Iv1.test"' "$render_root/ghtkn.yaml" || - fail "ghtkn.yaml did not render the client id" -grep -Fxq " type: agent" "$render_root/ghtkn.yaml" || +grep -Fxq " type: agent # access token の更新を自動化したい場合" "$render_root/ghtkn.yaml" || fail "ghtkn.yaml does not select the agent backend" -pass "ghtkn.yaml renders the client id with the agent backend" +for app in none read git write; do + grep -Fxq " client_id: \"Iv1.$app\"" "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml did not render the $app client id" + grep -Fq " - name: C-FO/$app " "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml did not render the C-FO/$app app" +done +[[ "$(grep -c '^ - name:' "$render_root/ghtkn.yaml")" -eq 4 ]] || + fail "ghtkn.yaml did not render exactly four apps" +grep -Fxq " git_owner: C-FO # Git Credential Helper により、 C-FO 配下のリポジトリに push/pull する際はこれが使用される" "$render_root/ghtkn.yaml" || + fail "ghtkn.yaml did not render git_owner for the git app" +pass "ghtkn.yaml renders every app with the agent backend" + +client_ids_json='{"read":"Iv1.read"}' +chezmoi_isolated true execute-template --file "$repo_root/home/dot_config/ghtkn/ghtkn.yaml.tmpl" >"$render_root/ghtkn-partial.yaml" +[[ "$(grep -c '^ - name:' "$render_root/ghtkn-partial.yaml")" -eq 1 ]] || + fail "ghtkn.yaml rendered an app without a client id" +grep -Fq " - name: C-FO/read " "$render_root/ghtkn-partial.yaml" || + fail "ghtkn.yaml dropped the app that has a client id" +client_ids_json='{}' +chezmoi_isolated true execute-template --file "$repo_root/home/dot_config/ghtkn/ghtkn.yaml.tmpl" >"$render_root/ghtkn-empty.yaml" +grep -Fxq "apps: []" "$render_root/ghtkn-empty.yaml" || + fail "ghtkn.yaml without client ids did not render apps: []" +pass "ghtkn.yaml renders only apps that have a client id" +client_ids_json='{"none":"Iv1.none","read":"Iv1.read","git":"Iv1.git","write":"Iv1.write"}' chezmoi_isolated false managed >"$render_root/managed-personal.txt" chezmoi_isolated true managed >"$render_root/managed-business.txt" From c4730e0db2c73b0c6100ac70797af9171c8c5068 Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Tue, 13 Oct 2026 11:54:54 +0900 Subject: [PATCH 5/6] feat(git): use the ghtkn credential helper for C-FO HTTPS remotes Keep C-FO URLs on HTTPS with an insteadOf override of the global SSH rewrite, and give them a path-aware ghtkn helper. The work include moves below the gh helper sections so its helper reset applies last. Co-Authored-By: Claude Opus 5.5 --- home/dot_config/git/config | 10 ++- home/dot_config/git/work.tmpl | 7 ++ tests/zsh/ghtkn-git-helper.test.sh | 123 +++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/zsh/ghtkn-git-helper.test.sh diff --git a/home/dot_config/git/config b/home/dot_config/git/config index 0120759..fd07b64 100644 --- a/home/dot_config/git/config +++ b/home/dot_config/git/config @@ -2,10 +2,6 @@ [include] path = ~/.config/git/main -# Work-specific config for C-FO repositories -[includeIf "gitdir:~/repos/github.com/C-FO/"] - path = ~/.config/git/work - # GitHub credential helper (gh auth) # Use env to find gh in PATH (works with devbox, homebrew, etc.) [credential "https://github.com"] @@ -16,6 +12,12 @@ helper = helper = !/usr/bin/env gh auth git-credential +# Work-specific config for C-FO repositories. Keep it below the credential +# sections: helper lists accumulate in file order, so work's `helper =` reset +# must come last to replace the gh helper. +[includeIf "gitdir:~/repos/github.com/C-FO/"] + path = ~/.config/git/work + # Secret detection: Use gitleaks (not configured in git config) # Usage: gitleaks detect --source . --verbose # Pre-commit: gitleaks protect --staged --verbose diff --git a/home/dot_config/git/work.tmpl b/home/dot_config/git/work.tmpl index 6f59910..7ecd85f 100644 --- a/home/dot_config/git/work.tmpl +++ b/home/dot_config/git/work.tmpl @@ -6,4 +6,11 @@ hooksPath = {{ .chezmoi.homeDir }}/.fdev-git-hooks [url "git@github.com:"] insteadOf = https://github.com/ +[url "https://github.com/C-FO/"] + insteadOf = https://github.com/C-FO/ +[credential "https://github.com"] + useHttpPath = true +[credential "https://github.com/C-FO"] + helper = + helper = !ghtkn git-credential {{- end }} diff --git a/tests/zsh/ghtkn-git-helper.test.sh b/tests/zsh/ghtkn-git-helper.test.sh new file mode 100644 index 0000000..5d1b77b --- /dev/null +++ b/tests/zsh/ghtkn-git-helper.test.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Verify the business-only C-FO ghtkn helper wiring in the rendered git config +# without invoking any credential helper. +set -euo pipefail + +unset GH_TOKEN GITHUB_TOKEN + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/../.." && pwd -P)" +git_source_dir="$repo_root/home/dot_config/git" + +for required_command in chezmoi cp git grep mkdir tail; do + command -v "$required_command" >/dev/null 2>&1 || { + echo "required command not found: $required_command" >&2 + exit 1 + } +done + +test_root="$(mktemp -d "${TMPDIR:-/tmp}/dots-ghtkn-git.XXXXXX")" +cleanup() { + rm -rf -- "$test_root" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +pass() { + echo "PASS: $1" +} + +chezmoi_config="$test_root/chezmoi.json" +printf '{}\n' >"$chezmoi_config" + +render() { + local business_use="$1" + local template="$2" + local output="$3" + + chezmoi \ + --config "$chezmoi_config" \ + --config-format json \ + --source "$repo_root" \ + --override-data "{\"business_use\":$business_use,\"name\":\"Test\",\"email\":\"t@example.com\",\"work_email\":\"t@work.example.com\",\"signing_key\":\"\"}" \ + execute-template --file "$template" >"$output" +} + +run_git() { + local profile_home="$1" + shift + env -i \ + "PATH=$PATH" \ + "HOME=$profile_home" \ + "GIT_CONFIG_GLOBAL=$profile_home/.config/git/config" \ + GIT_CONFIG_NOSYSTEM=1 \ + git "$@" +} + +in_repo() { + local profile_home="$1" + local repo="$2" + shift 2 + run_git "$profile_home" -C "$profile_home/repos/github.com/$repo" "$@" +} + +# Deploy the rendered config into a throwaway HOME so `~/.config/git/...` includes resolve to it. +setup_profile() { + local profile="$1" + local business_use="$2" + local profile_home="$test_root/$profile" + + mkdir -p "$profile_home/.config/git" "$profile_home/repos/github.com/C-FO" "$profile_home/repos/github.com/other" + cp "$git_source_dir/config" "$profile_home/.config/git/config" + render "$business_use" "$git_source_dir/main.tmpl" "$profile_home/.config/git/main" + render "$business_use" "$git_source_dir/work.tmpl" "$profile_home/.config/git/work" + run_git "$profile_home" init -q "$profile_home/repos/github.com/C-FO/clusterops" + run_git "$profile_home" init -q "$profile_home/repos/github.com/other/project" +} + +setup_profile business true +setup_profile personal false +business_home="$test_root/business" +personal_home="$test_root/personal" + +cfo_url="https://github.com/C-FO/clusterops" +other_url="https://github.com/other/project" +helper_pattern='^credential\..*helper$' + +[[ "$(in_repo "$business_home" C-FO/clusterops ls-remote --get-url "$cfo_url")" == "$cfo_url" ]] || + fail "business: a C-FO URL was rewritten away from HTTPS" +[[ "$(in_repo "$business_home" C-FO/clusterops ls-remote --get-url "$other_url")" == "git@github.com:other/project" ]] || + fail "business: a non-C-FO URL was not rewritten to SSH" +pass "business: C-FO stays HTTPS while other owners use SSH" + +business_helpers="$(in_repo "$business_home" C-FO/clusterops config --get-regexp "$helper_pattern")" +[[ "$(tail -n 1 <<<"$business_helpers")" == "credential.https://github.com/C-FO.helper !ghtkn git-credential" ]] || + fail "business: the ghtkn helper is not the last helper entry, so a gh helper entry would be appended after the reset" +pass "business: the C-FO ghtkn helper comes after the gh helper entries" + +[[ "$(in_repo "$business_home" C-FO/clusterops config --type=bool --get-urlmatch credential.useHttpPath "$cfo_url")" == "true" ]] || + fail "business: useHttpPath is not enabled for C-FO URLs" +pass "business: helper lookup is path-aware" + +other_helpers="$(in_repo "$business_home" other/project config --get-regexp "$helper_pattern")" +if grep -Fq "ghtkn" <<<"$other_helpers"; then + fail "business: the ghtkn helper leaks outside the C-FO checkout" +fi +pass "business: the ghtkn helper is limited to the C-FO checkout" + +if grep -Fq "C-FO" "$personal_home/.config/git/work"; then + fail "personal: the work config mentions C-FO" +fi +personal_helpers="$(in_repo "$personal_home" C-FO/clusterops config --get-regexp "$helper_pattern")" +if grep -Fq "ghtkn" <<<"$personal_helpers"; then + fail "personal: a ghtkn helper is configured" +fi +[[ "$(in_repo "$personal_home" C-FO/clusterops ls-remote --get-url "$other_url")" == "$other_url" ]] || + fail "personal: GitHub URLs were rewritten" +pass "personal: no C-FO helper or insteadOf configuration" + +echo "All ghtkn git helper tests passed." From 5b3f6d11d3c9bb2146395ca55ede59420d3c53f7 Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Tue, 13 Oct 2026 12:32:42 +0900 Subject: [PATCH 6/6] fix(zsh): clear an inherited AQUA_KEYRING_ENABLED on business shells Shells started under a multiplexer that predates the switch to ghtkn still inherit AQUA_KEYRING_ENABLED=true. aqua checks the keyring before ghtkn, so it sent the stale keyring token and the private C-FO registry failed with 401 Bad credentials. Co-Authored-By: Claude Opus 5.5 --- home/dot_zshenv.tmpl | 1 + tests/zsh/ghtkn-gh.test.sh | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/home/dot_zshenv.tmpl b/home/dot_zshenv.tmpl index dadf674..00679fb 100644 --- a/home/dot_zshenv.tmpl +++ b/home/dot_zshenv.tmpl @@ -169,6 +169,7 @@ export COLIMA_HOME="$XDG_CONFIG_HOME/colima" # `ghtkn agent unlock --enable-refresh`. GITHUB_TOKEN, AQUA_GITHUB_TOKEN and # AQUA_KEYRING_ENABLED must stay unset or aqua ignores ghtkn. GHTKN_APP picks # the read app, since the first app (C-FO/none) cannot read the registry. +unset AQUA_KEYRING_ENABLED # shells under a long-lived multiplexer still inherit it, and aqua checks keyring first export AQUA_GHTKN_ENABLED=true export GHTKN_APP=C-FO/read diff --git a/tests/zsh/ghtkn-gh.test.sh b/tests/zsh/ghtkn-gh.test.sh index 027a8f9..36dfbd3 100644 --- a/tests/zsh/ghtkn-gh.test.sh +++ b/tests/zsh/ghtkn-gh.test.sh @@ -182,6 +182,17 @@ grep -Fq "gh: ghtkn is required" "$render_root/business.zshrc" || fail "business zshrc is missing the ghtkn gh wrapper" pass "wrapper is rendered only for the business profile" +chezmoi_isolated true execute-template --file "$repo_root/home/dot_zshenv.tmpl" >"$render_root/business.zshenv" +mkdir -p "$test_root/home" +aqua_auth_env="$( + env -i HOME="$test_root/home" PATH="/usr/bin:/bin" AQUA_KEYRING_ENABLED=true \ + zsh -f -c 'source "$1" >/dev/null 2>&1; print -r -- "keyring=${AQUA_KEYRING_ENABLED-unset} ghtkn=${AQUA_GHTKN_ENABLED-unset}"' \ + zsh "$render_root/business.zshenv" +)" +[[ "$aqua_auth_env" == "keyring=unset ghtkn=true" ]] || + fail "business zshenv must drop an inherited AQUA_KEYRING_ENABLED and enable ghtkn (got: $aqua_auth_env)" +pass "business zshenv clears an inherited AQUA_KEYRING_ENABLED" + chezmoi_isolated true execute-template --file "$repo_root/home/dot_config/ghtkn/ghtkn.yaml.tmpl" >"$render_root/ghtkn.yaml" grep -Fxq " type: agent # access token の更新を自動化したい場合" "$render_root/ghtkn.yaml" || fail "ghtkn.yaml does not select the agent backend"