Covers one symptom: gh operations under one GitHub account failing or
silently behaving as a different, unrelated account, with no explicit
account switch performed in the current session. Applies to any machine
running multiple concurrent gh-authenticated processes (interactive
shells, IDE/agent sessions, their background tool servers) under more than
one GitHub account.
ghCLI installed, both accounts already authenticated at least once (gh auth statuslists both underLogged in to github.com account).- Shell access to inspect
~/.config/gh/hosts.ymland running processes (ps,lsof).
Symptom: a gh command that should run as one account errors or acts as
the other, e.g.:
GraphQL: Unauthorized: As an Enterprise Managed User, you cannot access
this content (mergePullRequest)
Confirm by running gh auth status — the Active account: true line will
show the wrong account for the work you're doing, with no gh auth switch
having been run in this session.
gh auth status— confirms which account is currently active machine-wide.ps aux | grep -i gh/ps aux | grep -i mcp— look for concurrent sessions or background tool-server processes that might be touching GitHub auth.- For any suspect process,
lsof -a -p <pid> -d cwd— its working directory usually reveals which account's work it's doing. - If it's a Node-based (or similar) tool server, grep its bundled source
for a literal
"gh"invocation via a subprocess call to confirm it shells out to theghbinary rather than using its own token.
Root cause, confirmed on this machine: gh's "active account" is a
single field in one shared file, ~/.config/gh/hosts.yml — not scoped
per-process, per-shell, or per-session. Any concurrent gh process on the
machine can flip it. A concurrent agent session working in an unrelated
project had a background tool-server process whose bundled code calls the
equivalent of gh auth token directly — reading whatever account happens
to be active at that moment, with no awareness of which account it
actually needs. This is a documented, acknowledged gh CLI limitation
(cli/cli Discussion #4221: "the active account is global, not
per-directory or per-repo"), not a one-off misconfiguration.
Give every account its own fully isolated gh config root
(GH_CONFIG_DIR), selected by working directory, at the one place every
gh invocation on the machine actually resolves from — not a shell
function (misses non-shell callers), not an IDE/agent lifecycle hook (its
process exits before anything it exports can persist to later, independent
command invocations).
-
Find the universal entry point. Check
which -a gh— if a user-owned directory (e.g.~/.local/bin) precedes the real binary in$PATH, and holds a script rather than the real executable, that's the one file every process resolvesghthrough. Confirm withps eww <pid>on a concurrent process you suspect — itsPATHshould show the same ordering. (On this machine, such a shim already existed for an unrelated compatibility patch, which made this the natural place to extend rather than build a parallel mechanism.) -
Create one isolated config root per account:
mkdir -p ~/.config/gh-<account-a> ~/.config/gh-<account-b> cp ~/.config/gh/config.yml ~/.config/gh-<account-a>/config.yml cp ~/.config/gh/config.yml ~/.config/gh-<account-b>/config.yml
-
Migrate existing credentials — no new OAuth flow needed:
GH_CONFIG_DIR=~/.config/gh-<account-a> gh auth login --hostname github.com \ --git-protocol ssh --with-token <<< "$(gh auth token --user <account-a>)" GH_CONFIG_DIR=~/.config/gh-<account-b> gh auth login --hostname github.com \ --git-protocol ssh --with-token <<< "$(gh auth token --user <account-b>)"
-
Add path-based dispatch to the universal entry point, right before it execs the real binary — only when the caller hasn't already set
GH_CONFIG_DIRexplicitly:if [ -z "${GH_CONFIG_DIR:-}" ]; then case "$PWD" in "$HOME"/Projects/<account-b-path-prefix>-*) export GH_CONFIG_DIR="$HOME/.config/gh-<account-b>" ;; *) export GH_CONFIG_DIR="$HOME/.config/gh-<account-a>" ;; esac fi exec "$REAL_GH" "$@"
-
Remove any earlier, partial attempt at this (e.g. a shell function in
.bashrc/.zshrc) so there's exactly one mechanism, not two that can disagree.
Solo-maintained tooling; no on-call rotation. If the fix doesn't hold
(active account still flips after remediation), stop and re-run Diagnosis
steps 2–4 against every currently-running gh-adjacent process before
changing anything further — don't guess at a second cause.
Verify:
GH_CONFIG_DIR=~/.config/gh-<account-a> gh auth status→ account A.GH_CONFIG_DIR=~/.config/gh-<account-b> gh auth status→ account B.cdinto an account-A project,unset GH_CONFIG_DIR,gh auth status→ account A, with existing aliases/editor/pager preferences intact.cdinto an account-B project (ideally the exact path the suspect concurrent process was running from),unset GH_CONFIG_DIR,gh auth status→ account B.- Hash
~/.config/gh/hosts.ymlbefore and after steps 3–4 — must be byte-identical. This is the actual proof: the shared file is never touched by dispatch-mediated calls again. - Anything the universal entry point special-cased before this change (an unrelated compatibility patch, in this case) still works unchanged.
Rollback: delete the dispatch block added in Remediation step 4 from
the universal entry point, restoring it to pass straight through to the
real binary. The two new GH_CONFIG_DIR roots and their credentials are
inert if unused — no need to delete them to roll back the behavior.