diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml
index 0966f3ed..bb3490ca 100644
--- a/services/hermes/agent-configmap.yaml
+++ b/services/hermes/agent-configmap.yaml
@@ -315,6 +315,33 @@ data:
real branch only when the requested implementation is review-ready. Never
force-push.
+ `scm.bstein.dev` is Gitea, not GitHub. Never load or follow a GitHub/`gh`
+ skill for an Atlas remote, and do not interpret an unauthenticated Gitea
+ HTTP 404 as a missing private repository. Use authenticated Git for clone,
+ fetch, and push. For pull-request metadata, comments, and merges, call
+ `/opt/coordinator/gitea_api.py METHOD /api/v1/...`; it injects the runtime
+ Vault token without exposing it to the command line, environment, output,
+ or transcript. Before a consequential merge, independently verify the
+ exact base/head ancestry and diff, run the relevant tests, and confirm the
+ candidate Jenkins result. Prefer the internal endpoint in
+ `JENKINS_BASE_URL`; when Jenkins API authorization prevents a read, use the
+ existing cluster access to inspect the controller's job/build files and
+ logs rather than guessing a public hostname. The preferred read-only path
+ is `/opt/coordinator/jenkins_build_evidence.py JOB [--branch BRANCH]
+ [--commit SHA] --wait`; it distinguishes a genuinely terminal build from
+ nested Jenkins execution metadata and returns bounded JSON/log evidence.
+ After merging, observe the default-branch build to a terminal result and
+ report exact commit, build, and test evidence.
+
+ The workspace already has a non-secret Hermes Git author identity. Do not
+ use `git reset --hard`, even in a fresh clone; use a detached worktree or a
+ clean branch switch when comparing revisions, and preserve any unexpected
+ file as user state. Use the Gitea pull-request merge endpoint for an open
+ PR so both Git history and PR state remain auditable. If an authorized
+ manual merge already reached the base branch, reconcile the open PR with
+ `Do=manually-merged` and its exact merge commit instead of leaving stale
+ review state.
+
The terminal PATH contains the pinned operator tools. Start cluster work
with `kubectl config current-context`, read-only status/events/logs, and the
relevant `titan-iac` manifests. Put durable desired-state changes on a
diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml
index e3107a56..9050fa63 100644
--- a/services/hermes/agent-deployment.yaml
+++ b/services/hermes/agent-deployment.yaml
@@ -25,7 +25,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
- ai.bstein.dev/config-rev: "20260816-routed-vision-v4"
+ ai.bstein.dev/config-rev: "20260816-atlas-forge-workflow-v1"
prometheus.io/scrape: "true"
prometheus.io/path: /metrics
prometheus.io/port: "9010"
@@ -187,6 +187,11 @@ spec:
done
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
upsert_env GIT_TERMINAL_PROMPT 0
+ upsert_env GITEA_BASE_URL https://scm.bstein.dev
+ upsert_env JENKINS_BASE_URL http://jenkins.jenkins.svc.cluster.local:8080
+ upsert_env ARIADNE_BASE_URL http://ariadne.maintenance.svc.cluster.local
+ upsert_env VICTORIA_METRICS_URL http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428
+ upsert_env GRAFANA_BASE_URL https://metrics.bstein.dev
chmod 0600 "${env_file}"
touch "${profile_file}"
if ! grep -qxF '# Hermes managed operator PATH.' "${profile_file}"; then
@@ -294,6 +299,8 @@ spec:
mv "${tools}/bin/kubectl.tmp" "${tools}/bin/kubectl"
fi
/bin/sh /opt/coordinator/install_agent_tools.sh
+ HOME=/opt/data/home git config --global user.name "Hermes Agent"
+ HOME=/opt/data/home git config --global user.email "hermes@bstein.dev"
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml
index 8ec736e3..f3c45d92 100644
--- a/services/hermes/kustomization.yaml
+++ b/services/hermes/kustomization.yaml
@@ -68,12 +68,14 @@ configMapGenerator:
- classifier_broker.py=scripts/classifier_broker.py
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
- worker_route_broker.py=scripts/worker_route_broker.py
+ - gitea_api.py=scripts/gitea_api.py
- gitea_askpass.sh=scripts/gitea_askpass.sh
- hermes_coordinator.py=scripts/hermes_coordinator.py
- hermes_model_routing.py=scripts/hermes_model_routing.py
- hermes_stt_client.py=scripts/hermes_stt_client.py
- image_broker.py=scripts/image_broker.py
- install_agent_tools.sh=scripts/install_agent_tools.sh
+ - jenkins_build_evidence.py=scripts/jenkins_build_evidence.py
- kanban_status_recovery.py=scripts/kanban_status_recovery.py
- migrate_herdr_state.py=scripts/migrate_herdr_state.py
- migrate_api_session_lineage.py=scripts/migrate_api_session_lineage.py
diff --git a/services/hermes/scripts/gitea_api.py b/services/hermes/scripts/gitea_api.py
new file mode 100644
index 00000000..a2188ba6
--- /dev/null
+++ b/services/hermes/scripts/gitea_api.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""Call the private Atlas Gitea API through a runtime-only token boundary."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+
+DEFAULT_BASE_URL = "https://scm.bstein.dev"
+DEFAULT_TOKEN_FILE = Path("/runtime-access/gitea-token")
+ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
+
+
+def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str:
+ """Read and validate the token from its in-memory Vault projection."""
+ token = path.read_text(encoding="utf-8").strip()
+ if not token:
+ raise ValueError(f"runtime credential is empty: {path}")
+ return token
+
+
+def api_url(base_url: str, path: str) -> str:
+ """Return a same-origin Gitea API URL for a validated API path."""
+ base = urllib.parse.urlsplit(base_url.rstrip("/"))
+ target = urllib.parse.urlsplit(path)
+ if base.scheme not in {"http", "https"} or not base.netloc:
+ raise ValueError("GITEA_BASE_URL must be an absolute HTTP(S) URL")
+ if target.scheme or target.netloc or target.fragment:
+ raise ValueError("API path must be relative to the configured Gitea origin")
+ if not target.path.startswith("/api/v1/"):
+ raise ValueError("API path must start with /api/v1/")
+ return urllib.parse.urlunsplit(
+ (base.scheme, base.netloc, target.path, target.query, "")
+ )
+
+
+def build_request(
+ method: str,
+ path: str,
+ *,
+ base_url: str,
+ token: str,
+ data: object | None = None,
+) -> urllib.request.Request:
+ """Build one authenticated request without placing the token in its URL."""
+ payload = None
+ if data is not None:
+ payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
+ return urllib.request.Request(
+ api_url(base_url, path),
+ data=payload,
+ method=method,
+ headers={
+ "Accept": "application/json",
+ "Authorization": f"token {token}",
+ "Content-Type": "application/json",
+ "User-Agent": "hermes-atlas-operator/1",
+ },
+ )
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ """Parse a bounded method, API path, and optional JSON request body."""
+ parser = argparse.ArgumentParser(
+ description="Call the Atlas Gitea API using the runtime Vault token."
+ )
+ parser.add_argument("method", choices=ALLOWED_METHODS)
+ parser.add_argument("path", help="Gitea path beginning with /api/v1/")
+ data_group = parser.add_mutually_exclusive_group()
+ data_group.add_argument("--data-json", help="JSON object/array request body")
+ data_group.add_argument(
+ "--data-file", type=Path, help="path to a JSON request body"
+ )
+ return parser.parse_args(argv)
+
+
+def load_data(args: argparse.Namespace) -> object | None:
+ """Decode the optional JSON body without involving a shell expansion."""
+ if args.data_json is not None:
+ return json.loads(args.data_json)
+ if args.data_file is not None:
+ return json.loads(args.data_file.read_text(encoding="utf-8"))
+ return None
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Execute the request, print only its response body, and return HTTP status."""
+ args = parse_args(argv)
+ try:
+ request = build_request(
+ args.method,
+ args.path,
+ base_url=os.environ.get("GITEA_BASE_URL", DEFAULT_BASE_URL),
+ token=read_token(),
+ data=load_data(args),
+ )
+ with urllib.request.urlopen(request, timeout=30) as response:
+ body = response.read()
+ if body:
+ sys.stdout.buffer.write(body)
+ if not body.endswith(b"\n"):
+ sys.stdout.buffer.write(b"\n")
+ return 0
+ except urllib.error.HTTPError as exc:
+ body = exc.read(65536)
+ print(f"Gitea API returned HTTP {exc.code}", file=sys.stderr)
+ if body:
+ sys.stderr.buffer.write(body)
+ if not body.endswith(b"\n"):
+ sys.stderr.buffer.write(b"\n")
+ return 1
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
+ print(f"Gitea API request failed: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/services/hermes/scripts/jenkins_build_evidence.py b/services/hermes/scripts/jenkins_build_evidence.py
new file mode 100644
index 00000000..8a7e621d
--- /dev/null
+++ b/services/hermes/scripts/jenkins_build_evidence.py
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+"""Read bounded Jenkins build evidence through Hermes' Kubernetes access."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+import sys
+import time
+import xml.etree.ElementTree as ET
+from dataclasses import asdict, dataclass
+
+
+JENKINS_HOME = "/var/jenkins_home/jobs"
+SAFE_JOB = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
+SAFE_BRANCH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
+
+
+@dataclass(frozen=True)
+class BuildEvidence:
+ """One Jenkins build's terminal state and bounded supporting evidence."""
+
+ job: str
+ branch: str | None
+ number: int
+ revision: str | None
+ result: str | None
+ building: bool
+ timestamp_ms: int | None
+ duration_ms: int | None
+ log_tail: str
+
+
+def _validate_job(value: str) -> str:
+ """Accept one literal Jenkins job directory segment."""
+ if not SAFE_JOB.fullmatch(value):
+ raise ValueError("job must contain only letters, digits, dot, underscore, or dash")
+ return value
+
+
+def _validate_branch(value: str) -> str:
+ """Accept a branch name while rejecting traversal and absolute paths."""
+ if not SAFE_BRANCH.fullmatch(value) or value.startswith("/"):
+ raise ValueError("branch contains unsupported characters")
+ if any(part in {"", ".", ".."} for part in value.split("/")):
+ raise ValueError("branch contains an unsafe path component")
+ return value
+
+
+def _kubectl(*command: str) -> str:
+ """Run one read-only command in the Jenkins controller container."""
+ result = subprocess.run(
+ [
+ "kubectl",
+ "-n",
+ "jenkins",
+ "exec",
+ "deployment/jenkins",
+ "-c",
+ "jenkins",
+ "--",
+ *command,
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ return result.stdout
+
+
+def _read(path: str) -> str:
+ """Read one known Jenkins metadata file without shell interpolation."""
+ return _kubectl("cat", path)
+
+
+def _directories(path: str) -> list[str]:
+ """List immediate Jenkins directories using a fixed find invocation."""
+ output = _kubectl(
+ "find", path, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\n"
+ )
+ return [line for line in output.splitlines() if line]
+
+
+def _head_name(config_xml: str) -> str | None:
+ """Extract the logical branch name from a multibranch job config."""
+ root = ET.fromstring(config_xml)
+ for head in root.findall(".//head"):
+ name = head.findtext("name")
+ if name:
+ return name
+ return root.findtext("displayName")
+
+
+def resolve_job_path(job: str, branch: str | None) -> str:
+ """Resolve a job and optional logical branch to its controller path."""
+ base = f"{JENKINS_HOME}/{_validate_job(job)}"
+ if branch is None:
+ return base
+ wanted = _validate_branch(branch)
+ branch_root = f"{base}/branches"
+ for encoded in _directories(branch_root):
+ config_path = f"{branch_root}/{encoded}/config.xml"
+ try:
+ if _head_name(_read(config_path)) == wanted:
+ return f"{branch_root}/{encoded}"
+ except (ET.ParseError, subprocess.SubprocessError):
+ continue
+ raise ValueError(f"Jenkins branch not found: {job}/{branch}")
+
+
+def parse_build_xml(
+ xml_text: str,
+ *,
+ job: str,
+ branch: str | None,
+ number: int,
+ log_tail: str = "",
+) -> BuildEvidence:
+ """Parse top-level build state without mistaking nested CPS state as final."""
+ root = ET.fromstring(xml_text)
+ revision = None
+ for tag in ("hash", "sha1"):
+ candidate = root.findtext(f".//revision/{tag}")
+ if candidate:
+ revision = candidate
+ break
+ result = root.findtext("result")
+ timestamp = root.findtext("timestamp")
+ duration = root.findtext("duration")
+ return BuildEvidence(
+ job=job,
+ branch=branch,
+ number=number,
+ revision=revision,
+ result=result,
+ building=result is None,
+ timestamp_ms=int(timestamp) if timestamp else None,
+ duration_ms=int(duration) if duration else None,
+ log_tail=log_tail,
+ )
+
+
+def find_build(
+ job: str,
+ branch: str | None,
+ commit: str | None,
+ log_lines: int,
+) -> BuildEvidence | None:
+ """Return the newest build matching an optional exact/prefix revision."""
+ job_path = resolve_job_path(job, branch)
+ numbers = sorted(
+ (int(value) for value in _directories(f"{job_path}/builds") if value.isdigit()),
+ reverse=True,
+ )
+ for number in numbers:
+ build_path = f"{job_path}/builds/{number}"
+ try:
+ evidence = parse_build_xml(
+ _read(f"{build_path}/build.xml"),
+ job=job,
+ branch=branch,
+ number=number,
+ )
+ except (ET.ParseError, subprocess.SubprocessError):
+ continue
+ if commit and not (evidence.revision or "").startswith(commit):
+ continue
+ log_tail = _kubectl("tail", "-n", str(log_lines), f"{build_path}/log")
+ return BuildEvidence(**{**asdict(evidence), "log_tail": log_tail})
+ return None
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ """Parse the bounded Jenkins evidence request."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("job")
+ parser.add_argument("--branch")
+ parser.add_argument("--commit", help="full or unambiguous leading commit SHA")
+ parser.add_argument("--wait", action="store_true", help="wait for a terminal result")
+ parser.add_argument("--timeout", type=int, default=900)
+ parser.add_argument("--poll", type=int, default=10)
+ parser.add_argument("--log-lines", type=int, default=40)
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Print one JSON evidence object, optionally waiting until it is terminal."""
+ args = parse_args(argv)
+ if args.timeout < 1 or args.poll < 1 or not 0 <= args.log_lines <= 200:
+ print("timeout/poll must be positive and log-lines must be 0..200", file=sys.stderr)
+ return 2
+ deadline = time.monotonic() + args.timeout
+ try:
+ while True:
+ evidence = find_build(args.job, args.branch, args.commit, args.log_lines)
+ if evidence and (not args.wait or not evidence.building):
+ print(json.dumps(asdict(evidence), indent=2, sort_keys=True))
+ return 0
+ if not args.wait or time.monotonic() >= deadline:
+ value = asdict(evidence) if evidence else None
+ print(json.dumps({"timed_out": args.wait, "evidence": value}, indent=2))
+ return 2 if args.wait else 1
+ time.sleep(args.poll)
+ except (OSError, ValueError, subprocess.SubprocessError, ET.ParseError) as exc:
+ print(f"Jenkins evidence read failed: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py
index 01cd33ea..f4a9a64f 100644
--- a/testing/tests/test_hermes_chat_quality.py
+++ b/testing/tests/test_hermes_chat_quality.py
@@ -99,6 +99,14 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
assert "Never call `kanban_show` without\na known, non-empty task ID" in soul
assert "must load a skill only when its workflow\nmaterially applies" in soul
assert "runtime-only `GIT_ASKPASS`" in soul
+ assert "`scm.bstein.dev` is Gitea, not GitHub" in instructions
+ assert "Never load or follow a GitHub/`gh`" in instructions
+ assert "/opt/coordinator/gitea_api.py METHOD /api/v1/..." in instructions
+ assert "JENKINS_BASE_URL" in instructions
+ assert "Do not\nuse `git reset --hard`" in instructions
+ rendered = (HERMES / "agent-deployment.yaml").read_text()
+ assert 'git config --global user.name "Hermes Agent"' in rendered
+ assert 'git config --global user.email "hermes@bstein.dev"' in rendered
def test_agent_image_completes_parked_kanban_tasks_atomically():
diff --git a/testing/tests/test_hermes_runtime_access.py b/testing/tests/test_hermes_runtime_access.py
index ff1e9c6e..d7be46ff 100644
--- a/testing/tests/test_hermes_runtime_access.py
+++ b/testing/tests/test_hermes_runtime_access.py
@@ -5,8 +5,10 @@ from __future__ import annotations
import importlib.util
import json
import sys
+import urllib.request
from pathlib import Path
+import pytest
import yaml
@@ -24,6 +26,90 @@ def _load(name: str):
return module
+def test_gitea_api_builds_runtime_authenticated_same_origin_requests():
+ gitea_api = _load("gitea_api")
+
+ request = gitea_api.build_request(
+ "POST",
+ "/api/v1/repos/atlas/cassandra/issues/1/comments",
+ base_url="https://scm.bstein.dev",
+ token="runtime-only-value",
+ data={"body": "reviewed"},
+ )
+
+ assert isinstance(request, urllib.request.Request)
+ assert request.full_url == (
+ "https://scm.bstein.dev/api/v1/repos/atlas/cassandra/issues/1/comments"
+ )
+ assert request.method == "POST"
+ assert request.get_header("Authorization") == "token runtime-only-value"
+ assert json.loads(request.data) == {"body": "reviewed"}
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "https://evil.example/api/v1/repos/atlas/cassandra",
+ "/repos/atlas/cassandra",
+ "/api/v1/repos/atlas/cassandra#fragment",
+ ],
+)
+def test_gitea_api_rejects_foreign_or_non_api_targets(path: str):
+ gitea_api = _load("gitea_api")
+
+ with pytest.raises(ValueError):
+ gitea_api.api_url("https://scm.bstein.dev", path)
+
+
+def test_jenkins_evidence_ignores_nested_execution_result():
+ evidence_reader = _load("jenkins_build_evidence")
+ evidence = evidence_reader.parse_build_xml(
+ """
+
+ abc123
+ 10000
+ SUCCESS
+
+ """,
+ job="demo",
+ branch="master",
+ number=15,
+ )
+
+ assert evidence.revision == "abc123"
+ assert evidence.result is None
+ assert evidence.building is True
+
+
+def test_jenkins_evidence_reads_only_top_level_terminal_result():
+ evidence_reader = _load("jenkins_build_evidence")
+ evidence = evidence_reader.parse_build_xml(
+ """
+
+ abc123
+ 1000250
+ FAILURE
+ SUCCESS
+
+ """,
+ job="demo",
+ branch=None,
+ number=4,
+ )
+
+ assert evidence.result == "FAILURE"
+ assert evidence.building is False
+ assert evidence.duration_ms == 250
+
+
+@pytest.mark.parametrize("branch", ["../master", "/master", "feature//unsafe"])
+def test_jenkins_evidence_rejects_unsafe_branch_paths(branch: str):
+ evidence_reader = _load("jenkins_build_evidence")
+
+ with pytest.raises(ValueError):
+ evidence_reader._validate_branch(branch)
+
+
def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeypatch):
stage = _load("stage_runtime_access")
vault = tmp_path / "vault"
@@ -232,6 +318,9 @@ def test_manifests_never_seed_access_material_into_persistent_env():
askpass = (SCRIPTS / "gitea_askpass.sh").read_text(encoding="utf-8")
assert "/runtime-access/gitea-token" in askpass
assert "GITEA_TOKEN" not in askpass
+ gitea_api = (SCRIPTS / "gitea_api.py").read_text(encoding="utf-8")
+ assert "/runtime-access/gitea-token" in gitea_api
+ assert "GITEA_TOKEN" not in gitea_api
def test_chat_media_reads_one_raw_runtime_secret(tmp_path: Path):