From 2fa4e7a808d7d0ab125f8ea870ce2bf48a060314 Mon Sep 17 00:00:00 2001 From: "Scion Agent (hh-dev)" Date: Sat, 27 Jun 2026 16:59:53 +0000 Subject: [PATCH 1/4] 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 --- harnesses/hermes/Dockerfile | 37 ++++++++++++++ harnesses/hermes/cloudbuild.yaml | 57 +++++++++++++++++++++ harnesses/hermes/config.yaml | 85 ++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 harnesses/hermes/Dockerfile create mode 100644 harnesses/hermes/cloudbuild.yaml create mode 100644 harnesses/hermes/config.yaml diff --git a/harnesses/hermes/Dockerfile b/harnesses/hermes/Dockerfile new file mode 100644 index 000000000..f1d2db89b --- /dev/null +++ b/harnesses/hermes/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +ARG HERMES_VERSION=latest + +# Install Node.js 22 LTS (required by Hermes for tool execution) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install ripgrep (required by Hermes for code search) +RUN apt-get update && apt-get install -y --no-install-recommends ripgrep \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install Hermes Agent via pip (available in scion-base) +RUN pip install --no-cache-dir "hermes-agent==${HERMES_VERSION}" \ + || pip install --no-cache-dir hermes-agent + +RUN mkdir -p /home/scion/.hermes \ + && chown -R scion:scion /home/scion/.hermes + +CMD ["hermes"] diff --git a/harnesses/hermes/cloudbuild.yaml b/harnesses/hermes/cloudbuild.yaml new file mode 100644 index 000000000..d3cec37f5 --- /dev/null +++ b/harnesses/hermes/cloudbuild.yaml @@ -0,0 +1,57 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Per-bundle Cloud Build configuration for the Hermes harness image. +# Builds scion-hermes on top of scion-base:<_TAG>. +steps: + - name: 'gcr.io/cloud-builders/docker' + id: 'setup-buildx' + args: ['buildx', 'create', '--name', 'mybuilder', '--use'] + env: + - 'DOCKER_CLI_EXPERIMENTAL=enabled' + + - name: 'gcr.io/cloud-builders/docker' + id: 'bootstrap-buildx' + args: ['buildx', 'inspect', '--bootstrap'] + env: + - 'DOCKER_CLI_EXPERIMENTAL=enabled' + + - name: 'gcr.io/cloud-builders/docker' + id: 'build-scion-hermes' + args: + - 'buildx' + - 'build' + - '--platform' + - 'linux/amd64,linux/arm64' + - '--build-arg' + - 'BASE_IMAGE=$_REGISTRY/scion-base:$_TAG' + - '-t' + - '$_REGISTRY/scion-hermes:$_SHORT_SHA' + - '-t' + - '$_REGISTRY/scion-hermes:$_TAG' + - '-f' + - 'Dockerfile' + - '--pull' + - '--push' + - '.' + env: + - 'DOCKER_CLI_EXPERIMENTAL=enabled' + +substitutions: + _REGISTRY: 'us-central1-docker.pkg.dev/${PROJECT_ID}/public-docker' + _TAG: 'latest' +options: + dynamicSubstitutions: true + machineType: 'E2_HIGHCPU_8' +timeout: 1200s diff --git a/harnesses/hermes/config.yaml b/harnesses/hermes/config.yaml new file mode 100644 index 000000000..df4fbd577 --- /dev/null +++ b/harnesses/hermes/config.yaml @@ -0,0 +1,85 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +harness: hermes +image: scion-hermes:latest +user: scion + +provisioner: + type: container-script + interface_version: 1 + command: ["python3", "/home/scion/.scion/harness/provision.py"] + timeout: 30s + lifecycle_events: + - pre-start + required_image_tools: + - python3 + +config_dir: .hermes +skills_dir: .hermes/skills +interrupt_key: C-c +instructions_file: AGENTS.md +system_prompt_mode: prepend_to_instructions + +model_aliases: + small: google/gemini-3.5-flash + medium: anthropic/claude-sonnet-4 + large: anthropic/claude-opus-4 + +command: + base: ["hermes", "chat", "--yolo"] + task_flag: "-q" + resume_flag: "-c" + task_position: after_base_args + +capabilities: + limits: + max_turns: { support: "yes" } + max_model_calls: { support: "no", reason: "Hermes does not expose per-model-call hooks" } + max_duration: { support: "yes" } + telemetry: + enabled: { support: "no", reason: "Hermes has Langfuse integration but no native OTEL" } + native_emitter: { support: "no" } + prompts: + system_prompt: { support: "partial", reason: "System prompt is downgraded into AGENTS.md" } + agent_instructions: { support: "yes" } + auth: + api_key: { support: "yes" } + auth_file: { support: "no" } + oauth_token: { support: "no" } + vertex_ai: { support: "no", reason: "Hermes gemini provider uses Google AI Studio API keys, not Vertex AI ADC" } + mcp: + stdio: { support: "yes" } + sse: { support: "yes" } + streamable_http: { support: "yes" } + project_scope: { support: "no", reason: "Not yet implemented" } + +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Run: hermes setup + Then run: python3 /home/scion/.scion/harness/capture_auth.py + +auth: + default_type: api-key + types: + api-key: + required_env: + - any_of: ["GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"] + autodetect: + env: + GOOGLE_API_KEY: api-key + OPENAI_API_KEY: api-key + ANTHROPIC_API_KEY: api-key From d321371df3b4a0d504d396e51a8a7b6ab9eaf93e Mon Sep 17 00:00:00 2001 From: "Scion Agent (hh-dev)" Date: Sat, 27 Jun 2026 17:01:26 +0000 Subject: [PATCH 2/4] 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 --- harnesses/hermes/provision.py | 527 ++++++++++++++++++++++++++++++++++ 1 file changed, 527 insertions(+) create mode 100644 harnesses/hermes/provision.py diff --git a/harnesses/hermes/provision.py b/harnesses/hermes/provision.py new file mode 100644 index 000000000..233e91552 --- /dev/null +++ b/harnesses/hermes/provision.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Hermes container-side provisioner. + +Runs inside the agent container during the pre-start lifecycle hook, invoked +by `sciontool harness provision --manifest ...`. The host-side +ContainerScriptHarness has already: + + * Staged this script and config.yaml under $HOME/.scion/harness/. + * Written inputs/auth-candidates.json with the env-var names + paths to + secret-value files under $HOME/.scion/harness/secrets/. + +This script's job: + + 1. Determine which API key is available, with precedence: + ANTHROPIC_API_KEY > OPENAI_API_KEY > GOOGLE_API_KEY. + 2. Read the secret value from the staged secrets/ file and write it + to ~/.hermes/.env (Hermes reads secrets from this dotenv file). + 3. Compose staged Scion prompt inputs into AGENTS.md (instruction + projection — Hermes auto-reads AGENTS.md as context). + 4. Apply MCP server configuration to ~/.hermes/mcp.json. + 5. Write outputs/resolved-auth.json and outputs/env.json (env overlay + with HERMES_YOLO_MODE, HERMES_QUIET, HERMES_ACCEPT_HOOKS, and + optionally HERMES_INFERENCE_MODEL). + +The script is stdlib-only — no third-party dependencies. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + import scion_harness # type: ignore[import-not-found] +except ImportError: + scion_harness = None # type: ignore[assignment] + +HERMES_ENV_FILE = "~/.hermes/.env" +SCION_MANAGED_BEGIN = "" +SCION_MANAGED_END = "" + +AUTH_KEY_PRECEDENCE = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"] + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_UNSUPPORTED = 2 + + +def _expand(path: str) -> str: + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_json(path: str) -> Any: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def _write_json(path: str, payload: Any) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp, path) + + +def _present_env_keys(candidates: dict[str, Any]) -> set[str]: + raw = candidates.get("env_vars") or [] + return {str(k) for k in raw if isinstance(k, str)} + + +def _env_secret_files(candidates: dict[str, Any]) -> dict[str, str]: + raw = candidates.get("env_secret_files") or {} + out: dict[str, str] = {} + if not isinstance(raw, dict): + return out + for k, v in raw.items(): + if isinstance(k, str) and isinstance(v, str) and v: + out[k] = v + return out + + +def _read_secret(env_secret_files: dict[str, str], name: str) -> str: + path = env_secret_files.get(name) + if not path: + return "" + real = _expand(path) + try: + with open(real, "r", encoding="utf-8") as f: + return f.read().rstrip("\r\n") + except OSError: + return "" + + +def _select_auth_key( + explicit: str, + env_keys: set[str], +) -> tuple[str, str]: + """Pick an API key env var. + + Returns (method, env_key). Raises ValueError on no-creds. + """ + if explicit and explicit != "api-key": + raise ValueError( + f"hermes: unknown auth type {explicit!r}; only 'api-key' is supported" + ) + + for key in AUTH_KEY_PRECEDENCE: + if key in env_keys: + return "api-key", key + + raise ValueError( + "hermes: no valid API key found; set ANTHROPIC_API_KEY, OPENAI_API_KEY, " + "or GOOGLE_API_KEY" + ) + + +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) + + +# --- Instruction projection ------------------------------------------------ + + +def _read_text_if_exists(path: str) -> str: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except OSError: + return "" + + +def _strip_scion_managed_block(content: str) -> str: + start = content.find(SCION_MANAGED_BEGIN) + if start == -1: + return content + end = content.find(SCION_MANAGED_END, start) + if end == -1: + print( + f"hermes provision: warning: found {SCION_MANAGED_BEGIN} but no matching " + f"{SCION_MANAGED_END}. Aborting strip to prevent data loss.", + file=sys.stderr, + ) + return content + end += len(SCION_MANAGED_END) + return (content[:start] + content[end:]).strip() + "\n" + + +def _markdown_section(title: str, content: str) -> str: + body = content.strip() + if not body: + return "" + return f"# {title}\n\n{body}\n" + + +def _skill_sections(home: str, skills_dir: str) -> list[str]: + if not skills_dir: + return [] + root = os.path.join(home, skills_dir) + if not os.path.isdir(root): + return [] + + sections: list[str] = [] + try: + entries = sorted(os.listdir(root)) + except OSError as exc: + print(f"hermes provision: could not list skills dir {root}: {exc}", file=sys.stderr) + return [] + + for entry in entries: + if entry.startswith("."): + continue + skill_md = os.path.join(root, entry, "SKILL.md") + if not os.path.isfile(skill_md): + continue + content = _read_text_if_exists(skill_md).strip() + if not content: + continue + sections.append(f"## {entry}\n\n{content}\n") + return sections + + +def _apply_instruction_projection(bundle: str, manifest: dict[str, Any]) -> None: + """Compose staged Scion prompt inputs into AGENTS.md. + + Hermes auto-reads AGENTS.md as context. The system prompt is downgraded + into AGENTS.md when config.yaml requests prepend_to_instructions. + """ + harness_cfg = manifest.get("harness_config") or {} + home = os.environ.get("HOME") or _expand("~") + instructions_file = str(harness_cfg.get("instructions_file") or "AGENTS.md") + system_prompt_mode = str(harness_cfg.get("system_prompt_mode") or "none") + skills_dir = str(harness_cfg.get("skills_dir") or ".hermes/skills") + + inputs_dir = os.path.join(bundle, "inputs") + instructions = _read_text_if_exists(os.path.join(inputs_dir, "instructions.md")) + system_prompt = _read_text_if_exists(os.path.join(inputs_dir, "system-prompt.md")) + skills = _skill_sections(home, skills_dir) + + target = os.path.join(home, instructions_file) + existing = _strip_scion_managed_block(_read_text_if_exists(target)) + + sections: list[str] = [] + if system_prompt.strip() and system_prompt_mode != "none": + sections.append(_markdown_section("System Instruction", system_prompt)) + + if instructions.strip(): + sections.append(_markdown_section("Agent Instructions", instructions)) + + if skills: + sections.append("# Skills\n\n" + "\n\n".join(skill.strip() for skill in skills) + "\n") + + if not sections and not existing.strip(): + if os.path.isfile(target): + os.remove(target) + return + + managed = "" + if sections: + managed = ( + f"{SCION_MANAGED_BEGIN}\n\n" + + "\n\n".join(section.strip() for section in sections if section.strip()) + + f"\n\n{SCION_MANAGED_END}\n" + ) + + unmanaged = "" + if existing.strip(): + unmanaged = existing.strip() + "\n" + if managed: + unmanaged = "\n" + unmanaged + content = managed + unmanaged + + os.makedirs(os.path.dirname(target) if os.path.dirname(target) else ".", exist_ok=True) + tmp = target + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp, target) + print(f"hermes provision: wrote instructions to {target}", file=sys.stderr) + + +# --- MCP server reconciliation --------------------------------------------- +# +# Hermes reads MCP config from ~/.hermes/mcp.json. The format is a JSON +# object with an "mcpServers" key containing named server definitions: +# { +# "mcpServers": { +# "name": { "command": "...", "args": [...], "env": {...} }, +# "name": { "url": "..." } +# } +# } + + +def _build_mcp_entry(name: str, spec: dict[str, Any]) -> dict[str, Any] | None: + """Translate a universal MCPServerConfig into a Hermes mcp.json entry.""" + transport = (spec.get("transport") or "").strip() + + if transport == "stdio": + cmd = spec.get("command") + if not isinstance(cmd, str) or not cmd: + print(f"hermes provision: mcp server {name!r}: stdio transport missing command", file=sys.stderr) + return None + entry: dict[str, Any] = {"command": cmd} + args = spec.get("args") or [] + if isinstance(args, list) and args: + entry["args"] = [str(a) for a in args] + env = spec.get("env") + if isinstance(env, dict) and env: + entry["env"] = {str(k): str(v) for k, v in env.items()} + return entry + elif transport in ("sse", "streamable-http"): + url = spec.get("url") + if not isinstance(url, str) or not url: + print(f"hermes provision: mcp server {name!r}: {transport} transport missing url", file=sys.stderr) + return None + entry = {"url": url} + headers = spec.get("headers") + if isinstance(headers, dict) and headers: + entry["headers"] = {str(k): str(v) for k, v in headers.items()} + return entry + else: + print(f"hermes provision: mcp server {name!r}: unsupported transport {transport!r}", file=sys.stderr) + return None + + +def _apply_mcp_servers(bundle: str) -> int: + """Write MCP server config to ~/.hermes/mcp.json. + + Returns the number of servers written. + """ + if scion_harness is None: + servers = _read_mcp_servers_inline(bundle) + else: + try: + servers = scion_harness.read_mcp_servers(bundle) + except ValueError as exc: + print(f"hermes provision: {exc}", file=sys.stderr) + return 0 + + if not servers: + return 0 + + mcp_servers: dict[str, Any] = {} + for name in sorted(servers.keys()): + spec = servers[name] + if not isinstance(spec, dict): + continue + scope = (spec.get("scope") or "global").strip().lower() + if scope == "project": + print( + f"hermes provision: mcp server {name!r} requested project scope; " + "registering globally (project-scoped MCP not implemented)", + file=sys.stderr, + ) + entry = _build_mcp_entry(name, spec) + if entry is not None: + mcp_servers[name] = entry + + if not mcp_servers: + return 0 + + hermes_dir = _expand("~/.hermes") + try: + os.makedirs(hermes_dir, exist_ok=True) + except OSError as exc: + print(f"hermes provision: could not create {hermes_dir}: {exc}", file=sys.stderr) + return 0 + + config_path = os.path.join(hermes_dir, "mcp.json") + payload = {"mcpServers": mcp_servers} + try: + _write_json(config_path, payload) + except OSError as exc: + print(f"hermes provision: failed to write mcp.json: {exc}", file=sys.stderr) + return 0 + + print(f"hermes provision: applied {len(mcp_servers)} mcp server(s)", file=sys.stderr) + return len(mcp_servers) + + +def _read_mcp_servers_inline(bundle: str) -> dict[str, dict[str, Any]]: + """Fallback when scion_harness import fails.""" + path = os.path.join(bundle, "inputs", "mcp-servers.json") + if not os.path.isfile(path): + return {} + try: + payload = _load_json(path) or {} + except (OSError, json.JSONDecodeError) as exc: + print(f"hermes provision: invalid mcp-servers.json: {exc}", file=sys.stderr) + return {} + if not isinstance(payload, dict): + return {} + servers = payload.get("mcp_servers") or {} + if not isinstance(servers, dict): + return {} + return {str(k): v for k, v in servers.items() if isinstance(v, dict)} + + +# --- Entry point ----------------------------------------------------------- + + +def _provision(manifest: dict[str, Any]) -> int: + bundle = manifest.get("harness_bundle_dir") or "$HOME/.scion/harness" + bundle = _expand(bundle) + inputs_dir = os.path.join(bundle, "inputs") + + auth_candidates_path = os.path.join(inputs_dir, "auth-candidates.json") + candidates: dict[str, Any] = {} + 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 + + explicit = str(candidates.get("explicit_type") or "").strip() + env_keys = _present_env_keys(candidates) + secret_files = _env_secret_files(candidates) + + harness_cfg = manifest.get("harness_config") or {} + no_auth_cfg = harness_cfg.get("no_auth") or {} + no_auth_behavior = str(no_auth_cfg.get("behavior") or "").strip() + + 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 + + # Write the API key to ~/.hermes/.env so Hermes can read it. + hermes_env_vars: dict[str, str] = {} + if method == "api-key": + api_key = _read_secret(secret_files, env_key) + if not api_key: + print( + f"hermes provision: chose api-key ({env_key}) but no secret value " + f"was staged at the recorded path; check ApplyAuthSettings", + file=sys.stderr, + ) + return EXIT_ERROR + hermes_env_vars[env_key] = api_key + + if hermes_env_vars: + try: + _write_hermes_env(hermes_env_vars) + except OSError as exc: + print(f"hermes provision: write .env failed: {exc}", file=sys.stderr) + return EXIT_ERROR + + try: + _apply_instruction_projection(bundle, manifest) + except OSError as exc: + print(f"hermes provision: instruction projection failed: {exc}", file=sys.stderr) + return EXIT_ERROR + + # Build env overlay — these env vars are injected into the container + # environment by sciontool before starting hermes. + env_payload: dict[str, str] = { + "HERMES_YOLO_MODE": "1", + "HERMES_QUIET": "1", + "HERMES_ACCEPT_HOOKS": "auto", + } + + # Resolve model alias if provided. + model_resolution = manifest.get("model_resolution") or {} + resolved_model = str(model_resolution.get("resolved_model") or "").strip() + if resolved_model: + env_payload["HERMES_INFERENCE_MODEL"] = resolved_model + + # Outputs. + outputs = manifest.get("outputs") or {} + env_out = _expand(outputs.get("env") or os.path.join(bundle, "outputs", "env.json")) + auth_out = _expand(outputs.get("resolved_auth") or os.path.join(bundle, "outputs", "resolved-auth.json")) + + resolved_payload: dict[str, Any] = { + "schema_version": 1, + "harness": "hermes", + "method": method, + "explicit_type": explicit or None, + } + if method == "api-key": + resolved_payload["env_var"] = env_key + + try: + _write_json(auth_out, resolved_payload) + _write_json(env_out, env_payload) + except OSError as exc: + print(f"hermes provision: failed to write outputs: {exc}", file=sys.stderr) + return EXIT_ERROR + + _apply_mcp_servers(bundle) + + print(f"hermes provision: method={method}", file=sys.stderr) + return EXIT_OK + + +def _dispatch(manifest: dict[str, Any]) -> int: + command = str(manifest.get("command") or "provision") + if command == "provision": + return _provision(manifest) + print(f"hermes provision: unsupported command {command!r}", file=sys.stderr) + return EXIT_UNSUPPORTED + + +def main() -> int: + parser = argparse.ArgumentParser(description="Hermes container-side provisioner") + parser.add_argument( + "--manifest", + help="Path to the staged manifest.json (defaults to $HOME/.scion/harness/manifest.json)", + default=None, + ) + args = parser.parse_args() + + manifest_path = args.manifest + if not manifest_path: + home = os.environ.get("HOME") or os.path.expanduser("~") + manifest_path = os.path.join(home, ".scion", "harness", "manifest.json") + + try: + manifest = _load_json(manifest_path) + except FileNotFoundError: + print(f"hermes provision: manifest not found at {manifest_path}", file=sys.stderr) + return EXIT_ERROR + except (OSError, json.JSONDecodeError) as exc: + print(f"hermes provision: failed to load manifest {manifest_path}: {exc}", file=sys.stderr) + return EXIT_ERROR + + if not isinstance(manifest, dict): + print("hermes provision: manifest is not an object", file=sys.stderr) + return EXIT_ERROR + + return _dispatch(manifest) + + +if __name__ == "__main__": + sys.exit(main()) From 1bf7227067e94b40162995dcd9592e522e9deecc Mon Sep 17 00:00:00 2001 From: "Scion Agent (hh-dev)" Date: Sat, 27 Jun 2026 17:02:14 +0000 Subject: [PATCH 3/4] 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 --- harnesses/hermes/README.md | 47 +++++++++ harnesses/hermes/capture_auth.py | 168 +++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 harnesses/hermes/README.md create mode 100644 harnesses/hermes/capture_auth.py diff --git a/harnesses/hermes/README.md b/harnesses/hermes/README.md new file mode 100644 index 000000000..b6947007c --- /dev/null +++ b/harnesses/hermes/README.md @@ -0,0 +1,47 @@ +# Hermes Harness Bundle + +Scion harness configuration for [Hermes Agent](https://github.com/nousresearch/hermes-agent), +Nous Research's AI coding agent (MIT license). + +## Install + +From a repository checkout: + +```sh +scion harness-config install harnesses/hermes +``` + +Or directly from GitHub: + +```sh +scion harness-config install github.com/GoogleCloudPlatform/scion/tree/main/harnesses/hermes +``` + +## Auth Modes + +| Mode | Env Var | Notes | +|------|---------|-------| +| `api-key` (default) | `ANTHROPIC_API_KEY` | Anthropic key (highest precedence) | +| `api-key` | `OPENAI_API_KEY` | OpenAI key | +| `api-key` | `GOOGLE_API_KEY` | Google AI Studio key | + +## Bundle Layout + +``` +hermes/ + config.yaml # Harness configuration (provisioner, capabilities, auth) + provision.py # Container-side provisioner (pre-start hook) + capture_auth.py # Credential capture for no-auth flow + Dockerfile # Image build (FROM scion-base) + cloudbuild.yaml # Cloud Build configuration +``` + +## Build the Image + +```sh +# Local Docker build +docker build --build-arg BASE_IMAGE=scion-base:latest -t scion-hermes:latest -f Dockerfile . + +# Cloud Build +gcloud builds submit --config cloudbuild.yaml . +``` diff --git a/harnesses/hermes/capture_auth.py b/harnesses/hermes/capture_auth.py new file mode 100644 index 000000000..395992edf --- /dev/null +++ b/harnesses/hermes/capture_auth.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Hermes capture-auth script. + +Scans for credential files on disk and stores them as project-scoped secrets +via `sciontool secret set`. Designed to run after the user authenticates +interactively inside a no-auth agent container. + +Reads credential mappings from inputs/capture-auth-config.json (derived from +the harness config.yaml's auth.types.*.required_files declarations). This +avoids hardcoding paths or key names in the script. + +Exit codes: + 0 = at least one credential captured + 1 = error + 2 = no credentials found (not an error, but nothing was stored) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_NO_CREDS = 2 + +HARNESS_BUNDLE = os.path.join( + os.environ.get("HOME") or os.path.expanduser("~"), + ".scion", "harness", +) + + +def _expand(path: str) -> str: + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_config(bundle: str) -> list[dict[str, Any]]: + config_path = os.path.join(bundle, "inputs", "capture-auth-config.json") + if not os.path.isfile(config_path): + return [] + with open(config_path, "r", encoding="utf-8") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + creds = data.get("credentials") + if not isinstance(creds, list): + return [] + return creds + + +def _capture_one( + entry: dict[str, Any], force: bool +) -> tuple[bool, str | None]: + """Attempt to capture a single credential. Returns (success, error_msg).""" + key = entry.get("key", "") + source = _expand(entry.get("source", "")) + secret_type = entry.get("type", "file") + target = entry.get("target", "") + + if not key or not source: + return False, "invalid entry: missing key or source" + + if not os.path.isfile(source): + return False, None + + cmd = [ + "sciontool", "secret", "set", key, f"@{source}", + "--type", secret_type, + "--target", target, + ] + if force: + cmd.append("--force") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + return False, "sciontool not found in PATH" + except subprocess.TimeoutExpired: + return False, f"sciontool timed out for key {key}" + + if result.returncode != 0: + stderr = result.stderr.strip() + return False, f"sciontool failed for {key}: {stderr}" + + return True, None + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Capture auth credentials and store as project secrets" + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing secrets", + ) + parser.add_argument( + "--bundle", + default=HARNESS_BUNDLE, + help="Path to harness bundle directory", + ) + args = parser.parse_args() + + entries = _load_config(args.bundle) + if not entries: + print( + "capture-auth: no credential mappings found in " + "inputs/capture-auth-config.json", + file=sys.stderr, + ) + return EXIT_NO_CREDS + + captured = 0 + errors = 0 + + for entry in entries: + key = entry.get("key", "") + source = entry.get("source", "") + expanded = _expand(source) if source else "" + + if not expanded or not os.path.isfile(expanded): + print(f"capture-auth: {key}: source not found ({source})") + continue + + ok, err = _capture_one(entry, args.force) + if err: + print(f"capture-auth: {key}: {err}", file=sys.stderr) + errors += 1 + elif ok: + print(f"capture-auth: {key}: captured from {source}") + captured += 1 + + if errors > 0 and captured == 0: + return EXIT_ERROR + + if captured == 0: + print("capture-auth: no credentials found to capture") + return EXIT_NO_CREDS + + print(f"capture-auth: {captured} credential(s) captured successfully") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) From 2ee49b6c1937e9a45a533b6986c8a70c5aebaecb Mon Sep 17 00:00:00 2001 From: "Scion Agent (hh-dev)" Date: Sat, 27 Jun 2026 17:21:12 +0000 Subject: [PATCH 4/4] 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 --- harnesses/hermes/Dockerfile | 11 +- harnesses/hermes/provision.py | 3 +- harnesses/hermes/provision_test.py | 264 +++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 harnesses/hermes/provision_test.py diff --git a/harnesses/hermes/Dockerfile b/harnesses/hermes/Dockerfile index f1d2db89b..a2c31eb09 100644 --- a/harnesses/hermes/Dockerfile +++ b/harnesses/hermes/Dockerfile @@ -16,7 +16,7 @@ ARG BASE_IMAGE FROM ${BASE_IMAGE} -ARG HERMES_VERSION=latest +ARG HERMES_VERSION= # Install Node.js 22 LTS (required by Hermes for tool execution) RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ @@ -28,10 +28,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends ripgrep \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Install Hermes Agent via pip (available in scion-base) -RUN pip install --no-cache-dir "hermes-agent==${HERMES_VERSION}" \ - || pip install --no-cache-dir hermes-agent +RUN if [ -n "${HERMES_VERSION}" ]; then \ + pip install --no-cache-dir "hermes-agent==${HERMES_VERSION}"; \ + else \ + pip install --no-cache-dir hermes-agent; \ + fi -RUN mkdir -p /home/scion/.hermes \ +RUN mkdir -p /home/scion/.hermes /home/scion/.hermes/skills \ && chown -R scion:scion /home/scion/.hermes CMD ["hermes"] diff --git a/harnesses/hermes/provision.py b/harnesses/hermes/provision.py index 233e91552..d45941cb3 100644 --- a/harnesses/hermes/provision.py +++ b/harnesses/hermes/provision.py @@ -53,7 +53,6 @@ except ImportError: scion_harness = None # type: ignore[assignment] -HERMES_ENV_FILE = "~/.hermes/.env" SCION_MANAGED_BEGIN = "" SCION_MANAGED_END = "" @@ -356,6 +355,7 @@ def _apply_mcp_servers(bundle: str) -> int: payload = {"mcpServers": mcp_servers} try: _write_json(config_path, payload) + os.chmod(config_path, 0o600) except OSError as exc: print(f"hermes provision: failed to write mcp.json: {exc}", file=sys.stderr) return 0 @@ -447,6 +447,7 @@ def _provision(manifest: dict[str, Any]) -> int: # Build env overlay — these env vars are injected into the container # environment by sciontool before starting hermes. env_payload: dict[str, str] = { + "HERMES_HOME": "/home/scion/.hermes", "HERMES_YOLO_MODE": "1", "HERMES_QUIET": "1", "HERMES_ACCEPT_HOOKS": "auto", diff --git a/harnesses/hermes/provision_test.py b/harnesses/hermes/provision_test.py new file mode 100644 index 000000000..61cdce8f8 --- /dev/null +++ b/harnesses/hermes/provision_test.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import importlib.util +import io +import os +import tempfile +import unittest +from contextlib import contextmanager, redirect_stderr + +PROVISION_PATH = os.path.join(os.path.dirname(__file__), "provision.py") +SPEC = importlib.util.spec_from_file_location("hermes_provision", PROVISION_PATH) +assert SPEC is not None +provision = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(provision) + + +@contextmanager +def temporary_home(path: str): + old_home = os.environ.get("HOME") + os.environ["HOME"] = path + try: + yield + finally: + if old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = old_home + + +class AuthResolutionTest(unittest.TestCase): + def test_anthropic_takes_precedence_over_openai_and_google(self) -> None: + env_keys = {"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"} + method, key = provision._select_auth_key("", env_keys) + self.assertEqual(method, "api-key") + self.assertEqual(key, "ANTHROPIC_API_KEY") + + def test_openai_takes_precedence_over_google(self) -> None: + env_keys = {"OPENAI_API_KEY", "GOOGLE_API_KEY"} + method, key = provision._select_auth_key("", env_keys) + self.assertEqual(method, "api-key") + self.assertEqual(key, "OPENAI_API_KEY") + + def test_google_key_used_when_alone(self) -> None: + env_keys = {"GOOGLE_API_KEY"} + method, key = provision._select_auth_key("", env_keys) + self.assertEqual(method, "api-key") + self.assertEqual(key, "GOOGLE_API_KEY") + + def test_explicit_api_key_type_accepted(self) -> None: + env_keys = {"GOOGLE_API_KEY"} + method, key = provision._select_auth_key("api-key", env_keys) + self.assertEqual(method, "api-key") + self.assertEqual(key, "GOOGLE_API_KEY") + + def test_unknown_auth_type_raises(self) -> None: + with self.assertRaises(ValueError) as ctx: + provision._select_auth_key("auth-file", {"ANTHROPIC_API_KEY"}) + self.assertIn("only 'api-key' is supported", str(ctx.exception)) + + def test_no_keys_raises(self) -> None: + with self.assertRaises(ValueError) as ctx: + provision._select_auth_key("", set()) + self.assertIn("no valid API key found", str(ctx.exception)) + + +class InstructionProjectionTest(unittest.TestCase): + def test_composes_prompts_and_skills(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = os.path.join(tmp, "home") + bundle = os.path.join(tmp, "bundle") + os.makedirs(os.path.join(bundle, "inputs")) + os.makedirs(os.path.join(home, ".hermes", "skills", "example")) + os.makedirs(os.path.join(home, ".hermes", "skills", "second")) + + with open(os.path.join(bundle, "inputs", "system-prompt.md"), "w") as f: + f.write("System rules") + with open(os.path.join(bundle, "inputs", "instructions.md"), "w") as f: + f.write("Agent rules") + with open( + os.path.join(home, ".hermes", "skills", "example", "SKILL.md"), "w" + ) as f: + f.write("# Example Skill\n\nUse this skill.") + with open( + os.path.join(home, ".hermes", "skills", "second", "SKILL.md"), "w" + ) as f: + f.write("# Second Skill\n\nUse this other skill.") + + manifest = { + "harness_config": { + "instructions_file": "AGENTS.md", + "skills_dir": ".hermes/skills", + "system_prompt_mode": "prepend_to_instructions", + }, + } + + with temporary_home(home): + provision._apply_instruction_projection(bundle, manifest) + provision._apply_instruction_projection(bundle, manifest) + + with open(os.path.join(home, "AGENTS.md"), "r") as f: + content = f.read() + + self.assertEqual(content.count(provision.SCION_MANAGED_BEGIN), 1) + self.assertIn("# System Instruction\n\nSystem rules", content) + self.assertIn("# Agent Instructions\n\nAgent rules", content) + self.assertIn("## example\n\n# Example Skill\n\nUse this skill.", content) + self.assertIn("## second\n\n# Second Skill\n\nUse this other skill.", content) + + def test_cleans_stale_managed_block_when_inputs_empty(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = os.path.join(tmp, "home") + bundle = os.path.join(tmp, "bundle") + os.makedirs(os.path.join(bundle, "inputs")) + os.makedirs(home) + + agents_path = os.path.join(home, "AGENTS.md") + with open(agents_path, "w") as f: + f.write( + f"{provision.SCION_MANAGED_BEGIN}\n\n" + "# Agent Instructions\n\nOld managed content\n\n" + f"{provision.SCION_MANAGED_END}\n\n" + "# User Notes\n\nKeep this.\n" + ) + + manifest = { + "harness_config": { + "instructions_file": "AGENTS.md", + "skills_dir": ".hermes/skills", + "system_prompt_mode": "prepend_to_instructions", + }, + } + + with temporary_home(home): + provision._apply_instruction_projection(bundle, manifest) + + with open(agents_path, "r") as f: + content = f.read() + + self.assertNotIn(provision.SCION_MANAGED_BEGIN, content) + self.assertNotIn("Old managed content", content) + self.assertEqual(content, "# User Notes\n\nKeep this.\n") + + def test_removes_file_when_only_stale_managed_block_remains(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = os.path.join(tmp, "home") + bundle = os.path.join(tmp, "bundle") + os.makedirs(os.path.join(bundle, "inputs")) + os.makedirs(home) + + agents_path = os.path.join(home, "AGENTS.md") + with open(agents_path, "w") as f: + f.write( + f"{provision.SCION_MANAGED_BEGIN}\n\n" + "# Agent Instructions\n\nOld managed content\n\n" + f"{provision.SCION_MANAGED_END}\n" + ) + + manifest = { + "harness_config": { + "instructions_file": "AGENTS.md", + "skills_dir": ".hermes/skills", + "system_prompt_mode": "prepend_to_instructions", + }, + } + + with temporary_home(home): + provision._apply_instruction_projection(bundle, manifest) + + self.assertFalse(os.path.exists(agents_path)) + + def test_preserves_content_when_end_marker_missing(self) -> None: + content = ( + "# Before\n\n" + f"{provision.SCION_MANAGED_BEGIN}\n\n" + "# Agent Instructions\n\nManaged without an end marker\n\n" + "# After\n" + ) + stderr = io.StringIO() + + with redirect_stderr(stderr): + got = provision._strip_scion_managed_block(content) + + self.assertEqual(got, content) + self.assertIn("Aborting strip to prevent data loss", stderr.getvalue()) + + +class MCPEntryBuildingTest(unittest.TestCase): + def test_stdio_transport(self) -> None: + spec = { + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": {"HOME": "/home/scion"}, + } + entry = provision._build_mcp_entry("fs", spec) + self.assertIsNotNone(entry) + self.assertEqual(entry["command"], "npx") + self.assertEqual(entry["args"], ["-y", "@modelcontextprotocol/server-filesystem"]) + self.assertEqual(entry["env"], {"HOME": "/home/scion"}) + + def test_sse_transport(self) -> None: + spec = { + "transport": "sse", + "url": "https://mcp.example.com/sse", + "headers": {"Authorization": "Bearer tok"}, + } + entry = provision._build_mcp_entry("remote", spec) + self.assertIsNotNone(entry) + self.assertEqual(entry["url"], "https://mcp.example.com/sse") + self.assertEqual(entry["headers"], {"Authorization": "Bearer tok"}) + + def test_streamable_http_transport(self) -> None: + spec = { + "transport": "streamable-http", + "url": "https://mcp.example.com/stream", + } + entry = provision._build_mcp_entry("stream", spec) + self.assertIsNotNone(entry) + self.assertEqual(entry["url"], "https://mcp.example.com/stream") + self.assertNotIn("headers", entry) + + def test_unknown_transport_returns_none(self) -> None: + spec = {"transport": "grpc", "url": "localhost:50051"} + stderr = io.StringIO() + with redirect_stderr(stderr): + entry = provision._build_mcp_entry("bad", spec) + self.assertIsNone(entry) + self.assertIn("unsupported transport", stderr.getvalue()) + + def test_stdio_missing_command_returns_none(self) -> None: + spec = {"transport": "stdio"} + stderr = io.StringIO() + with redirect_stderr(stderr): + entry = provision._build_mcp_entry("no-cmd", spec) + self.assertIsNone(entry) + self.assertIn("missing command", stderr.getvalue()) + + def test_sse_missing_url_returns_none(self) -> None: + spec = {"transport": "sse"} + stderr = io.StringIO() + with redirect_stderr(stderr): + entry = provision._build_mcp_entry("no-url", spec) + self.assertIsNone(entry) + self.assertIn("missing url", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main()