Skip to content

Instantly share code, notes, and snippets.

@robbiemu
Last active July 19, 2026 13:42
Show Gist options
  • Select an option

  • Save robbiemu/0cc8d63115c15dd3363f0adc3770a666 to your computer and use it in GitHub Desktop.

Select an option

Save robbiemu/0cc8d63115c15dd3363f0adc3770a666 to your computer and use it in GitHub Desktop.
Apple Keychain secret source for Hermes (bulk-style, service=hermes)
"""
Hermes Secret Source Plugin: Apple Keychain.
Default convention:
service = hermes
account = ENV_VAR_NAME
Example:
security add-generic-password -U \
-s hermes \
-a OPENROUTER_API_KEY \
-w "$OPENROUTER_API_KEY"
A secret's keychain identity has three axes — service, account, and keychain
file. The source-level ``service`` and ``default_keychain`` give the defaults;
``items`` overrides any of the three per secret. ``accounts`` is the shorthand
for the common case where every secret uses the defaults.
Hermes config (simple — every account under service=hermes, default keychain):
secrets:
sources: [apple_keychain]
apple_keychain:
enabled: true
service: hermes
accounts:
- OPENROUTER_API_KEY
- SPARK_885A_API_KEY
Advanced — per-secret service/account/keychain overrides via ``items``:
secrets:
sources: [apple_keychain]
apple_keychain:
enabled: true
service: hermes
default_keychain: "" # source-level fallback
items:
- env: OPENROUTER_API_KEY # uses defaults
- env: SPARK_885A_API_KEY
account: spark-885a # account name differs from env var
- env: GLM_API_KEY
keychain: /Library/Keychains/System.keychain # read from a specific file
The ``keychain`` / ``default_keychain`` fields exist because macOS ties keychain
access to the caller's security session: a login keychain is only readable inside
the user's GUI login session, while the System keychain is readable from any
session (including a launchd daemon at boot). Point a headless daemon at the
System keychain and an interactive CLI at the login keychain by giving them
configs that differ only on these fields.
Design goals:
- Startup-only secret source.
- No model-callable secret reader.
- No writes to os.environ.
- No prompts.
- No shell=True.
- Same-name account convention by default.
- ``items`` for any per-secret keychain-identity variation (service, account, or
keychain file) — not just "weird account names."
"""
from __future__ import annotations
import platform
import re
from pathlib import Path
from typing import Any
from agent.secret_sources.base import (
ErrorKind,
FetchResult,
SecretSource,
run_secret_cli,
)
from agent.secret_sources.base import SECRET_SOURCE_API_VERSION
_ENV_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _is_valid_env_name(value: object) -> bool:
return isinstance(value, str) and bool(_ENV_RE.match(value))
def _as_positive_float(value: object, default: float) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
return default
return parsed if parsed > 0 else default
def _string_or_default(value: object, default: str) -> str:
if isinstance(value, str) and value.strip():
return value.strip()
return default
def _clean_stderr(text: object, limit: int = 240) -> str:
if not isinstance(text, str):
return ""
return " ".join(text.strip().split())[:limit]
def _normalize_items(cfg: dict[str, Any]) -> tuple[list[dict[str, str]], list[str]]:
"""
Accepts either:
service: hermes
default_keychain: /path/to/keychain # optional source-level default
accounts:
- OPENROUTER_API_KEY
- SPARK_885A_API_KEY
Or an advanced form:
service: hermes
default_keychain: /path/to/keychain
items:
- env: OPENROUTER_API_KEY
- env: SPARK_885A_API_KEY
account: spark-885a
service: hermes-local
keychain: /different/keychain-db
``keychain`` (source-level) is a deprecated alias for ``default_keychain``
and is honored only when ``default_keychain`` is absent.
Returns:
(items, warnings)
Each item has:
env
service
account
keychain
"""
warnings: list[str] = []
items: list[dict[str, str]] = []
default_service = _string_or_default(cfg.get("service"), "hermes")
# Source-level keychain fallback. default_keychain is the documented name;
# the older `keychain` field is honored as a deprecated alias for back-compat.
default_keychain = ""
if isinstance(cfg.get("default_keychain"), str):
default_keychain = cfg["default_keychain"].strip()
elif isinstance(cfg.get("keychain"), str):
default_keychain = cfg["keychain"].strip()
accounts = cfg.get("accounts", [])
if accounts is None:
accounts = []
if not isinstance(accounts, list):
warnings.append("Ignoring apple_keychain.accounts: expected a list.")
accounts = []
for account in accounts:
if not _is_valid_env_name(account):
warnings.append(
f"Skipping account {account!r}: expected a valid environment variable name."
)
continue
items.append(
{
"env": account,
"service": default_service,
"account": account,
"keychain": default_keychain,
}
)
advanced_items = cfg.get("items", [])
if advanced_items is None:
advanced_items = []
if not isinstance(advanced_items, list):
warnings.append("Ignoring apple_keychain.items: expected a list.")
advanced_items = []
for raw_item in advanced_items:
if not isinstance(raw_item, dict):
warnings.append(f"Skipping item {raw_item!r}: expected a mapping.")
continue
env_name = raw_item.get("env")
if not _is_valid_env_name(env_name):
warnings.append(
f"Skipping item {raw_item!r}: missing valid env name."
)
continue
service = _string_or_default(raw_item.get("service"), default_service)
account = _string_or_default(raw_item.get("account"), env_name)
# Per-item override wins; otherwise fall back to the source-level default.
item_keychain = default_keychain
if isinstance(raw_item.get("keychain"), str):
candidate = raw_item["keychain"].strip()
item_keychain = candidate if candidate else default_keychain
elif raw_item.get("keychain") is not None:
warnings.append(
f"Skipping keychain override for item {env_name!r}: expected a string."
)
items.append(
{
"env": env_name,
"service": service,
"account": account,
"keychain": item_keychain,
}
)
seen: set[str] = set()
deduped: list[dict[str, str]] = []
for item in items:
env_name = item["env"]
if env_name in seen:
warnings.append(
f"Duplicate Apple Keychain target {env_name}; keeping the first one."
)
continue
seen.add(env_name)
deduped.append(item)
return deduped, warnings
class AppleKeychainSource(SecretSource):
api_version = SECRET_SOURCE_API_VERSION
name = "apple_keychain"
label = "Apple Keychain"
shape = "bulk"
def is_enabled(self, cfg: dict) -> bool:
return (
platform.system() == "Darwin"
and isinstance(cfg, dict)
and bool(cfg.get("enabled"))
)
def override_existing(self, cfg: dict) -> bool:
# Local Keychain should usually win over stale .env values after rotation.
if not isinstance(cfg, dict):
return True
return bool(cfg.get("override_existing", True))
def fetch_timeout_seconds(self, cfg: dict) -> float:
if not isinstance(cfg, dict):
return 10.0
# Hermes enforces the wall-clock budget around the whole source. Keep
# this snappy because Keychain should be local and fast.
return _as_positive_float(cfg.get("timeout_seconds"), 10.0)
def config_schema(self) -> dict:
return {
"enabled": {
"description": "Enable Apple Keychain secret resolution.",
"default": False,
},
"service": {
"description": (
"Default Keychain generic-password service name. "
"Accounts are treated as environment variable names."
),
"default": "hermes",
},
"accounts": {
"description": (
"Environment variable names to resolve from Keychain using "
"service=<service>, account=<ENV_VAR_NAME>."
),
"default": [],
},
"items": {
"description": (
"Per-secret mappings. Each item may set env, account, service, "
"and keychain to override the source-level defaults."
),
"default": [],
},
"default_keychain": {
"description": (
"Keychain file used when an item doesn't specify its own "
"`keychain`. Empty means use the default Keychain search list "
"(typically the user's login keychain)."
),
"default": "",
},
"binary_path": {
"description": "Path to the macOS security CLI.",
"default": "/usr/bin/security",
},
"override_existing": {
"description": "Whether Keychain values override existing env/.env values.",
"default": True,
},
"timeout_seconds": {
"description": "Wall-clock timeout for this source.",
"default": 10,
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
result = FetchResult()
try:
if platform.system() != "Darwin":
result.error = "Apple Keychain secret source only works on macOS."
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
if not isinstance(cfg, dict):
result.error = "secrets.apple_keychain must be a mapping."
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
items, warnings = _normalize_items(cfg)
result.warnings.extend(warnings)
if not items:
result.error = (
"secrets.apple_keychain.enabled is true, but no accounts or "
"items were configured."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
security_bin = _string_or_default(
cfg.get("binary_path"),
"/usr/bin/security",
)
timeout = min(self.fetch_timeout_seconds(cfg), 30.0)
secrets: dict[str, str] = {}
for item in items:
env_name = item["env"]
service = item["service"]
account = item["account"]
item_keychain = item.get("keychain", "")
argv = [
security_bin,
"find-generic-password",
"-w",
"-s",
service,
"-a",
account,
]
if item_keychain:
argv.append(item_keychain)
try:
proc = run_secret_cli(
argv,
allow_env=(),
timeout=timeout,
)
except RuntimeError as exc:
result.error = str(exc)
result.error_kind = ErrorKind.BINARY_MISSING
return result
if proc.returncode != 0:
detail = _clean_stderr(proc.stderr or proc.stdout)
if detail:
result.warnings.append(
f"Could not resolve {env_name} from Apple Keychain: {detail}"
)
else:
result.warnings.append(
f"Could not resolve {env_name} from Apple Keychain."
)
continue
value = proc.stdout.rstrip("\n")
if not value:
result.warnings.append(
f"Apple Keychain returned an empty value for {env_name}; skipped."
)
continue
secrets[env_name] = value
if not secrets:
result.error = "Apple Keychain source resolved no secrets."
result.error_kind = ErrorKind.EMPTY_VALUE
return result
result.secrets = secrets
result.binary_path = Path(security_bin)
return result
except Exception as exc:
# Contract: fetch() should not raise.
result.error = f"Apple Keychain source failed internally: {exc}"
result.error_kind = ErrorKind.INTERNAL
return result
def register(ctx):
ctx.register_secret_source(AppleKeychainSource())

Apple Keychain Secret Source for Hermes

This is a local Apple Keychain secret source for Hermes Agent. It lets Hermes resolve selected provider credentials from macOS Keychain instead of storing them as plaintext in ~/.hermes/.env or config.yaml.

The default convention is:

service = hermes
account = ENV_VAR_NAME

For example, OPENROUTER_API_KEY is stored as a generic password with service hermes and account OPENROUTER_API_KEY.

This is useful for public API providers such as OpenRouter, Z.AI / GLM, or any local OpenAI-compatible inference server that expects bearer-token authentication. In my setup, SPARK_885A_API_KEY is the API key for my local SGLang server, spark-885a.

Files in this gist

File What it is
plugin.yaml Plugin manifest
__init__.py Plugin source (AppleKeychainSource). Also copied into Hermes as a built-in.
config.yaml Example ~/.hermes/config.yaml secrets block
config-advanced.yaml Variant with per-secret service/account/keychain mappings
provision-secrets.sh Helper to load secret values into Keychain (login or System)
promote-to-builtin.sh Promote the plugin to a Hermes built-in source

1. Install the plugin

Drop the plugin files here:

~/.hermes/plugins/apple-keychain/
  plugin.yaml
  __init__.py

Provision the secret values into Keychain (see provision-secrets.sh), then merge this into ~/.hermes/config.yaml:

secrets:
  sources:
    - apple_keychain

  apple_keychain:
    enabled: true
    service: hermes
    override_existing: true
    timeout_seconds: 10
    accounts:
      - OPENROUTER_API_KEY
      - GLM_API_KEY
      - LOCAL_INFERENCE_API_KEY
      - DISCORD_BOT_TOKEN

In my own config, LOCAL_INFERENCE_API_KEY is named SPARK_885A_API_KEY because it authenticates requests to my local SGLang inference server.

Default convention

service = hermes
account = ENV_VAR_NAME

Manual add / smoke test

security add-generic-password -U \
  -s hermes \
  -a OPENROUTER_API_KEY \
  -w "$OPENROUTER_API_KEY"

# read it back
security find-generic-password -w \
  -s hermes \
  -a OPENROUTER_API_KEY >/dev/null

The macOS security CLI supports add-generic-password and find-generic-password, which is the small surface this plugin uses. (ss64.com)

2. Promote it to a built-in source

The important part. The plugin stays where it is, but Hermes also needs to load the same source as an early built-in, alongside Bitwarden and 1Password. The plugin alone loads too late to feed keys to provider models on first use -- the promotion is what makes this behave like Hermes Vault during first-process provider discovery. promote-to-builtin.sh does all three steps:

  1. Locates the Hermes Python interpreter and the agent package root.
  2. Copies ~/.hermes/plugins/apple-keychain/__init__.py into agent/secret_sources/apple_keychain.py.
  3. Patches agent/secret_sources/registry.py to register AppleKeychainSource(), immediately after the existing 1Password registration block. It backs up the registry first and short-circuits if the patch is already present (idempotent).
bash promote-to-builtin.sh

Note: the "promote to built-in" step is a local workaround for early startup hydration. It patches your installed Hermes copy, so it may need to be re-applied after hermes update. The standalone plugin remains the portable/official packaging shape; the promotion is what makes this behave like Hermes Vault during first-process provider discovery.

3. Restart and verify

Fully restart Hermes -- not just the current chat/session:

hermes model

You should see OpenRouter and Spark again.

If a subagent still 401s, check only whether the env var is present in the Hermes process (not the value):

HERMES_PY="$(head -n 1 "$(command -v hermes)" | sed 's/^#!//' | awk '{print $1}')"

"$HERMES_PY" - <<'PY'
import os
for k in ["OPENROUTER_API_KEY", "GLM_API_KEY", "LOCAL_INFERENCE_API_KEY", "DISCORD_BOT_TOKEN"]:
    v = os.environ.get(k, "")
    print(k, "len=", len(v))
PY

Delegation

Keep the keys in Keychain and reference them by env var in delegation -- do not put the raw values back in .env unless you need an emergency rollback:

delegation:
  api_key: ${LOCAL_INFERENCE_API_KEY}

Per-secret keychain identity (advanced mappings)

A secret's keychain identity has three axes:

  • service -- the generic-password service name (default hermes)
  • account -- the keychain account name (defaults to the env var name)
  • keychain -- the keychain file to read from (default: the macOS search list, typically the login keychain)

accounts: is the shorthand for the common case where every secret uses the defaults. items: overrides any of the three per secret, and default_keychain: sets a source-level fallback for items that don't specify their own. See config-advanced.yaml:

secrets:
  sources: [apple_keychain]

  apple_keychain:
    enabled: true
    service: hermes
    default_keychain: ""                       # empty = default search list

    accounts:
      - NVIDIA_API_KEY                         # uses all defaults

    items:
      - env: OPENROUTER_API_KEY
        account: openrouter-main               # account differs from env var

      - env: LOCAL_INFERENCE_API_KEY
        service: dgx-spark
        account: spark-885a

      - env: DISCORD_BOT_TOKEN
        keychain: /Library/Keychains/System.keychain   # read from a specific file

That resolves as:

NVIDIA_API_KEY          from keychain=login,    service=hermes,    account=NVIDIA_API_KEY
OPENROUTER_API_KEY      from keychain=login,    service=hermes,    account=openrouter-main
LOCAL_INFERENCE_API_KEY from keychain=login,    service=dgx-spark, account=spark-885a
DISCORD_BOT_TOKEN       from keychain=System,   service=hermes,    account=DISCORD_BOT_TOKEN

(For a source-level default instead of per-item, set default_keychain: to a path and drop the per-item keychain: lines.)

System keychain for headless daemons

The keychain field exists because macOS ties keychain access to the caller's security session:

  • The login keychain (~/Library/Keychains/login.keychain-db) is only readable inside your GUI login session. An interactive hermes CLI run from your terminal reads it fine.
  • The System keychain (/Library/Keychains/System.keychain) is readable from any session, including a launchd daemon at boot before anyone logs in, or a cron job with no attached user.

A macOS launchd LaunchDaemon runs in the system domain, outside any GUI login session, so it cannot read the login keychain -- not because it is locked, but because of this session boundary. (Calling security unlock-keychain from the daemon reports success but reads still fail; the unlock only takes effect within the GUI session.)

To serve a headless daemon, provision a copy of the secrets into the System keychain and point the daemon's config at it. provision-secrets.sh takes a KEYCHAIN env var for exactly this:

# 1. Provision a copy into the System keychain (writes there require root;
#    the script re-execs under sudo for you):
export OPENROUTER_API_KEY=... GLM_API_KEY=... # etc.
KEYCHAIN=/Library/Keychains/System.keychain bash provision-secrets.sh

# 2. Give the daemon a config that reads from the System keychain:
#    (config snippet)
#   apple_keychain:
#     default_keychain: /Library/Keychains/System.keychain
#     accounts: [OPENROUTER_API_KEY, GLM_API_KEY, ...]

Keep the login-keychain copy for interactive CLI use; the daemon reads the System copy. The two deployments differ only on the default_keychain / keychain field.

Rotation note: because there are now two copies, rotating a credential means refreshing both. Re-run provision-secrets.sh once without KEYCHAIN (login) and once with KEYCHAIN=/Library/Keychains/System.keychain (System) after you rotate. A secret that exists in only one location will silently resolve to nothing in the deployment that points at the other.

Design notes

Hermes' secret-source contract says the source should fetch values only, return {ENV_VAR: value}, never write os.environ, never prompt, and let Hermes handle ordering, precedence, provenance, and protected vars. bulk is the right shape when the source injects a set of secrets implicitly rather than binding each env var to a separate ref. (Hermes Agent docs)

Design goals:

  • Startup-only secret source.
  • No model-callable secret reader.
  • No writes to os.environ.
  • No prompts.
  • No shell=True.
  • Same-name account convention by default.
  • items for any per-secret keychain-identity variation (service, account, or keychain file) -- not just "weird account names."

Security note: this removes provider keys from plaintext .env / config.yaml, but Hermes still materializes the resolved values into the process environment at startup. The source itself does not write os.environ; the Hermes secret-source framework does. Keep accounts: small and explicit.

System keychain caveat: secrets in /Library/Keychains/System.keychain are readable by any local process (root or any user), not just your account. That is the trade-off for boot-time access without a GUI login -- it is the System keychain's designed purpose, but it is broader exposure than the login keychain, which is encrypted at rest with your login password. Use the System keychain only for secrets a headless daemon genuinely needs.

Note on custom model-provider plugins

This only hydrates secrets early. It does not define model providers.

If you use a custom Hermes model-provider plugin for a local OpenAI-compatible server, keep provider-specific settings under that provider's own config section, not under Hermes' mutable model: selection block.

For example, a local SGLang provider might keep base_url, key_env, context_length, and extra_body under sglang:. Hermes may rewrite the top-level model: section when changing the selected default model, so provider definitions should not depend on model.key_env or model.base_url.

In my setup, spark-885a is just the hostname of my local SGLang inference server.

Testing gotcha

Hermes' docs say plugin discovery happens after the first .env load in the discovering process, so plugin secret sources may only affect subsequent spawned Hermes processes (gateway children, cron sessions, subagents). This is exactly why the built-in promotion in step 2 exists. A full restart is the cleanest test.

# Advanced mapping variant: per-secret service/account/keychain overrides.
#
# A secret's keychain identity has three axes — service, account, and keychain
# file. `items` overrides any of them per secret; `default_keychain` is the
# source-level fallback for items that don't say.
#
# The keychain axis matters when a headless daemon (e.g. a macOS launchd
# LaunchDaemon) needs to read secrets at boot: the user's login keychain is
# only readable inside the GUI login session, while the System keychain is
# readable from any session. Point a daemon's config at the System keychain
# and an interactive CLI's config at the login keychain; the two configs differ
# only on the keychain field.
secrets:
sources: [apple_keychain]
apple_keychain:
enabled: true
service: hermes
default_keychain: "" # empty = default search list (login keychain)
accounts:
- NVIDIA_API_KEY # uses defaults: service=hermes, login keychain
items:
- env: OPENROUTER_API_KEY
account: openrouter-main # account name differs from env var
- env: LOCAL_INFERENCE_API_KEY
service: dgx-spark
account: spark-885a
# Read this one from the System keychain instead of the default — useful
# for a headless daemon that runs outside the GUI login session.
- env: DISCORD_BOT_TOKEN
keychain: /Library/Keychains/System.keychain
secrets:
sources:
- apple_keychain
apple_keychain:
enabled: true
service: hermes
override_existing: true
timeout_seconds: 10
accounts:
- OPENROUTER_API_KEY
- GLM_API_KEY
- LOCAL_INFERENCE_API_KEY
- DISCORD_BOT_TOKEN
name: apple-keychain
version: "0.1.0"
description: Resolve Hermes provider secrets from macOS Keychain
#!/usr/bin/env bash
#
# Promote the Apple Keychain plugin to a built-in Hermes secret source.
#
# This is NOT a reinstall: the plugin stays installed at
# ~/.hermes/plugins/apple-keychain/
# In addition, its module is copied into Hermes' built-in secret-source
# package and the built-in registry is patched so it loads during the same
# early path as Bitwarden / 1Password / Vault. That early load is the
# "missing gear tooth" -- plugin discovery happens too late to feed keys to
# provider models on first use.
#
# Idempotent: safe to re-run. The registry patch short-circuits if
# AppleKeychainSource is already present.
set -euo pipefail
# --- 1. Locate the Hermes Python interpreter + agent package root -------------
# Resolve the python interpreter behind the `hermes` launcher. The launcher may
# be (a) a python script with a #!/path/to/python shebang, or (b) a shell wrapper
# that execs a venv binary (e.g. ~/.local/bin/hermes -> .../venv/bin/hermes).
# Case (b) is what pipx/uv-managed installs produce, and parsing its bash
# shebang would yield /usr/bin/env — wrong. Handle both.
_hermes_bin="$(command -v hermes)"
_shebang="$(head -n 1 "$_hermes_bin" | sed 's/^#!//')"
case "$_shebang" in
*bash|*sh|*/env\ bash|*/env\ sh)
# Shell wrapper: extract the python from the exec'd venv binary, then derive
# its python (venv/bin/hermes -> venv/bin/python).
_exec_line="$(grep -E '^[[:space:]]*exec ' "$_hermes_bin" | head -1)"
_venv_bin="$(printf '%s\n' "$_exec_line" | sed -E 's/.*exec[[:space:]]+"([^"]+)".*/\1/')"
_bin_dir="$(dirname "$_venv_bin")"
HERMES_PY="$_bin_dir/python"
;;
*)
# Direct python shebang.
HERMES_PY="$(printf '%s' "$_shebang" | awk '{print $1}')"
;;
esac
[ -x "$HERMES_PY" ] || { echo "could not resolve a python interpreter from 'hermes' (got: $HERMES_PY)" >&2; exit 1; }
echo "Hermes interpreter: $HERMES_PY"
AGENT_ROOT="$("$HERMES_PY" - <<'PY'
from pathlib import Path
import agent
print(Path(agent.__file__).resolve().parent)
PY
)"
echo "agent root: $AGENT_ROOT"
echo "secret sources: $AGENT_ROOT/secret_sources"
# Sanity-check the expected files exist before we touch anything.
REGISTRY="$AGENT_ROOT/secret_sources/registry.py"
for f in "$AGENT_ROOT/secret_sources" "$REGISTRY"; do
[[ -e "$f" ]] || { echo "missing: $f" >&2; exit 1; }
done
# --- 2. Copy the plugin module into the built-in secret-source package --------
cp ~/.hermes/plugins/apple-keychain/__init__.py \
"$AGENT_ROOT/secret_sources/apple_keychain.py"
echo "copied __init__.py -> secret_sources/apple_keychain.py"
# --- 3. Patch the built-in registry to register AppleKeychainSource -----------
#
# Inserts an AppleKeychainSource registration block immediately after the
# existing 1Password registration block, mirroring its shape. Backs up the
# file first. Short-circuits if already patched.
cp "$REGISTRY" "$REGISTRY.bak.$(date +%Y%m%d-%H%M%S)"
"$HERMES_PY" - "$REGISTRY" <<'PY'
from pathlib import Path
import re
import sys
path = Path(sys.argv[1])
text = path.read_text()
if "AppleKeychainSource" in text:
print("Apple Keychain registry patch already present.")
raise SystemExit(0)
pattern = re.compile(
r"(?P<block>"
r"^ try:\n"
r" from agent\.secret_sources\.onepassword import OnePasswordSource\n"
r"(?:[ \t]*\n)?"
r" register_source\(OnePasswordSource\(\)\)\n"
r" except Exception[^\n]*\n"
r" logger\.warning\(\s*"
r"\"Failed to register bundled 1Password secret source\"\s*,?\s*"
r"exc_info=True\s*\)\n"
r")",
re.MULTILINE,
)
apple_keychain_block = """
try:
from agent.secret_sources.apple_keychain import AppleKeychainSource
register_source(AppleKeychainSource())
except Exception:
logger.warning("Failed to register local Apple Keychain secret source", exc_info=True)
"""
match = pattern.search(text)
if match is None:
raise SystemExit("Could not find the 1Password registration block. Open registry.py and patch manually.")
insert_at = match.end("block")
path.write_text(text[:insert_at] + apple_keychain_block + text[insert_at:])
print("Patched", path)
PY
echo
echo "Done. Now fully restart Hermes (not just the current chat/session), then run:"
echo " hermes model"
echo "You should see OpenRouter and Spark again."
#!/usr/bin/env bash
#
# =============================================================================
# ⚠️ KEYCHAIN SAFETY — read docs/keychain-safety.md before editing or
# improvising any keychain command. A flag-handling bug in an ad-hoc
# `security add-generic-password -w "$KC"` one-liner once silently
# destroyed 5 real secrets (the path string became the password and
# overwrote the login keychain). This script is the sanctioned path for
# keychain writes precisely so operators don't hand-write these commands.
# If you need an operation this script doesn't cover, ADD A SUBCOMMAND
# here (with a test) — do not bypass it with a one-liner.
# =============================================================================
#
# Load provider secret values into the macOS Keychain under service=hermes,
# one generic-password per env var. Export the values first (e.g. from your
# password manager), then run this.
#
# By default secrets are written to the user's default keychain (the login
# keychain), which is what an interactive CLI reads from. To provision a copy
# for a headless daemon that runs outside the GUI login session — e.g. a macOS
# launchd LaunchDaemon, which cannot read the login keychain — set KEYCHAIN to
# the System keychain path:
#
# KEYCHAIN=/Library/Keychains/System.keychain bash provision-secrets.sh
#
# The System keychain is readable from any session, including at boot before
# login. Writing to it requires root, so the script re-execs under sudo when
# KEYCHAIN points outside the user's keychain directory.
#
# Re-run after rotating a credential to refresh both locations. Keep the login
# and System copies in sync; the plugin reads each secret from whichever
# keychain its config entry points at (see config-advanced.yaml).
#
# The account list is derived from config.yaml (secrets.apple_keychain.accounts
# + items[].env), so this script and the running gateway can never disagree on
# which secrets exist.
set -euo pipefail
# Optional target keychain. Empty = security CLI default (login keychain).
KEYCHAIN="${KEYCHAIN:-}"
# If a non-login keychain is requested, we need root to write to it.
if [[ -n "$KEYCHAIN" && "$KEYCHAIN" != "$HOME/Library/Keychains/"* ]]; then
if [[ "$(id -u)" -ne 0 ]]; then
echo "re-running under sudo to write to $KEYCHAIN" >&2
exec sudo -E KEYCHAIN="$KEYCHAIN" "$0" "$@"
fi
fi
kc_add() {
local name="$1"
local value="${!name:-}"
if [[ -z "$value" ]]; then
printf 'missing env var: %s\n' "$name" >&2
return 1
fi
# Build the argv; append the keychain path only when one is specified.
local args=(add-generic-password -U -s hermes -a "$name" -w "$value")
if [[ -n "$KEYCHAIN" ]]; then
# -A: allow any app to read without a prompt. Required for a headless
# daemon (no GUI to answer the access prompt) and matches how the System
# keychain is meant to be used. Omit -A for the login keychain so the
# normal ACL/prompt behavior is preserved for interactive reads.
args+=(-A "$KEYCHAIN")
fi
security "${args[@]}"
}
if [[ -n "$KEYCHAIN" ]]; then
echo "provisioning into: $KEYCHAIN" >&2
else
echo "provisioning into: default (login) keychain" >&2
fi
# Derive the account list from config.yaml rather than hardcoding it here.
# This is the single source of truth — config.yaml's secrets.apple_keychain.accounts
# is also what the running gateway reads, so the script and the gateway can
# never disagree on which secrets exist. (Previously this was a hardcoded list
# that drifted from config, causing provision to abort on secrets the operator
# didn't have while missing ones the gateway expected.)
CONFIG="${HERMES_CONFIG:-$HOME/.hermes/config.yaml}"
if [[ ! -f "$CONFIG" ]]; then
echo "config not found at $CONFIG (set HERMES_CONFIG to override); cannot determine account list" >&2
exit 1
fi
# Prefer the hermes venv python (PyYAML is guaranteed there); fall back to
# `python3` on PATH. System python3 often lacks PyYAML, which would make the
# config derivation silently fail. The venv path matches the one the gateway
# itself runs under.
HERMES_PY="${HERMES_PY:-}"
if [[ -z "$HERMES_PY" ]]; then
for cand in \
"$HOME/.hermes/hermes-agent/venv/bin/python" \
"$(command -v python3)"; do
[[ -n "$cand" && -x "$cand" ]] && { HERMES_PY="$cand"; break; }
done
fi
if [[ -z "$HERMES_PY" ]]; then
echo "no python interpreter found (set HERMES_PY); cannot read config" >&2
exit 1
fi
if ! "$HERMES_PY" -c 'import yaml' >/dev/null 2>&1; then
echo "python at $HERMES_PY lacks PyYAML; install it or set HERMES_PY to a python with yaml" >&2
exit 1
fi
# Read accounts into a bash 3.2-compatible array (no mapfile — stock macOS
# /bin/bash is 3.2, which lacks it). Process substitution + read loop instead.
ACCOUNTS=()
while IFS= read -r line; do
[[ -n "$line" ]] && ACCOUNTS+=("$line")
done < <("$HERMES_PY" - "$CONFIG" <<'PY'
import sys, yaml
try:
cfg = yaml.safe_load(open(sys.argv[1]))
except Exception as e:
sys.exit(f"could not parse config: {e}")
ak = (cfg or {}).get("secrets", {}).get("apple_keychain", {}) or {}
accounts = list(ak.get("accounts") or [])
# Also honor advanced `items` entries (they carry their own env name).
for it in ak.get("items") or []:
if isinstance(it, dict) and isinstance(it.get("env"), str):
accounts.append(it["env"])
for a in accounts:
print(a)
PY
)
if [[ "${#ACCOUNTS[@]}" -eq 0 ]]; then
echo "no accounts found in $CONFIG (secrets.apple_keychain.accounts is empty/absent)" >&2
exit 1
fi
echo "accounts from config ($CONFIG): ${#ACCOUNTS[@]}" >&2
echo " ${ACCOUNTS[*]}" >&2
for name in "${ACCOUNTS[@]}"; do
kc_add "$name"
done
echo "provisioned ${#ACCOUNTS[@]} secret(s)" >&2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment