Skip to content

Commit 9324950

Browse files
ptoneScion Agent (hh-dev)
andauthored
feat(hermes): add Hermes Agent harness bundle (#519)
* feat(hermes): add harness config, Dockerfile, and cloudbuild 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 * feat(hermes): add container-side provisioner 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 * feat(hermes): add capture_auth script and README - 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 * fix(hermes): address code review findings - [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 * fix(hermes): fix no-auth detection and stale mcp.json cleanup - Fix no-auth check: use 'not env_keys' instead of 'not candidates' since auth-candidates.json always contains metadata even with zero credentials - Remove stale mcp.json when no MCP servers are configured, preventing leftover config from a previous provision run - Add test for no-auth mode activating with metadata-only candidates - Add test for stale mcp.json removal --------- Co-authored-by: Scion Agent (hh-dev) <agent@scion.dev>
1 parent 90ac468 commit 9324950

7 files changed

Lines changed: 1257 additions & 0 deletions

File tree

harnesses/hermes/Dockerfile

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# syntax=docker/dockerfile:1
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
ARG BASE_IMAGE
17+
FROM ${BASE_IMAGE}
18+
19+
ARG HERMES_VERSION=
20+
21+
# Install Node.js 22 LTS (required by Hermes for tool execution)
22+
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
23+
&& apt-get install -y --no-install-recommends nodejs \
24+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
25+
26+
# Install ripgrep (required by Hermes for code search)
27+
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep \
28+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
29+
30+
# Install Hermes Agent via pip (available in scion-base)
31+
RUN if [ -n "${HERMES_VERSION}" ]; then \
32+
pip install --no-cache-dir "hermes-agent==${HERMES_VERSION}"; \
33+
else \
34+
pip install --no-cache-dir hermes-agent; \
35+
fi
36+
37+
RUN mkdir -p /home/scion/.hermes /home/scion/.hermes/skills \
38+
&& chown -R scion:scion /home/scion/.hermes
39+
40+
CMD ["hermes"]

harnesses/hermes/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Hermes Harness Bundle
2+
3+
Scion harness configuration for [Hermes Agent](https://github.com/nousresearch/hermes-agent),
4+
Nous Research's AI coding agent (MIT license).
5+
6+
## Install
7+
8+
From a repository checkout:
9+
10+
```sh
11+
scion harness-config install harnesses/hermes
12+
```
13+
14+
Or directly from GitHub:
15+
16+
```sh
17+
scion harness-config install github.com/GoogleCloudPlatform/scion/tree/main/harnesses/hermes
18+
```
19+
20+
## Auth Modes
21+
22+
| Mode | Env Var | Notes |
23+
|------|---------|-------|
24+
| `api-key` (default) | `ANTHROPIC_API_KEY` | Anthropic key (highest precedence) |
25+
| `api-key` | `OPENAI_API_KEY` | OpenAI key |
26+
| `api-key` | `GOOGLE_API_KEY` | Google AI Studio key |
27+
28+
## Bundle Layout
29+
30+
```
31+
hermes/
32+
config.yaml # Harness configuration (provisioner, capabilities, auth)
33+
provision.py # Container-side provisioner (pre-start hook)
34+
capture_auth.py # Credential capture for no-auth flow
35+
Dockerfile # Image build (FROM scion-base)
36+
cloudbuild.yaml # Cloud Build configuration
37+
```
38+
39+
## Build the Image
40+
41+
```sh
42+
# Local Docker build
43+
docker build --build-arg BASE_IMAGE=scion-base:latest -t scion-hermes:latest -f Dockerfile .
44+
45+
# Cloud Build
46+
gcloud builds submit --config cloudbuild.yaml .
47+
```

harnesses/hermes/capture_auth.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Hermes capture-auth script.
16+
17+
Scans for credential files on disk and stores them as project-scoped secrets
18+
via `sciontool secret set`. Designed to run after the user authenticates
19+
interactively inside a no-auth agent container.
20+
21+
Reads credential mappings from inputs/capture-auth-config.json (derived from
22+
the harness config.yaml's auth.types.*.required_files declarations). This
23+
avoids hardcoding paths or key names in the script.
24+
25+
Exit codes:
26+
0 = at least one credential captured
27+
1 = error
28+
2 = no credentials found (not an error, but nothing was stored)
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import argparse
34+
import json
35+
import os
36+
import subprocess
37+
import sys
38+
from typing import Any
39+
40+
EXIT_OK = 0
41+
EXIT_ERROR = 1
42+
EXIT_NO_CREDS = 2
43+
44+
HARNESS_BUNDLE = os.path.join(
45+
os.environ.get("HOME") or os.path.expanduser("~"),
46+
".scion", "harness",
47+
)
48+
49+
50+
def _expand(path: str) -> str:
51+
return os.path.expanduser(os.path.expandvars(path))
52+
53+
54+
def _load_config(bundle: str) -> list[dict[str, Any]]:
55+
config_path = os.path.join(bundle, "inputs", "capture-auth-config.json")
56+
if not os.path.isfile(config_path):
57+
return []
58+
with open(config_path, "r", encoding="utf-8") as f:
59+
try:
60+
data = json.load(f)
61+
except (json.JSONDecodeError, OSError):
62+
return []
63+
creds = data.get("credentials")
64+
if not isinstance(creds, list):
65+
return []
66+
return creds
67+
68+
69+
def _capture_one(
70+
entry: dict[str, Any], force: bool
71+
) -> tuple[bool, str | None]:
72+
"""Attempt to capture a single credential. Returns (success, error_msg)."""
73+
key = entry.get("key", "")
74+
source = _expand(entry.get("source", ""))
75+
secret_type = entry.get("type", "file")
76+
target = entry.get("target", "")
77+
78+
if not key or not source:
79+
return False, "invalid entry: missing key or source"
80+
81+
if not os.path.isfile(source):
82+
return False, None
83+
84+
cmd = [
85+
"sciontool", "secret", "set", key, f"@{source}",
86+
"--type", secret_type,
87+
"--target", target,
88+
]
89+
if force:
90+
cmd.append("--force")
91+
92+
try:
93+
result = subprocess.run(
94+
cmd,
95+
capture_output=True,
96+
text=True,
97+
timeout=30,
98+
)
99+
except FileNotFoundError:
100+
return False, "sciontool not found in PATH"
101+
except subprocess.TimeoutExpired:
102+
return False, f"sciontool timed out for key {key}"
103+
104+
if result.returncode != 0:
105+
stderr = result.stderr.strip()
106+
return False, f"sciontool failed for {key}: {stderr}"
107+
108+
return True, None
109+
110+
111+
def main() -> int:
112+
parser = argparse.ArgumentParser(
113+
description="Capture auth credentials and store as project secrets"
114+
)
115+
parser.add_argument(
116+
"--force",
117+
action="store_true",
118+
help="Overwrite existing secrets",
119+
)
120+
parser.add_argument(
121+
"--bundle",
122+
default=HARNESS_BUNDLE,
123+
help="Path to harness bundle directory",
124+
)
125+
args = parser.parse_args()
126+
127+
entries = _load_config(args.bundle)
128+
if not entries:
129+
print(
130+
"capture-auth: no credential mappings found in "
131+
"inputs/capture-auth-config.json",
132+
file=sys.stderr,
133+
)
134+
return EXIT_NO_CREDS
135+
136+
captured = 0
137+
errors = 0
138+
139+
for entry in entries:
140+
key = entry.get("key", "<unknown>")
141+
source = entry.get("source", "")
142+
expanded = _expand(source) if source else ""
143+
144+
if not expanded or not os.path.isfile(expanded):
145+
print(f"capture-auth: {key}: source not found ({source})")
146+
continue
147+
148+
ok, err = _capture_one(entry, args.force)
149+
if err:
150+
print(f"capture-auth: {key}: {err}", file=sys.stderr)
151+
errors += 1
152+
elif ok:
153+
print(f"capture-auth: {key}: captured from {source}")
154+
captured += 1
155+
156+
if errors > 0 and captured == 0:
157+
return EXIT_ERROR
158+
159+
if captured == 0:
160+
print("capture-auth: no credentials found to capture")
161+
return EXIT_NO_CREDS
162+
163+
print(f"capture-auth: {captured} credential(s) captured successfully")
164+
return EXIT_OK
165+
166+
167+
if __name__ == "__main__":
168+
sys.exit(main())

harnesses/hermes/cloudbuild.yaml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# Per-bundle Cloud Build configuration for the Hermes harness image.
16+
# Builds scion-hermes on top of scion-base:<_TAG>.
17+
steps:
18+
- name: 'gcr.io/cloud-builders/docker'
19+
id: 'setup-buildx'
20+
args: ['buildx', 'create', '--name', 'mybuilder', '--use']
21+
env:
22+
- 'DOCKER_CLI_EXPERIMENTAL=enabled'
23+
24+
- name: 'gcr.io/cloud-builders/docker'
25+
id: 'bootstrap-buildx'
26+
args: ['buildx', 'inspect', '--bootstrap']
27+
env:
28+
- 'DOCKER_CLI_EXPERIMENTAL=enabled'
29+
30+
- name: 'gcr.io/cloud-builders/docker'
31+
id: 'build-scion-hermes'
32+
args:
33+
- 'buildx'
34+
- 'build'
35+
- '--platform'
36+
- 'linux/amd64,linux/arm64'
37+
- '--build-arg'
38+
- 'BASE_IMAGE=$_REGISTRY/scion-base:$_TAG'
39+
- '-t'
40+
- '$_REGISTRY/scion-hermes:$_SHORT_SHA'
41+
- '-t'
42+
- '$_REGISTRY/scion-hermes:$_TAG'
43+
- '-f'
44+
- 'Dockerfile'
45+
- '--pull'
46+
- '--push'
47+
- '.'
48+
env:
49+
- 'DOCKER_CLI_EXPERIMENTAL=enabled'
50+
51+
substitutions:
52+
_REGISTRY: 'us-central1-docker.pkg.dev/${PROJECT_ID}/public-docker'
53+
_TAG: 'latest'
54+
options:
55+
dynamicSubstitutions: true
56+
machineType: 'E2_HIGHCPU_8'
57+
timeout: 1200s

0 commit comments

Comments
 (0)