feat(hermes): add Hermes Agent harness bundle#320
Conversation
Add the Hermes Agent harness bundle scaffold with: - config.yaml: harness configuration with API key auth (Anthropic, OpenAI, Google AI Studio), model aliases, command flags, and capability declarations - Dockerfile: scion-base overlay installing Node.js 22, ripgrep, and hermes-agent via pip - cloudbuild.yaml: multi-platform Cloud Build config for scion-hermes
Minimal provision.py handling: - API key auth with ANTHROPIC > OPENAI > GOOGLE precedence, written to ~/.hermes/.env - Instruction projection into AGENTS.md with scion-managed blocks - MCP server config written to ~/.hermes/mcp.json - Env overlay with HERMES_YOLO_MODE, HERMES_QUIET, HERMES_ACCEPT_HOOKS, and optional HERMES_INFERENCE_MODEL from model alias resolution
- capture_auth.py: credential capture for no-auth flow, reads from inputs/capture-auth-config.json and stores via sciontool - README.md: bundle documentation with install, auth modes, and build instructions
There was a problem hiding this comment.
Code Review
This pull request introduces a new harness bundle for the Hermes Agent, featuring container-side provisioning, credential capturing, and MCP server configuration. The code review identifies several critical improvements: fixing a bug in provision.py that breaks the no_auth flow when auth-candidates.json is empty, defining required_files in config.yaml to enable capture_auth.py to function, avoiding a dummy pip install failure in the Dockerfile when using the default 'latest' version, adding type-safety checks to prevent AttributeError crashes when parsing JSON inputs, and refactoring provision.py to use the defined HERMES_ENV_FILE constant.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if not candidates and no_auth_behavior: | ||
| print(f"hermes provision: no-auth mode (behavior={no_auth_behavior}), skipping auth setup", file=sys.stderr) | ||
| method = "none" | ||
| env_key = "" | ||
| else: | ||
| try: | ||
| method, env_key = _select_auth_key(explicit, env_keys) | ||
| except ValueError as exc: | ||
| print(str(exc), file=sys.stderr) | ||
| return EXIT_ERROR |
There was a problem hiding this comment.
If auth-candidates.json is present but contains no valid keys (which is the standard way no-auth is represented when the staging layer runs but finds no secrets), candidates is a non-empty dictionary (containing schema_version: 1 etc.). Thus, not candidates evaluates to False, and the script proceeds to the else block where _select_auth_key raises a ValueError because env_keys is empty. This completely breaks the no_auth behavior when auth-candidates.json is present but empty of keys. We should check if any supported key is present in env_keys instead of checking not candidates.
| if not candidates and no_auth_behavior: | |
| print(f"hermes provision: no-auth mode (behavior={no_auth_behavior}), skipping auth setup", file=sys.stderr) | |
| method = "none" | |
| env_key = "" | |
| else: | |
| try: | |
| method, env_key = _select_auth_key(explicit, env_keys) | |
| except ValueError as exc: | |
| print(str(exc), file=sys.stderr) | |
| return EXIT_ERROR | |
| has_supported_key = any(k in env_keys for k in AUTH_KEY_PRECEDENCE) | |
| if not has_supported_key and no_auth_behavior: | |
| print(f"hermes provision: no-auth mode (behavior={no_auth_behavior}), skipping auth setup", file=sys.stderr) | |
| method = "none" | |
| env_key = "" | |
| else: | |
| try: | |
| method, env_key = _select_auth_key(explicit, env_keys) | |
| except ValueError as exc: | |
| print(str(exc), file=sys.stderr) | |
| return EXIT_ERROR |
| auth: | ||
| default_type: api-key | ||
| types: | ||
| api-key: | ||
| required_env: | ||
| - any_of: ["GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"] |
There was a problem hiding this comment.
The no_auth.message instructs the user to run capture_auth.py to capture credentials after running hermes setup. However, capture_auth.py relies on inputs/capture-auth-config.json, which is only generated if there are required_files defined under auth.types in config.yaml. Since there are no required_files defined here, capture-auth-config.json will never be generated, and capture_auth.py will always exit with EXIT_NO_CREDS (2) without capturing anything. To fix this, you should define the required_files (e.g., ~/.hermes/.env or whichever file hermes setup writes credentials to) under the api-key auth type.
auth:
default_type: api-key
types:
api-key:
required_env:
- any_of: ["GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
required_files:
- name: HERMES_ENV
target_suffix: .hermes/.env| RUN pip install --no-cache-dir "hermes-agent==${HERMES_VERSION}" \ | ||
| || pip install --no-cache-dir hermes-agent |
There was a problem hiding this comment.
If HERMES_VERSION is latest (the default), running pip install "hermes-agent==latest" will fail and print an error in the build logs before falling back to the unpinned install. We can avoid this dummy failure by checking if the version is latest first.
RUN if [ "${HERMES_VERSION}" = "latest" ]; then \
pip install --no-cache-dir hermes-agent; \
else \
pip install --no-cache-dir "hermes-agent==${HERMES_VERSION}"; \
fi
| with open(config_path, "r", encoding="utf-8") as f: | ||
| try: | ||
| data = json.load(f) | ||
| except (json.JSONDecodeError, OSError): | ||
| return [] | ||
| creds = data.get("credentials") |
There was a problem hiding this comment.
If capture-auth-config.json is malformed or contains a non-object (like a list or string), json.load(f) will succeed but data won't be a dictionary, causing data.get to raise an AttributeError. We should verify isinstance(data, dict) before calling .get().
| with open(config_path, "r", encoding="utf-8") as f: | |
| try: | |
| data = json.load(f) | |
| except (json.JSONDecodeError, OSError): | |
| return [] | |
| creds = data.get("credentials") | |
| with open(config_path, "r", encoding="utf-8") as f: | |
| try: | |
| data = json.load(f) | |
| except (json.JSONDecodeError, OSError): | |
| return [] | |
| if not isinstance(data, dict): | |
| return [] | |
| creds = data.get("credentials") |
| if os.path.isfile(auth_candidates_path): | ||
| try: | ||
| candidates = _load_json(auth_candidates_path) or {} | ||
| except (OSError, json.JSONDecodeError) as exc: | ||
| print(f"hermes provision: invalid auth-candidates.json: {exc}", file=sys.stderr) | ||
| return EXIT_ERROR |
There was a problem hiding this comment.
If auth-candidates.json is not a dictionary (e.g., it's a list or other JSON type), candidates.get() on line 402 will raise an AttributeError and crash the provisioner. We should verify isinstance(candidates, dict) after loading.
| if os.path.isfile(auth_candidates_path): | |
| try: | |
| candidates = _load_json(auth_candidates_path) or {} | |
| except (OSError, json.JSONDecodeError) as exc: | |
| print(f"hermes provision: invalid auth-candidates.json: {exc}", file=sys.stderr) | |
| return EXIT_ERROR | |
| if os.path.isfile(auth_candidates_path): | |
| try: | |
| candidates = _load_json(auth_candidates_path) or {} | |
| except (OSError, json.JSONDecodeError) as exc: | |
| print(f"hermes provision: invalid auth-candidates.json: {exc}", file=sys.stderr) | |
| return EXIT_ERROR | |
| if not isinstance(candidates, dict): | |
| print("hermes provision: auth-candidates.json is not a dictionary", file=sys.stderr) | |
| return EXIT_ERROR |
| def _write_hermes_env(env_vars: dict[str, str]) -> None: | ||
| """Write key=value pairs to ~/.hermes/.env.""" | ||
| hermes_dir = _expand("~/.hermes") | ||
| os.makedirs(hermes_dir, exist_ok=True) | ||
| target = os.path.join(hermes_dir, ".env") | ||
| tmp = target + ".tmp" | ||
| with open(tmp, "w", encoding="utf-8") as f: | ||
| for k, v in sorted(env_vars.items()): | ||
| f.write(f"{k}={v}\n") | ||
| os.chmod(tmp, 0o600) | ||
| os.replace(tmp, target) |
There was a problem hiding this comment.
The constant HERMES_ENV_FILE is defined on line 56 but never used. Instead, the path ~/.hermes/.env is hardcoded and duplicated here. We should use the HERMES_ENV_FILE constant to improve maintainability.
| def _write_hermes_env(env_vars: dict[str, str]) -> None: | |
| """Write key=value pairs to ~/.hermes/.env.""" | |
| hermes_dir = _expand("~/.hermes") | |
| os.makedirs(hermes_dir, exist_ok=True) | |
| target = os.path.join(hermes_dir, ".env") | |
| tmp = target + ".tmp" | |
| with open(tmp, "w", encoding="utf-8") as f: | |
| for k, v in sorted(env_vars.items()): | |
| f.write(f"{k}={v}\n") | |
| os.chmod(tmp, 0o600) | |
| os.replace(tmp, target) | |
| def _write_hermes_env(env_vars: dict[str, str]) -> None: | |
| """Write key=value pairs to ~/.hermes/.env.""" | |
| target = _expand(HERMES_ENV_FILE) | |
| os.makedirs(os.path.dirname(target), exist_ok=True) | |
| tmp = target + ".tmp" | |
| with open(tmp, "w", encoding="utf-8") as f: | |
| for k, v in sorted(env_vars.items()): | |
| f.write(f"{k}={v}\n") | |
| os.chmod(tmp, 0o600) | |
| os.replace(tmp, target) |
- [H1] Add provision_test.py with 16 tests covering auth resolution (ANTHROPIC>OPENAI>GOOGLE precedence), instruction projection (compose, stale-block cleanup, file removal, malformed-marker safety), and MCP entry building (stdio, sse, streamable-http, unknown transport) - [M1] Add HERMES_HOME=/home/scion/.hermes to env overlay - [M2] Create .hermes/skills directory in Dockerfile - [M3] Set 0600 permissions on mcp.json to protect auth headers - [M4] Clean up pip install fallback with empty default ARG - [L1] Remove dead HERMES_ENV_FILE constant
Summary
harnesses/hermes/, following the same pattern as the existing codex, opencode, and antigravity harnessespip install hermes-agent)Files
config.yamlhermes chat --yolo -q), capabilities, auth typesprovision.py~/.hermes/.env, instruction projection →AGENTS.md, MCP →~/.hermes/mcp.json, env overlayDockerfilecloudbuild.yamlcapture_auth.pyREADME.mdDesign decisions
no.envfiles for config, keeping provision.py much simpler~/.hermes/mcp.jsonwithmcpServerskey, similar to the universal schemaTest plan
scion harness-config install harnesses/hermessucceedsdocker build --build-arg BASE_IMAGE=scion-base:latest -t scion-hermes:latest -f Dockerfile .