From 634c1f67523a3de4e95652dc000491fda154bf43 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 17 Mar 2026 01:13:34 -0700 Subject: [PATCH 1/4] fix: sanitize corrupted .env files on read and during migration Fixes two corruption patterns that break API keys during updates: 1. Concatenated KEY=VALUE pairs on a single line due to missing newlines (e.g. ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...). Uses a known-keys set to safely detect and split concatenated entries without false-splitting values that contain uppercase text. 2. Stale KEY=*** placeholder entries left by incomplete setup runs that never get updated and shadow real credentials. Changes: - Add _sanitize_env_lines() that splits concatenated known keys and drops *** placeholders - Add sanitize_env_file() public API for explicit repair - Call sanitization in save_env_value() on every read (self-healing) - Call sanitize_env_file() at the start of migrate_config() so existing corrupted files are repaired on update - 12 new tests covering splits, placeholders, edge cases, and integration --- hermes_cli/config.py | 124 ++++++++++++++++++++++++++++++ tests/hermes_cli/test_config.py | 132 ++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c3a4c701..18bfc6de 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -25,6 +25,18 @@ from typing import Dict, Any, Optional, List, Tuple _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# Env var names written to .env that aren't in OPTIONAL_ENV_VARS +# (managed by setup/provider flows directly). +_EXTRA_ENV_KEYS = frozenset({ + "OPENAI_API_KEY", "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", + "AUXILIARY_VISION_MODEL", + "DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL", + "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL", + "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", + "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", + "WHATSAPP_MODE", "WHATSAPP_ENABLED", +}) import yaml @@ -765,6 +777,14 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]} """ results = {"env_added": [], "config_added": [], "warnings": []} + + # ── Always: sanitize .env (split concatenated keys, drop *** placeholders) ── + try: + fixes = sanitize_env_file() + if fixes and not quiet: + print(f" ✓ Repaired .env file ({fixes} corrupted entries fixed)") + except Exception: + pass # best-effort; don't block migration on sanitize failure # Check config version current_ver, latest_ver = check_config_version() @@ -1121,6 +1141,108 @@ def load_env() -> Dict[str, str]: return env_vars +def _sanitize_env_lines(lines: list) -> list: + """Fix corrupted .env lines before writing. + + Handles two known corruption patterns: + 1. Concatenated KEY=VALUE pairs on a single line (missing newline between + entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``). + 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs. + + Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only + split on real Hermes env var names, avoiding false positives from values + that happen to contain uppercase text with ``=``. + """ + # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras. + # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined. + known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS + + sanitized: list[str] = [] + for line in lines: + raw = line.rstrip("\r\n") + stripped = raw.strip() + + # Preserve blank lines and comments + if not stripped or stripped.startswith("#"): + sanitized.append(raw + "\n") + continue + + # Drop stale *** placeholder entries + if "=" in stripped: + _k, _, _v = stripped.partition("=") + if _v.strip().strip("'\"") == "***": + continue + + # Detect concatenated KEY=VALUE pairs on one line. + # Search for known KEY= patterns at any position in the line. + split_positions = [] + for key_name in known_keys: + needle = key_name + "=" + idx = stripped.find(needle) + while idx >= 0: + split_positions.append(idx) + idx = stripped.find(needle, idx + len(needle)) + + if len(split_positions) > 1: + split_positions.sort() + # Deduplicate (shouldn't happen, but be safe) + split_positions = sorted(set(split_positions)) + for i, pos in enumerate(split_positions): + end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped) + part = stripped[pos:end].strip() + if part: + sanitized.append(part + "\n") + else: + sanitized.append(stripped + "\n") + + return sanitized + + +def sanitize_env_file() -> int: + """Read, sanitize, and rewrite ~/.hermes/.env in place. + + Returns the number of lines that were fixed (concatenation splits + + placeholder removals). Returns 0 when no changes are needed. + """ + env_path = get_env_path() + if not env_path.exists(): + return 0 + + read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} + write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {} + + with open(env_path, **read_kw) as f: + original_lines = f.readlines() + + sanitized = _sanitize_env_lines(original_lines) + + if sanitized == original_lines: + return 0 + + # Count fixes: difference in line count (from splits) + removed lines + fixes = abs(len(sanitized) - len(original_lines)) + if fixes == 0: + # Lines changed content (e.g. *** removal) even if count is same + fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b) + fixes += abs(len(sanitized) - len(original_lines)) + + fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_") + try: + with os.fdopen(fd, "w", **write_kw) as f: + f.writelines(sanitized) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, env_path) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + _secure_file(env_path) + return fixes + + def save_env_value(key: str, value: str): """Save or update a value in ~/.hermes/.env.""" if not _ENV_VAR_NAME_RE.match(key): @@ -1138,6 +1260,8 @@ def save_env_value(key: str, value: str): if env_path.exists(): with open(env_path, **read_kw) as f: lines = f.readlines() + # Sanitize on every read: split concatenated keys, drop stale placeholders + lines = _sanitize_env_lines(lines) # Find and update or append found = False diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index d6dc2af1..1a93d639 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -15,6 +15,8 @@ from hermes_cli.config import ( save_config, save_env_value, save_env_value_secure, + sanitize_env_file, + _sanitize_env_lines, ) @@ -203,3 +205,133 @@ class TestSaveConfigAtomicity: raw = yaml.safe_load(f) assert raw["model"] == "test/atomic-model" assert raw["agent"]["max_turns"] == 77 + + +class TestSanitizeEnvLines: + """Tests for .env file corruption repair.""" + + def test_splits_concatenated_keys(self): + """Two KEY=VALUE pairs jammed on one line get split.""" + lines = ["ANTHROPIC_API_KEY=sk-ant-xxxOPENAI_BASE_URL=https://api.openai.com/v1\n"] + result = _sanitize_env_lines(lines) + assert result == [ + "ANTHROPIC_API_KEY=sk-ant-xxx\n", + "OPENAI_BASE_URL=https://api.openai.com/v1\n", + ] + + def test_drops_stale_placeholder(self): + """KEY=*** entries are removed.""" + lines = [ + "OPENROUTER_API_KEY=sk-or-real\n", + "ANTHROPIC_TOKEN=***\n", + "FAL_KEY=fal-real\n", + ] + result = _sanitize_env_lines(lines) + assert result == [ + "OPENROUTER_API_KEY=sk-or-real\n", + "FAL_KEY=fal-real\n", + ] + + def test_drops_quoted_placeholder(self): + """KEY='***' and KEY=\"***\" are also removed.""" + lines = ['ANTHROPIC_TOKEN="***"\n', "OTHER_KEY='***'\n"] + result = _sanitize_env_lines(lines) + assert result == [] + + def test_preserves_clean_file(self): + """A well-formed .env file passes through unchanged (modulo trailing newlines).""" + lines = [ + "OPENROUTER_API_KEY=sk-or-xxx\n", + "FIRECRAWL_API_KEY=fc-xxx\n", + "# a comment\n", + "\n", + ] + result = _sanitize_env_lines(lines) + assert result == lines + + def test_preserves_comments_and_blanks(self): + lines = ["# comment\n", "\n", "KEY=val\n"] + result = _sanitize_env_lines(lines) + assert result == lines + + def test_adds_missing_trailing_newline(self): + """Lines missing trailing newline get one added.""" + lines = ["FOO_BAR=baz"] + result = _sanitize_env_lines(lines) + assert result == ["FOO_BAR=baz\n"] + + def test_three_concatenated_keys(self): + """Three known keys on one line all get separated.""" + lines = ["FAL_KEY=111FIRECRAWL_API_KEY=222GITHUB_TOKEN=333\n"] + result = _sanitize_env_lines(lines) + assert result == [ + "FAL_KEY=111\n", + "FIRECRAWL_API_KEY=222\n", + "GITHUB_TOKEN=333\n", + ] + + def test_value_with_equals_sign_not_split(self): + """A value containing '=' shouldn't be falsely split (lowercase in value).""" + lines = ["OPENAI_BASE_URL=https://api.example.com/v1?key=abc123\n"] + result = _sanitize_env_lines(lines) + assert result == lines + + def test_unknown_keys_not_split(self): + """Unknown key names on one line are NOT split (avoids false positives).""" + lines = ["CUSTOM_VAR=value123OTHER_THING=value456\n"] + result = _sanitize_env_lines(lines) + # Unknown keys stay on one line — no false split + assert len(result) == 1 + + def test_value_ending_with_digits_still_splits(self): + """Concatenation is detected even when value ends with digits.""" + lines = ["OPENROUTER_API_KEY=sk-or-v1-abc123OPENAI_BASE_URL=https://api.openai.com/v1\n"] + result = _sanitize_env_lines(lines) + assert len(result) == 2 + assert result[0].startswith("OPENROUTER_API_KEY=") + assert result[1].startswith("OPENAI_BASE_URL=") + + def test_save_env_value_fixes_corruption_on_write(self, tmp_path): + """save_env_value sanitizes corrupted lines when writing a new key.""" + env_file = tmp_path / ".env" + env_file.write_text( + "ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n" + "STALE_KEY=***\n" + ) + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + save_env_value("NEW_KEY", "new-value") + + content = env_file.read_text() + lines = content.strip().split("\n") + + # Corrupted line should be split, placeholder removed, new key added + assert "ANTHROPIC_API_KEY=sk-ant" in lines + assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines + assert "NEW_KEY=new-value" in lines + assert "STALE_KEY=***" not in content + + def test_sanitize_env_file_returns_fix_count(self, tmp_path): + """sanitize_env_file reports how many entries were fixed.""" + env_file = tmp_path / ".env" + env_file.write_text( + "FAL_KEY=good\n" + "OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n" + "STALE=***\n" + ) + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + fixes = sanitize_env_file() + assert fixes > 0 + + # Verify file is now clean + content = env_file.read_text() + assert "STALE=***" not in content + assert "OPENROUTER_API_KEY=val\n" in content + assert "FIRECRAWL_API_KEY=val2\n" in content + + def test_sanitize_env_file_noop_on_clean_file(self, tmp_path): + """No changes when file is already clean.""" + env_file = tmp_path / ".env" + env_file.write_text("GOOD_KEY=good\nOTHER_KEY=other\n") + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + fixes = sanitize_env_file() + assert fixes == 0 From b6a51c955eec5184969da71ed998c3defbc67487 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 17 Mar 2026 01:26:23 -0700 Subject: [PATCH 2/4] fix: clear stale ANTHROPIC_TOKEN during migration, remove false *** detection - Remove *** placeholder detection from _sanitize_env_lines (was based on confusing terminal redaction with literal file content) - Add migrate_config() logic to clear stale ANTHROPIC_TOKEN when better credentials exist (ANTHROPIC_API_KEY or Claude Code auto-discovery) - Old ANTHROPIC_TOKEN values shadow Claude Code credential fallthrough, breaking auth for users who updated without re-running setup - Preserves ANTHROPIC_TOKEN when it's the only auth method available - 3 new migration tests, updated existing tests --- hermes_cli/config.py | 36 ++++++++++--- tests/hermes_cli/test_config.py | 94 ++++++++++++++++++++++++--------- 2 files changed, 97 insertions(+), 33 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 18bfc6de..1cdf7853 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -778,13 +778,41 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A """ results = {"env_added": [], "config_added": [], "warnings": []} - # ── Always: sanitize .env (split concatenated keys, drop *** placeholders) ── + # ── Always: sanitize .env (split concatenated keys) ── try: fixes = sanitize_env_file() if fixes and not quiet: print(f" ✓ Repaired .env file ({fixes} corrupted entries fixed)") except Exception: pass # best-effort; don't block migration on sanitize failure + + # ── Always: clear stale ANTHROPIC_TOKEN when better credentials exist ── + # Old setups left ANTHROPIC_TOKEN with an outdated value that shadows + # Claude Code auto-discovery (CLAUDE_CODE_OAUTH_TOKEN) or a direct + # ANTHROPIC_API_KEY. + try: + old_token = get_env_value("ANTHROPIC_TOKEN") + if old_token: + has_api_key = bool(get_env_value("ANTHROPIC_API_KEY")) + has_claude_code = False + try: + from agent.anthropic_adapter import ( + read_claude_code_credentials, + is_claude_code_token_valid, + ) + cc_creds = read_claude_code_credentials() + has_claude_code = bool( + cc_creds and is_claude_code_token_valid(cc_creds) + ) + except Exception: + pass + if has_api_key or has_claude_code: + save_env_value("ANTHROPIC_TOKEN", "") + if not quiet: + source = "ANTHROPIC_API_KEY" if has_api_key else "Claude Code credentials" + print(f" ✓ Cleared stale ANTHROPIC_TOKEN (using {source} instead)") + except Exception: + pass # Check config version current_ver, latest_ver = check_config_version() @@ -1167,12 +1195,6 @@ def _sanitize_env_lines(lines: list) -> list: sanitized.append(raw + "\n") continue - # Drop stale *** placeholder entries - if "=" in stripped: - _k, _, _v = stripped.partition("=") - if _v.strip().strip("'\"") == "***": - continue - # Detect concatenated KEY=VALUE pairs on one line. # Search for known KEY= patterns at any position in the line. split_positions = [] diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 1a93d639..94e7f6a5 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -12,6 +12,7 @@ from hermes_cli.config import ( ensure_hermes_home, load_config, load_env, + migrate_config, save_config, save_env_value, save_env_value_secure, @@ -219,25 +220,6 @@ class TestSanitizeEnvLines: "OPENAI_BASE_URL=https://api.openai.com/v1\n", ] - def test_drops_stale_placeholder(self): - """KEY=*** entries are removed.""" - lines = [ - "OPENROUTER_API_KEY=sk-or-real\n", - "ANTHROPIC_TOKEN=***\n", - "FAL_KEY=fal-real\n", - ] - result = _sanitize_env_lines(lines) - assert result == [ - "OPENROUTER_API_KEY=sk-or-real\n", - "FAL_KEY=fal-real\n", - ] - - def test_drops_quoted_placeholder(self): - """KEY='***' and KEY=\"***\" are also removed.""" - lines = ['ANTHROPIC_TOKEN="***"\n', "OTHER_KEY='***'\n"] - result = _sanitize_env_lines(lines) - assert result == [] - def test_preserves_clean_file(self): """A well-formed .env file passes through unchanged (modulo trailing newlines).""" lines = [ @@ -296,19 +278,18 @@ class TestSanitizeEnvLines: env_file = tmp_path / ".env" env_file.write_text( "ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n" - "STALE_KEY=***\n" + "FAL_KEY=existing\n" ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): - save_env_value("NEW_KEY", "new-value") + save_env_value("MESSAGING_CWD", "/tmp") content = env_file.read_text() lines = content.strip().split("\n") - # Corrupted line should be split, placeholder removed, new key added + # Corrupted line should be split, new key added assert "ANTHROPIC_API_KEY=sk-ant" in lines assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines - assert "NEW_KEY=new-value" in lines - assert "STALE_KEY=***" not in content + assert "MESSAGING_CWD=/tmp" in lines def test_sanitize_env_file_returns_fix_count(self, tmp_path): """sanitize_env_file reports how many entries were fixed.""" @@ -316,7 +297,6 @@ class TestSanitizeEnvLines: env_file.write_text( "FAL_KEY=good\n" "OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n" - "STALE=***\n" ) with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): fixes = sanitize_env_file() @@ -324,7 +304,6 @@ class TestSanitizeEnvLines: # Verify file is now clean content = env_file.read_text() - assert "STALE=***" not in content assert "OPENROUTER_API_KEY=val\n" in content assert "FIRECRAWL_API_KEY=val2\n" in content @@ -335,3 +314,66 @@ class TestSanitizeEnvLines: with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): fixes = sanitize_env_file() assert fixes == 0 + + +class TestStaleAnthropicTokenMigration: + """Test that migrate_config clears stale ANTHROPIC_TOKEN.""" + + def test_clears_stale_token_when_api_key_exists(self, tmp_path): + """ANTHROPIC_TOKEN cleared when ANTHROPIC_API_KEY is also set.""" + env_file = tmp_path / ".env" + env_file.write_text( + "ANTHROPIC_API_KEY=sk-ant-real-key\n" + "ANTHROPIC_TOKEN=old-stale-token\n" + ) + with patch.dict(os.environ, { + "HERMES_HOME": str(tmp_path), + "ANTHROPIC_API_KEY": "sk-ant-real-key", + "ANTHROPIC_TOKEN": "old-stale-token", + }): + migrate_config(interactive=False, quiet=True) + + env = load_env() + assert env.get("ANTHROPIC_TOKEN") == "" + assert env.get("ANTHROPIC_API_KEY") == "sk-ant-real-key" + + def test_clears_stale_token_when_claude_code_available(self, tmp_path): + """ANTHROPIC_TOKEN cleared when Claude Code credentials exist.""" + env_file = tmp_path / ".env" + env_file.write_text("ANTHROPIC_TOKEN=old-stale-token\n") + + fake_creds = {"accessToken": "valid-token", "expiresAt": 0} + with patch.dict(os.environ, { + "HERMES_HOME": str(tmp_path), + "ANTHROPIC_TOKEN": "old-stale-token", + }): + with patch( + "agent.anthropic_adapter.read_claude_code_credentials", + return_value=fake_creds, + ), patch( + "agent.anthropic_adapter.is_claude_code_token_valid", + return_value=True, + ): + migrate_config(interactive=False, quiet=True) + + env = load_env() + assert env.get("ANTHROPIC_TOKEN") == "" + + def test_preserves_token_when_no_alternative(self, tmp_path): + """ANTHROPIC_TOKEN kept when no API key or Claude Code creds exist.""" + env_file = tmp_path / ".env" + env_file.write_text("ANTHROPIC_TOKEN=only-auth-method\n") + + with patch.dict(os.environ, { + "HERMES_HOME": str(tmp_path), + "ANTHROPIC_TOKEN": "only-auth-method", + }): + os.environ.pop("ANTHROPIC_API_KEY", None) + with patch( + "agent.anthropic_adapter.read_claude_code_credentials", + return_value=None, + ): + migrate_config(interactive=False, quiet=True) + + env = load_env() + assert env.get("ANTHROPIC_TOKEN") == "only-auth-method" From e9f1a8e39bfbe5358720bcc586aa4249abefda5e Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 17 Mar 2026 01:28:38 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20gate=20ANTHROPIC=5FTOKEN=20cleanup?= =?UTF-8?q?=20to=20config=20version=208=E2=86=929=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump _config_version 8 → 9 - Move stale ANTHROPIC_TOKEN clearing into 'if current_ver < 9' block so it only runs once during the upgrade, not on every migrate_config() - ANTHROPIC_TOKEN is still a valid auth path (OAuth flow), so we don't want to clear it repeatedly — only during the one-time migration from old setups that left it stale - Add test_skips_on_version_9_or_later to verify one-time behavior - All tests set config version 8 to trigger migration --- hermes_cli/config.py | 56 ++++++++++++++++----------------- tests/hermes_cli/test_config.py | 28 +++++++++++++++++ 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 1cdf7853..0dde47bf 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -349,7 +349,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 8, + "_config_version": 9, } # ============================================================================= @@ -786,34 +786,6 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A except Exception: pass # best-effort; don't block migration on sanitize failure - # ── Always: clear stale ANTHROPIC_TOKEN when better credentials exist ── - # Old setups left ANTHROPIC_TOKEN with an outdated value that shadows - # Claude Code auto-discovery (CLAUDE_CODE_OAUTH_TOKEN) or a direct - # ANTHROPIC_API_KEY. - try: - old_token = get_env_value("ANTHROPIC_TOKEN") - if old_token: - has_api_key = bool(get_env_value("ANTHROPIC_API_KEY")) - has_claude_code = False - try: - from agent.anthropic_adapter import ( - read_claude_code_credentials, - is_claude_code_token_valid, - ) - cc_creds = read_claude_code_credentials() - has_claude_code = bool( - cc_creds and is_claude_code_token_valid(cc_creds) - ) - except Exception: - pass - if has_api_key or has_claude_code: - save_env_value("ANTHROPIC_TOKEN", "") - if not quiet: - source = "ANTHROPIC_API_KEY" if has_api_key else "Claude Code credentials" - print(f" ✓ Cleared stale ANTHROPIC_TOKEN (using {source} instead)") - except Exception: - pass - # Check config version current_ver, latest_ver = check_config_version() @@ -856,6 +828,32 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A tz_display = config["timezone"] or "(server-local)" print(f" ✓ Added timezone to config.yaml: {tz_display}") + # ── Version 8 → 9: clear stale ANTHROPIC_TOKEN when better creds exist ── + if current_ver < 9: + try: + old_token = get_env_value("ANTHROPIC_TOKEN") + if old_token: + has_api_key = bool(get_env_value("ANTHROPIC_API_KEY")) + has_claude_code = False + try: + from agent.anthropic_adapter import ( + read_claude_code_credentials, + is_claude_code_token_valid, + ) + cc_creds = read_claude_code_credentials() + has_claude_code = bool( + cc_creds and is_claude_code_token_valid(cc_creds) + ) + except Exception: + pass + if has_api_key or has_claude_code: + save_env_value("ANTHROPIC_TOKEN", "") + if not quiet: + source = "ANTHROPIC_API_KEY" if has_api_key else "Claude Code credentials" + print(f" ✓ Cleared stale ANTHROPIC_TOKEN (using {source} instead)") + except Exception: + pass + if current_ver < latest_ver and not quiet: print(f"Config version: {current_ver} → {latest_ver}") diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 94e7f6a5..4c5a547e 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -319,8 +319,16 @@ class TestSanitizeEnvLines: class TestStaleAnthropicTokenMigration: """Test that migrate_config clears stale ANTHROPIC_TOKEN.""" + def _write_config_version(self, tmp_path, version): + """Write a config.yaml with a specific _config_version.""" + config_path = tmp_path / "config.yaml" + import yaml + config = {"_config_version": version} + config_path.write_text(yaml.safe_dump(config, sort_keys=False)) + def test_clears_stale_token_when_api_key_exists(self, tmp_path): """ANTHROPIC_TOKEN cleared when ANTHROPIC_API_KEY is also set.""" + self._write_config_version(tmp_path, 8) env_file = tmp_path / ".env" env_file.write_text( "ANTHROPIC_API_KEY=sk-ant-real-key\n" @@ -339,6 +347,7 @@ class TestStaleAnthropicTokenMigration: def test_clears_stale_token_when_claude_code_available(self, tmp_path): """ANTHROPIC_TOKEN cleared when Claude Code credentials exist.""" + self._write_config_version(tmp_path, 8) env_file = tmp_path / ".env" env_file.write_text("ANTHROPIC_TOKEN=old-stale-token\n") @@ -361,6 +370,7 @@ class TestStaleAnthropicTokenMigration: def test_preserves_token_when_no_alternative(self, tmp_path): """ANTHROPIC_TOKEN kept when no API key or Claude Code creds exist.""" + self._write_config_version(tmp_path, 8) env_file = tmp_path / ".env" env_file.write_text("ANTHROPIC_TOKEN=only-auth-method\n") @@ -377,3 +387,21 @@ class TestStaleAnthropicTokenMigration: env = load_env() assert env.get("ANTHROPIC_TOKEN") == "only-auth-method" + + def test_skips_on_version_9_or_later(self, tmp_path): + """Migration doesn't fire when already at config version 9+.""" + self._write_config_version(tmp_path, 9) + env_file = tmp_path / ".env" + env_file.write_text( + "ANTHROPIC_API_KEY=sk-ant-real-key\n" + "ANTHROPIC_TOKEN=should-stay\n" + ) + with patch.dict(os.environ, { + "HERMES_HOME": str(tmp_path), + "ANTHROPIC_API_KEY": "sk-ant-real-key", + "ANTHROPIC_TOKEN": "should-stay", + }): + migrate_config(interactive=False, quiet=True) + + env = load_env() + assert env.get("ANTHROPIC_TOKEN") == "should-stay" From 1c61ab6bd9ecf2b92c77fcc882bffa82a660a60c Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 17 Mar 2026 01:31:20 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20unconditionally=20clear=20ANTHROPIC?= =?UTF-8?q?=5FTOKEN=20on=20v8=E2=86=92v9=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No conditional checks — just clear it. The new auth flow doesn't use this env var. Anyone upgrading gets it wiped once, then it's done. --- hermes_cli/config.py | 24 ++-------- tests/hermes_cli/test_config.py | 84 +++++---------------------------- 2 files changed, 17 insertions(+), 91 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 0dde47bf..c17154bd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -828,29 +828,15 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A tz_display = config["timezone"] or "(server-local)" print(f" ✓ Added timezone to config.yaml: {tz_display}") - # ── Version 8 → 9: clear stale ANTHROPIC_TOKEN when better creds exist ── + # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ── + # The new Anthropic auth flow no longer uses this env var. if current_ver < 9: try: old_token = get_env_value("ANTHROPIC_TOKEN") if old_token: - has_api_key = bool(get_env_value("ANTHROPIC_API_KEY")) - has_claude_code = False - try: - from agent.anthropic_adapter import ( - read_claude_code_credentials, - is_claude_code_token_valid, - ) - cc_creds = read_claude_code_credentials() - has_claude_code = bool( - cc_creds and is_claude_code_token_valid(cc_creds) - ) - except Exception: - pass - if has_api_key or has_claude_code: - save_env_value("ANTHROPIC_TOKEN", "") - if not quiet: - source = "ANTHROPIC_API_KEY" if has_api_key else "Claude Code credentials" - print(f" ✓ Cleared stale ANTHROPIC_TOKEN (using {source} instead)") + save_env_value("ANTHROPIC_TOKEN", "") + if not quiet: + print(" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)") except Exception: pass diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 4c5a547e..ba4f5c84 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -316,92 +316,32 @@ class TestSanitizeEnvLines: assert fixes == 0 -class TestStaleAnthropicTokenMigration: - """Test that migrate_config clears stale ANTHROPIC_TOKEN.""" +class TestAnthropicTokenMigration: + """Test that config version 8→9 clears ANTHROPIC_TOKEN.""" def _write_config_version(self, tmp_path, version): - """Write a config.yaml with a specific _config_version.""" config_path = tmp_path / "config.yaml" import yaml - config = {"_config_version": version} - config_path.write_text(yaml.safe_dump(config, sort_keys=False)) + config_path.write_text(yaml.safe_dump({"_config_version": version})) - def test_clears_stale_token_when_api_key_exists(self, tmp_path): - """ANTHROPIC_TOKEN cleared when ANTHROPIC_API_KEY is also set.""" + def test_clears_token_on_upgrade_to_v9(self, tmp_path): + """ANTHROPIC_TOKEN is cleared unconditionally when upgrading to v9.""" self._write_config_version(tmp_path, 8) - env_file = tmp_path / ".env" - env_file.write_text( - "ANTHROPIC_API_KEY=sk-ant-real-key\n" - "ANTHROPIC_TOKEN=old-stale-token\n" - ) + (tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n") with patch.dict(os.environ, { "HERMES_HOME": str(tmp_path), - "ANTHROPIC_API_KEY": "sk-ant-real-key", - "ANTHROPIC_TOKEN": "old-stale-token", + "ANTHROPIC_TOKEN": "old-token", }): migrate_config(interactive=False, quiet=True) - - env = load_env() - assert env.get("ANTHROPIC_TOKEN") == "" - assert env.get("ANTHROPIC_API_KEY") == "sk-ant-real-key" - - def test_clears_stale_token_when_claude_code_available(self, tmp_path): - """ANTHROPIC_TOKEN cleared when Claude Code credentials exist.""" - self._write_config_version(tmp_path, 8) - env_file = tmp_path / ".env" - env_file.write_text("ANTHROPIC_TOKEN=old-stale-token\n") - - fake_creds = {"accessToken": "valid-token", "expiresAt": 0} - with patch.dict(os.environ, { - "HERMES_HOME": str(tmp_path), - "ANTHROPIC_TOKEN": "old-stale-token", - }): - with patch( - "agent.anthropic_adapter.read_claude_code_credentials", - return_value=fake_creds, - ), patch( - "agent.anthropic_adapter.is_claude_code_token_valid", - return_value=True, - ): - migrate_config(interactive=False, quiet=True) - - env = load_env() - assert env.get("ANTHROPIC_TOKEN") == "" - - def test_preserves_token_when_no_alternative(self, tmp_path): - """ANTHROPIC_TOKEN kept when no API key or Claude Code creds exist.""" - self._write_config_version(tmp_path, 8) - env_file = tmp_path / ".env" - env_file.write_text("ANTHROPIC_TOKEN=only-auth-method\n") - - with patch.dict(os.environ, { - "HERMES_HOME": str(tmp_path), - "ANTHROPIC_TOKEN": "only-auth-method", - }): - os.environ.pop("ANTHROPIC_API_KEY", None) - with patch( - "agent.anthropic_adapter.read_claude_code_credentials", - return_value=None, - ): - migrate_config(interactive=False, quiet=True) - - env = load_env() - assert env.get("ANTHROPIC_TOKEN") == "only-auth-method" + assert load_env().get("ANTHROPIC_TOKEN") == "" def test_skips_on_version_9_or_later(self, tmp_path): - """Migration doesn't fire when already at config version 9+.""" + """Already at v9 — ANTHROPIC_TOKEN is not touched.""" self._write_config_version(tmp_path, 9) - env_file = tmp_path / ".env" - env_file.write_text( - "ANTHROPIC_API_KEY=sk-ant-real-key\n" - "ANTHROPIC_TOKEN=should-stay\n" - ) + (tmp_path / ".env").write_text("ANTHROPIC_TOKEN=current-token\n") with patch.dict(os.environ, { "HERMES_HOME": str(tmp_path), - "ANTHROPIC_API_KEY": "sk-ant-real-key", - "ANTHROPIC_TOKEN": "should-stay", + "ANTHROPIC_TOKEN": "current-token", }): migrate_config(interactive=False, quiet=True) - - env = load_env() - assert env.get("ANTHROPIC_TOKEN") == "should-stay" + assert load_env().get("ANTHROPIC_TOKEN") == "current-token"