From f504aecffe7fe8fdad9fb2baeae3839701929877 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:44:34 -0700 Subject: [PATCH] fix(profiles): clone auth.json so OAuth credentials carry to cloned profiles (#51719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selective --clone / --clone-from / --clone-config copied .env but not auth.json, silently dropping the credential pool — including OAuth tokens (Anthropic `claude /login`, Codex, xAI) that never land in .env. A profile cloned from an OAuth-authenticated default therefore resolved a different provider (or none) than the source under provider: auto. --clone-all already carried auth.json via the full copytree; only the selective path missed it. Add auth.json to _CLONE_CONFIG_FILES and tighten it to 0o600 after copy, matching .env semantics. --- hermes_cli/profiles.py | 23 ++++++++++++++++++----- tests/hermes_cli/test_profiles.py | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 025eff5176c..6b5544ca093 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -53,9 +53,21 @@ _PROFILE_DIRS = [ ] # Files copied during --clone (if they exist in the source) +# +# auth.json carries the per-profile credential pool — including OAuth tokens +# (Anthropic `claude /login`, Codex, xAI) that never land in .env. Cloning +# .env but not auth.json silently dropped those credentials from the clone, +# so a profile cloned from an OAuth-authenticated default resolved a +# different provider (or none) than the source. The global-root fallback in +# read_credential_pool only masks this while the clone's HERMES_HOME still +# resolves to the same default root and the user hasn't run `hermes auth add` +# locally. Copying it here makes selective --clone match `.env` semantics and +# the full --clone-all copytree (which already carries auth.json). Both are +# credential files and get tightened to owner-only (0o600) after copy. _CLONE_CONFIG_FILES = [ "config.yaml", ".env", + "auth.json", "SOUL.md", ] @@ -938,11 +950,12 @@ def create_profile( if src.exists(): dst = profile_dir / filename shutil.copy2(src, dst) - # Tighten .env to owner-only after copy. shutil.copy2 - # preserves source mode bits, but if the source's .env - # was loose (host umask 0o022 leaving 0o644), tighten - # explicitly so the clone doesn't inherit weak perms. - if filename == ".env": + # Tighten credential files to owner-only after copy. + # shutil.copy2 preserves source mode bits, but if the + # source's .env / auth.json was loose (host umask 0o022 + # leaving 0o644), tighten explicitly so the clone doesn't + # inherit weak perms. + if filename in {".env", "auth.json"}: try: os.chmod(str(dst), 0o600) except OSError: diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 29840d8c728..edc5a82f86f 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -7,6 +7,7 @@ and shell completion generation. import json import io +import os import tarfile from pathlib import Path from unittest.mock import patch, MagicMock @@ -215,6 +216,27 @@ class TestCreateProfile: assert (profile_dir / ".env").read_text().strip() == "KEY=val" assert (profile_dir / "SOUL.md").read_text() == "Be helpful." + def test_clone_config_copies_auth_json(self, profile_env): + # auth.json holds the credential pool (incl. OAuth tokens that never + # land in .env). Selective --clone must carry it so a profile cloned + # from an OAuth-authenticated source keeps the same credentials, + # matching --clone-all and .env semantics. + tmp_path = profile_env + default_home = tmp_path / ".hermes" + (default_home / "auth.json").write_text( + '{"credential_pool": {"anthropic": [{"access_token": "tok"}]}}' + ) + + profile_dir = create_profile("coder", clone_config=True, no_alias=True) + + cloned_auth = profile_dir / "auth.json" + assert cloned_auth.exists() + cloned = json.loads(cloned_auth.read_text()) + assert cloned["credential_pool"]["anthropic"][0]["access_token"] == "tok" + # Credential file must be tightened to owner-only, like .env. + if os.name == "posix": + assert (cloned_auth.stat().st_mode & 0o777) == 0o600 + def test_clone_config_migrates_legacy_config_version(self, profile_env): tmp_path = profile_env default_home = tmp_path / ".hermes"