hermes: harden Atlas forge workflow
This commit is contained in:
parent
f35e89777b
commit
c7ac16d206
@ -315,6 +315,33 @@ data:
|
|||||||
real branch only when the requested implementation is review-ready. Never
|
real branch only when the requested implementation is review-ready. Never
|
||||||
force-push.
|
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
|
The terminal PATH contains the pinned operator tools. Start cluster work
|
||||||
with `kubectl config current-context`, read-only status/events/logs, and the
|
with `kubectl config current-context`, read-only status/events/logs, and the
|
||||||
relevant `titan-iac` manifests. Put durable desired-state changes on a
|
relevant `titan-iac` manifests. Put durable desired-state changes on a
|
||||||
|
|||||||
@ -25,7 +25,7 @@ spec:
|
|||||||
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
|
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/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/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/scrape: "true"
|
||||||
prometheus.io/path: /metrics
|
prometheus.io/path: /metrics
|
||||||
prometheus.io/port: "9010"
|
prometheus.io/port: "9010"
|
||||||
@ -187,6 +187,11 @@ spec:
|
|||||||
done
|
done
|
||||||
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
|
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
|
||||||
upsert_env GIT_TERMINAL_PROMPT 0
|
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}"
|
chmod 0600 "${env_file}"
|
||||||
touch "${profile_file}"
|
touch "${profile_file}"
|
||||||
if ! grep -qxF '# Hermes managed operator PATH.' "${profile_file}"; then
|
if ! grep -qxF '# Hermes managed operator PATH.' "${profile_file}"; then
|
||||||
@ -294,6 +299,8 @@ spec:
|
|||||||
mv "${tools}/bin/kubectl.tmp" "${tools}/bin/kubectl"
|
mv "${tools}/bin/kubectl.tmp" "${tools}/bin/kubectl"
|
||||||
fi
|
fi
|
||||||
/bin/sh /opt/coordinator/install_agent_tools.sh
|
/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:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
runAsUser: 10000
|
runAsUser: 10000
|
||||||
|
|||||||
@ -68,12 +68,14 @@ configMapGenerator:
|
|||||||
- classifier_broker.py=scripts/classifier_broker.py
|
- classifier_broker.py=scripts/classifier_broker.py
|
||||||
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
|
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
|
||||||
- worker_route_broker.py=scripts/worker_route_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
|
- gitea_askpass.sh=scripts/gitea_askpass.sh
|
||||||
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
||||||
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
||||||
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
||||||
- image_broker.py=scripts/image_broker.py
|
- image_broker.py=scripts/image_broker.py
|
||||||
- install_agent_tools.sh=scripts/install_agent_tools.sh
|
- 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
|
- kanban_status_recovery.py=scripts/kanban_status_recovery.py
|
||||||
- migrate_herdr_state.py=scripts/migrate_herdr_state.py
|
- migrate_herdr_state.py=scripts/migrate_herdr_state.py
|
||||||
- migrate_api_session_lineage.py=scripts/migrate_api_session_lineage.py
|
- migrate_api_session_lineage.py=scripts/migrate_api_session_lineage.py
|
||||||
|
|||||||
125
services/hermes/scripts/gitea_api.py
Normal file
125
services/hermes/scripts/gitea_api.py
Normal file
@ -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())
|
||||||
213
services/hermes/scripts/jenkins_build_evidence.py
Normal file
213
services/hermes/scripts/jenkins_build_evidence.py
Normal file
@ -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())
|
||||||
@ -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 "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 "must load a skill only when its workflow\nmaterially applies" in soul
|
||||||
assert "runtime-only `GIT_ASKPASS`" 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():
|
def test_agent_image_completes_parked_kanban_tasks_atomically():
|
||||||
|
|||||||
@ -5,8 +5,10 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
@ -24,6 +26,90 @@ def _load(name: str):
|
|||||||
return module
|
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(
|
||||||
|
"""
|
||||||
|
<flow-build>
|
||||||
|
<actions><revision><hash>abc123</hash></revision></actions>
|
||||||
|
<timestamp>1000</timestamp><duration>0</duration>
|
||||||
|
<execution><result>SUCCESS</result></execution>
|
||||||
|
</flow-build>
|
||||||
|
""",
|
||||||
|
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(
|
||||||
|
"""
|
||||||
|
<flow-build>
|
||||||
|
<actions><revision><hash>abc123</hash></revision></actions>
|
||||||
|
<timestamp>1000</timestamp><duration>250</duration>
|
||||||
|
<result>FAILURE</result>
|
||||||
|
<execution><result>SUCCESS</result></execution>
|
||||||
|
</flow-build>
|
||||||
|
""",
|
||||||
|
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):
|
def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeypatch):
|
||||||
stage = _load("stage_runtime_access")
|
stage = _load("stage_runtime_access")
|
||||||
vault = tmp_path / "vault"
|
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")
|
askpass = (SCRIPTS / "gitea_askpass.sh").read_text(encoding="utf-8")
|
||||||
assert "/runtime-access/gitea-token" in askpass
|
assert "/runtime-access/gitea-token" in askpass
|
||||||
assert "GITEA_TOKEN" not 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):
|
def test_chat_media_reads_one_raw_runtime_secret(tmp_path: Path):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user