feat(v1.1.1): auto-mirror macOS Keychain credentials in install.sh
On macOS, Claude Code stores OAuth credentials in Keychain but hermes-agent reads them from ~/.claude/.credentials.json. The oneliner would install the patch successfully but auth still failed until users manually ran DrQbz's Python mirror snippet (README troubleshooting). install.sh now detects Darwin via 'uname -s' and mirrors the 'Claude Code-credentials' Keychain entry into ~/.claude/.credentials.json on every run (with content-compare to skip no-op writes). No-op on Linux where Claude Code writes the file directly. - install.sh: Darwin branch reads Keychain via 'security' and mirrors to file with 0600 perms; warns if neither Keychain entry nor file exist so the user knows to run 'claude auth login --claudeai' - README.md: troubleshooting section updated to note the auto-mirror, keeps the manual Python snippet as a fallback for edge cases - anthropic_billing_bypass.py: version bump to 1.1.1 (module unchanged, tracks the installer release) - tests/test_install.sh: 3 new tests via PATH-shimmed 'uname' and 'security' fakes — fresh mirror, idempotent re-run, missing Keychain Closes the last gap on issue #5 (@DrQbz, macOS Keychain auth).
This commit is contained in:
parent
b430a8a78f
commit
75a3f71200
43
README.md
43
README.md
@ -65,29 +65,17 @@ Installed through a `sitecustomize.py` MetaPathFinder hook, so it runs at interp
|
|||||||
|
|
||||||
### Auth issues
|
### Auth issues
|
||||||
|
|
||||||
- **`Anthropic 401 authentication failed`** or **`No Anthropic credentials found`**: Hermes reads Claude subscription credentials from `~/.claude/.credentials.json`. If Claude Code is authenticated (e.g. in macOS Keychain) but that file is missing or stale, Hermes fails even when Claude Code itself works. Fix:
|
- **`Anthropic 401 authentication failed`** or **`No Anthropic credentials found`**: Hermes reads Claude subscription credentials from `~/.claude/.credentials.json`. If Claude Code is authenticated (e.g. in macOS Keychain) but that file is missing or stale, Hermes fails even when Claude Code itself works.
|
||||||
|
|
||||||
|
On macOS, `install.sh` v1.1.1+ auto-mirrors the `Claude Code-credentials` Keychain entry into `~/.claude/.credentials.json` on every run, so re-running the installer is usually enough. Full fix:
|
||||||
|
|
||||||
1. Refresh Claude subscription login:
|
1. Refresh Claude subscription login:
|
||||||
```bash
|
```bash
|
||||||
claude auth login --claudeai
|
claude auth login --claudeai
|
||||||
```
|
```
|
||||||
2. **macOS only** — mirror the Keychain `Claude Code-credentials` entry into the file Hermes reads:
|
2. Re-run the installer to re-mirror credentials (macOS) and reload the patch:
|
||||||
```bash
|
```bash
|
||||||
python3 - <<'PY'
|
./install.sh
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
secret = subprocess.check_output(
|
|
||||||
['security', 'find-generic-password', '-s', 'Claude Code-credentials', '-w'],
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
cred_path = Path.home() / '.claude' / '.credentials.json'
|
|
||||||
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
cred_path.write_text(secret)
|
|
||||||
cred_path.chmod(0o600)
|
|
||||||
print(f'wrote {cred_path}')
|
|
||||||
PY
|
|
||||||
```
|
```
|
||||||
3. Remove stale `ANTHROPIC_TOKEN` / `ANTHROPIC_API_KEY` values from `~/.hermes/.env` — they can override subscription auth.
|
3. Remove stale `ANTHROPIC_TOKEN` / `ANTHROPIC_API_KEY` values from `~/.hermes/.env` — they can override subscription auth.
|
||||||
4. Reset cached credentials:
|
4. Reset cached credentials:
|
||||||
@ -99,7 +87,26 @@ Installed through a `sitecustomize.py` MetaPathFinder hook, so it runs at interp
|
|||||||
hermes chat -q 'Reply with exactly: AUTH TEST OK' --provider anthropic -m claude-sonnet-4-6 -Q
|
hermes chat -q 'Reply with exactly: AUTH TEST OK' --provider anthropic -m claude-sonnet-4-6 -Q
|
||||||
```
|
```
|
||||||
|
|
||||||
Credit: this macOS Keychain mirror step was written up by [@DrQbz](https://github.com/DrQbz) in [issue #5](https://github.com/kristianvast/hermes-claude-auth/issues/5).
|
If the auto-mirror doesn't work (e.g. your Keychain entry is under a different service name), mirror it manually:
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
secret = subprocess.check_output(
|
||||||
|
['security', 'find-generic-password', '-s', 'Claude Code-credentials', '-w'],
|
||||||
|
text=True,
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
cred_path = Path.home() / '.claude' / '.credentials.json'
|
||||||
|
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cred_path.write_text(secret)
|
||||||
|
cred_path.chmod(0o600)
|
||||||
|
print(f'wrote {cred_path}')
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Credit: the macOS Keychain mirror approach was written up by [@DrQbz](https://github.com/DrQbz) in [issue #5](https://github.com/kristianvast/hermes-claude-auth/issues/5) and is now automated in `install.sh`.
|
||||||
|
|
||||||
### Billing / routing issues
|
### Billing / routing issues
|
||||||
|
|
||||||
|
|||||||
@ -87,11 +87,16 @@ Version history
|
|||||||
header, ``?beta=true`` query param on ``/v1/messages``. Addresses the
|
header, ``?beta=true`` query param on ``/v1/messages``. Addresses the
|
||||||
"Third-party apps now draw from your extra usage, not your plan limits"
|
"Third-party apps now draw from your extra usage, not your plan limits"
|
||||||
400 error introduced by Anthropic's 2026-04-14+ validator tightening.
|
400 error introduced by Anthropic's 2026-04-14+ validator tightening.
|
||||||
|
- 1.1.1 (2026-04-22): Installer only — ``install.sh`` now auto-mirrors the
|
||||||
|
``Claude Code-credentials`` macOS Keychain entry into
|
||||||
|
``~/.claude/.credentials.json`` on Darwin hosts, so the oneliner works
|
||||||
|
end-to-end on macOS without a manual post-install step. Bypass module
|
||||||
|
itself is unchanged; version bump tracks the release.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "1.1.0"
|
__version__ = "1.1.1"
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
|
|||||||
21
install.sh
21
install.sh
@ -61,6 +61,27 @@ fi
|
|||||||
chmod 644 "$SITECUSTOMIZE"
|
chmod 644 "$SITECUSTOMIZE"
|
||||||
printf "${GREEN}[✓] Installed hook into %s${RESET}\n" "$SITECUSTOMIZE"
|
printf "${GREEN}[✓] Installed hook into %s${RESET}\n" "$SITECUSTOMIZE"
|
||||||
|
|
||||||
|
# macOS: hermes-agent reads Claude subscription credentials from
|
||||||
|
# ~/.claude/.credentials.json, but Claude Code on macOS stores them in
|
||||||
|
# Keychain only. Mirror the Keychain entry into the file so auth works
|
||||||
|
# out of the box. No-op on Linux (Claude Code writes the file directly).
|
||||||
|
if [ "$(uname -s)" = "Darwin" ]; then
|
||||||
|
CRED_FILE="$HOME/.claude/.credentials.json"
|
||||||
|
if KEYCHAIN_CRED="$(security find-generic-password -s 'Claude Code-credentials' -w 2>/dev/null)"; then
|
||||||
|
mkdir -p "$(dirname "$CRED_FILE")"
|
||||||
|
if [ ! -f "$CRED_FILE" ] || [ "$(cat "$CRED_FILE" 2>/dev/null)" != "$KEYCHAIN_CRED" ]; then
|
||||||
|
printf '%s' "$KEYCHAIN_CRED" > "$CRED_FILE"
|
||||||
|
chmod 600 "$CRED_FILE"
|
||||||
|
printf "${GREEN}[✓] Mirrored Claude Code credentials from Keychain → %s${RESET}\n" "$CRED_FILE"
|
||||||
|
else
|
||||||
|
printf "${GREEN}[✓] Claude Code credentials file already matches Keychain${RESET}\n"
|
||||||
|
fi
|
||||||
|
elif [ ! -f "$CRED_FILE" ]; then
|
||||||
|
printf "${YELLOW}[!] macOS detected but no 'Claude Code-credentials' Keychain entry found${RESET}\n"
|
||||||
|
printf " Run: claude auth login --claudeai\n"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if systemctl --user is-active hermes-gateway.service >/dev/null 2>&1; then
|
if systemctl --user is-active hermes-gateway.service >/dev/null 2>&1; then
|
||||||
systemctl --user restart hermes-gateway.service
|
systemctl --user restart hermes-gateway.service
|
||||||
printf "${GREEN}[✓] Restarted hermes-gateway.service${RESET}\n"
|
printf "${GREEN}[✓] Restarted hermes-gateway.service${RESET}\n"
|
||||||
|
|||||||
@ -139,6 +139,92 @@ else
|
|||||||
fail "$T5" "install.sh or uninstall.sh --purge exited non-zero"
|
fail "$T5" "install.sh or uninstall.sh --purge exited non-zero"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# macOS Keychain mirror tests — fake `uname -s` → Darwin and a fake
|
||||||
|
# `security find-generic-password` via PATH shims so install.sh takes the
|
||||||
|
# Darwin branch without an actual Mac.
|
||||||
|
FAKE_BIN="$FAKE_HOME/fakebin"
|
||||||
|
mkdir -p "$FAKE_BIN"
|
||||||
|
cat > "$FAKE_BIN/uname" <<'UNAME_EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [ "${1:-}" = "-s" ]; then
|
||||||
|
echo Darwin
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /usr/bin/uname "$@"
|
||||||
|
UNAME_EOF
|
||||||
|
chmod +x "$FAKE_BIN/uname"
|
||||||
|
|
||||||
|
FAKE_CRED='{"oauth":{"accessToken":"sk-ant-fake","refreshToken":"rt-fake","expiresAt":0}}'
|
||||||
|
cat > "$FAKE_BIN/security" <<SECURITY_EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [ "\${1:-}" = "find-generic-password" ] && [ "\${2:-}" = "-s" ] \\
|
||||||
|
&& [ "\${3:-}" = "Claude Code-credentials" ] && [ "\${4:-}" = "-w" ]; then
|
||||||
|
printf '%s' '$FAKE_CRED'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
SECURITY_EOF
|
||||||
|
chmod +x "$FAKE_BIN/security"
|
||||||
|
|
||||||
|
# Test 6: Fresh macOS install mirrors Keychain → ~/.claude/.credentials.json
|
||||||
|
T6="Test 6: macOS install mirrors Keychain credentials to credentials.json"
|
||||||
|
rm -rf "$FAKE_HOME/.claude"
|
||||||
|
if PATH="$FAKE_BIN:$PATH" "$REPO_DIR/install.sh" >/dev/null 2>&1; then
|
||||||
|
ok=1
|
||||||
|
CRED_FILE="$FAKE_HOME/.claude/.credentials.json"
|
||||||
|
assert_file_exists "$T6" "$CRED_FILE" || ok=0
|
||||||
|
if [ -f "$CRED_FILE" ]; then
|
||||||
|
actual="$(cat "$CRED_FILE")"
|
||||||
|
if [ "$actual" != "$FAKE_CRED" ]; then
|
||||||
|
fail "$T6" "credentials content mismatch: got '$actual'"
|
||||||
|
ok=0
|
||||||
|
fi
|
||||||
|
mode="$(python3 -c "import os, sys; print(oct(os.stat(sys.argv[1]).st_mode)[-3:])" "$CRED_FILE")"
|
||||||
|
if [ "$mode" != "600" ]; then
|
||||||
|
fail "$T6" "credentials file mode is $mode, expected 600"
|
||||||
|
ok=0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ "$ok" -eq 1 ] && pass "$T6"
|
||||||
|
else
|
||||||
|
fail "$T6" "install.sh exited non-zero under faked macOS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 7: Idempotent macOS re-install does not rewrite file with identical content
|
||||||
|
T7="Test 7: macOS re-install does not rewrite identical credentials"
|
||||||
|
CRED_FILE="$FAKE_HOME/.claude/.credentials.json"
|
||||||
|
if [ -f "$CRED_FILE" ]; then
|
||||||
|
mtime_before="$(python3 -c "import os, sys; print(os.stat(sys.argv[1]).st_mtime_ns)" "$CRED_FILE")"
|
||||||
|
sleep 1
|
||||||
|
if PATH="$FAKE_BIN:$PATH" "$REPO_DIR/install.sh" >/dev/null 2>&1; then
|
||||||
|
mtime_after="$(python3 -c "import os, sys; print(os.stat(sys.argv[1]).st_mtime_ns)" "$CRED_FILE")"
|
||||||
|
if [ "$mtime_before" != "$mtime_after" ]; then
|
||||||
|
fail "$T7" "credentials file rewritten despite identical content"
|
||||||
|
else
|
||||||
|
pass "$T7"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "$T7" "install.sh exited non-zero on idempotent macOS re-run"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "$T7" "Test 6 did not produce a credentials file; cannot run idempotency check"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 8: macOS install with no Keychain entry leaves credentials file absent
|
||||||
|
T8="Test 8: macOS install with missing Keychain entry leaves no file"
|
||||||
|
rm -rf "$FAKE_HOME/.claude"
|
||||||
|
cat > "$FAKE_BIN/security" <<'SECURITY_FAIL_EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 1
|
||||||
|
SECURITY_FAIL_EOF
|
||||||
|
chmod +x "$FAKE_BIN/security"
|
||||||
|
|
||||||
|
if PATH="$FAKE_BIN:$PATH" "$REPO_DIR/install.sh" >/dev/null 2>&1; then
|
||||||
|
assert_file_not_exists "$T8" "$FAKE_HOME/.claude/.credentials.json" && pass "$T8"
|
||||||
|
else
|
||||||
|
fail "$T8" "install.sh exited non-zero when Keychain entry absent"
|
||||||
|
fi
|
||||||
|
|
||||||
TOTAL=$((PASS + FAIL))
|
TOTAL=$((PASS + FAIL))
|
||||||
printf '\n%d/%d tests passed\n' "$PASS" "$TOTAL"
|
printf '\n%d/%d tests passed\n' "$PASS" "$TOTAL"
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user