Compare commits

..

3 Commits

Author SHA1 Message Date
Alexander Whitestone
6bbf6c4e0e ci: fix smoke JSON parsing for backup tests (#693)
Some checks failed
Smoke Test / smoke (pull_request) Failing after 10m42s
2026-04-14 23:59:46 -04:00
Alexander Whitestone
6fbdbcf1c1 docs: add Hermes backup runbook (#693) 2026-04-14 23:57:41 -04:00
Alexander Whitestone
f8a9bae8fb feat: add encrypted Hermes backup pipeline (#693) 2026-04-14 23:56:46 -04:00
10 changed files with 458 additions and 448 deletions

View File

@@ -1,97 +0,0 @@
name: Agent PR Gate
'on':
pull_request:
branches: [main]
jobs:
gate:
runs-on: ubuntu-latest
outputs:
syntax_status: ${{ steps.syntax.outcome }}
tests_status: ${{ steps.tests.outcome }}
criteria_status: ${{ steps.criteria.outcome }}
risk_level: ${{ steps.risk.outputs.level }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install CI dependencies
run: |
python3 -m pip install --quiet pyyaml pytest
- id: risk
name: Classify PR risk
run: |
BASE_REF="${GITHUB_BASE_REF:-main}"
git fetch origin "$BASE_REF" --depth 1
git diff --name-only "origin/$BASE_REF"...HEAD > /tmp/changed_files.txt
python3 scripts/agent_pr_gate.py classify-risk --files-file /tmp/changed_files.txt > /tmp/risk.json
python3 - <<'PY'
import json, os
with open('/tmp/risk.json', 'r', encoding='utf-8') as fh:
data = json.load(fh)
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh:
fh.write('level=' + data['risk'] + '\n')
PY
- id: syntax
name: Syntax and parse checks
continue-on-error: true
run: |
find . \( -name '*.yml' -o -name '*.yaml' \) | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' | while read f; do python3 -m json.tool "$f" > /dev/null || exit 1; done
find . -name '*.py' | xargs -r python3 -m py_compile
find . -name '*.sh' | xargs -r bash -n
- id: tests
name: Test suite
continue-on-error: true
run: |
pytest -q --ignore=uni-wizard/v2/tests/test_author_whitelist.py
- id: criteria
name: PR criteria verification
continue-on-error: true
run: |
python3 scripts/agent_pr_gate.py validate-pr --event-path "$GITHUB_EVENT_PATH"
- name: Fail gate if any required check failed
if: steps.syntax.outcome != 'success' || steps.tests.outcome != 'success' || steps.criteria.outcome != 'success'
run: exit 1
report:
needs: gate
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Post PR gate report
env:
GITEA_TOKEN: ${{ github.token }}
run: |
python3 scripts/agent_pr_gate.py comment \
--event-path "$GITHUB_EVENT_PATH" \
--token "$GITEA_TOKEN" \
--syntax "${{ needs.gate.outputs.syntax_status }}" \
--tests "${{ needs.gate.outputs.tests_status }}" \
--criteria "${{ needs.gate.outputs.criteria_status }}" \
--risk "${{ needs.gate.outputs.risk_level }}"
- name: Auto-merge low-risk clean PRs
if: needs.gate.result == 'success' && needs.gate.outputs.risk_level == 'low'
env:
GITEA_TOKEN: ${{ github.token }}
run: |
python3 scripts/agent_pr_gate.py merge \
--event-path "$GITHUB_EVENT_PATH" \
--token "$GITEA_TOKEN"

View File

@@ -1,5 +1,5 @@
name: Smoke Test
'on':
on:
pull_request:
push:
branches: [main]
@@ -11,13 +11,10 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install parse dependencies
run: |
python3 -m pip install --quiet pyyaml
- name: Parse check
run: |
find . \( -name '*.yml' -o -name '*.yaml' \) | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' | while read f; do python3 -m json.tool "$f" > /dev/null || exit 1; done
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' -print0 | xargs -0 -r -n1 python3 -m json.tool > /dev/null
find . -name '*.py' | xargs -r python3 -m py_compile
find . -name '*.sh' | xargs -r bash -n
echo "PASS: All files parse"
@@ -25,3 +22,6 @@ jobs:
run: |
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v '.gitea' | grep -v 'detect_secrets' | grep -v 'test_trajectory_sanitize'; then exit 1; fi
echo "PASS: No secrets"
- name: Backup pipeline regression test
run: |
python3 -m unittest discover -s tests -p 'test_backup_pipeline.py' -v

98
docs/BACKUP_PIPELINE.md Normal file
View File

@@ -0,0 +1,98 @@
# Encrypted Hermes Backup Pipeline
Issue: `timmy-home#693`
This pipeline creates a nightly encrypted archive of `~/.hermes`, stores a local encrypted copy, uploads it to remote storage, and supports restore verification.
## What gets backed up
By default the pipeline archives:
- `~/.hermes/config.yaml`
- `~/.hermes/state.db`
- `~/.hermes/sessions/`
- `~/.hermes/cron/`
- any other files under `~/.hermes`
Override the source with `BACKUP_SOURCE_DIR=/path/to/.hermes`.
## Backup command
```bash
BACKUP_PASSPHRASE_FILE=~/.config/timmy/backup.passphrase \
BACKUP_NAS_TARGET=/Volumes/timmy-nas/hermes-backups \
bash scripts/backup_pipeline.sh
```
The script writes:
- local encrypted copy: `~/.timmy-backups/hermes/<timestamp>/hermes-backup-<timestamp>.tar.gz.enc`
- local manifest: `~/.timmy-backups/hermes/<timestamp>/hermes-backup-<timestamp>.json`
- log file: `~/.timmy-backups/hermes/logs/backup_pipeline.log`
## Nightly schedule
Run every night at 03:00:
```cron
0 3 * * * cd /Users/apayne/.timmy/timmy-home && BACKUP_PASSPHRASE_FILE=/Users/apayne/.config/timmy/backup.passphrase BACKUP_NAS_TARGET=/Volumes/timmy-nas/hermes-backups bash scripts/backup_pipeline.sh >> /Users/apayne/.timmy-backups/hermes/logs/cron.log 2>&1
```
## Remote targets
At least one remote target must be configured.
### Local NAS
Use a mounted path:
```bash
BACKUP_NAS_TARGET=/Volumes/timmy-nas/hermes-backups
```
The pipeline copies the encrypted archive and manifest into `<BACKUP_NAS_TARGET>/<timestamp>/`.
### S3-compatible storage
```bash
BACKUP_PASSPHRASE_FILE=~/.config/timmy/backup.passphrase \
BACKUP_S3_URI=s3://timmy-backups/hermes \
AWS_ENDPOINT_URL=https://minio.example.com \
bash scripts/backup_pipeline.sh
```
Notes:
- `aws` CLI must be installed if `BACKUP_S3_URI` is set.
- `AWS_ENDPOINT_URL` is optional and is used for MinIO, R2, and other S3-compatible endpoints.
## Restore playbook
Restore an encrypted archive into a clean target root:
```bash
BACKUP_PASSPHRASE_FILE=~/.config/timmy/backup.passphrase \
bash scripts/restore_backup.sh \
/Volumes/timmy-nas/hermes-backups/20260415-030000/hermes-backup-20260415-030000.tar.gz.enc \
/tmp/hermes-restore
```
Result:
- restored tree lands at `/tmp/hermes-restore/.hermes`
- if a sibling manifest exists, the restore script verifies the archive SHA256 before decrypting
## End-to-end verification
Run the regression suite:
```bash
python3 -m unittest discover -s tests -p 'test_backup_pipeline.py' -v
```
This proves:
1. the backup output is encrypted
2. plaintext archives do not leak into the backup destinations
3. the restore script recreates the original `.hermes` tree end-to-end
4. the pipeline refuses to run without a remote target

View File

@@ -12,6 +12,8 @@ Quick-reference index for common operational tasks across the Timmy Foundation i
| Check fleet health | fleet-ops | `python3 scripts/fleet_readiness.py` |
| Agent scorecard | fleet-ops | `python3 scripts/agent_scorecard.py` |
| View fleet manifest | fleet-ops | `cat manifest.yaml` |
| Backup Hermes state | timmy-home | `BACKUP_PASSPHRASE_FILE=... BACKUP_NAS_TARGET=... bash scripts/backup_pipeline.sh` |
| Restore Hermes state | timmy-home | `BACKUP_PASSPHRASE_FILE=... bash scripts/restore_backup.sh <archive> <restore-root>` |
## the-nexus (Frontend + Brain)

View File

@@ -1,191 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
LOW_RISK_PREFIXES = (
'docs/', 'reports/', 'notes/', 'tickets/', 'research/', 'briefings/',
'twitter-archive/notes/', 'tests/'
)
LOW_RISK_SUFFIXES = {'.md', '.txt', '.jsonl'}
MEDIUM_RISK_PREFIXES = ('.gitea/workflows/',)
HIGH_RISK_PREFIXES = (
'scripts/', 'deploy/', 'infrastructure/', 'metrics/', 'heartbeat/',
'wizards/', 'evennia/', 'uniwizard/', 'uni-wizard/', 'timmy-local/',
'evolution/'
)
HIGH_RISK_SUFFIXES = {'.py', '.sh', '.ini', '.service'}
def read_changed_files(path):
return [line.strip() for line in Path(path).read_text(encoding='utf-8').splitlines() if line.strip()]
def classify_risk(files):
if not files:
return 'high'
level = 'low'
for file_path in files:
path = file_path.strip()
suffix = Path(path).suffix.lower()
if path.startswith(LOW_RISK_PREFIXES):
continue
if path.startswith(HIGH_RISK_PREFIXES) or suffix in HIGH_RISK_SUFFIXES:
return 'high'
if path.startswith(MEDIUM_RISK_PREFIXES):
level = 'medium'
continue
if path.startswith(LOW_RISK_PREFIXES) or suffix in LOW_RISK_SUFFIXES:
continue
level = 'high'
return level
def validate_pr_body(title, body):
details = []
combined = f"{title}\n{body}".strip()
if not re.search(r'#\d+', combined):
details.append('PR body/title must include an issue reference like #562.')
if not re.search(r'(^|\n)\s*(verification|tests?)\s*:', body, re.IGNORECASE):
details.append('PR body must include a Verification: section.')
return (len(details) == 0, details)
def build_comment_body(syntax_status, tests_status, criteria_status, risk_level):
statuses = {
'syntax': syntax_status,
'tests': tests_status,
'criteria': criteria_status,
}
all_clean = all(value == 'success' for value in statuses.values())
action = 'auto-merge' if all_clean and risk_level == 'low' else 'human review'
lines = [
'## Agent PR Gate',
'',
'| Check | Status |',
'|-------|--------|',
f"| Syntax / parse | {syntax_status} |",
f"| Test suite | {tests_status} |",
f"| PR criteria | {criteria_status} |",
f"| Risk level | {risk_level} |",
'',
]
failed = [name for name, value in statuses.items() if value != 'success']
if failed:
lines.append('### Failure details')
for name in failed:
lines.append(f'- {name} reported failure. Inspect the workflow logs for that step.')
else:
lines.append('All automated checks passed.')
lines.extend([
'',
f'Recommendation: {action}.',
'Low-risk documentation/test-only PRs may be auto-merged. Operational changes stay in human review.',
])
return '\n'.join(lines)
def _read_event(event_path):
data = json.loads(Path(event_path).read_text(encoding='utf-8'))
pr = data.get('pull_request') or {}
repo = (data.get('repository') or {}).get('full_name') or os.environ.get('GITHUB_REPOSITORY')
pr_number = pr.get('number') or data.get('number')
title = pr.get('title') or ''
body = pr.get('body') or ''
return repo, pr_number, title, body
def _request_json(method, url, token, payload=None):
data = None if payload is None else json.dumps(payload).encode('utf-8')
headers = {'Authorization': f'token {token}', 'Content-Type': 'application/json'}
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode('utf-8'))
def post_comment(repo, pr_number, token, body):
url = f'{API_BASE}/repos/{repo}/issues/{pr_number}/comments'
return _request_json('POST', url, token, {'body': body})
def merge_pr(repo, pr_number, token):
url = f'{API_BASE}/repos/{repo}/pulls/{pr_number}/merge'
return _request_json('POST', url, token, {'Do': 'merge'})
def cmd_classify_risk(args):
files = list(args.files or [])
if args.files_file:
files.extend(read_changed_files(args.files_file))
print(json.dumps({'risk': classify_risk(files), 'files': files}, indent=2))
return 0
def cmd_validate_pr(args):
_, _, title, body = _read_event(args.event_path)
ok, details = validate_pr_body(title, body)
if ok:
print('PR body validation passed.')
return 0
for detail in details:
print(detail)
return 1
def cmd_comment(args):
repo, pr_number, _, _ = _read_event(args.event_path)
body = build_comment_body(args.syntax, args.tests, args.criteria, args.risk)
post_comment(repo, pr_number, args.token, body)
print(f'Commented on PR #{pr_number} in {repo}.')
return 0
def cmd_merge(args):
repo, pr_number, _, _ = _read_event(args.event_path)
merge_pr(repo, pr_number, args.token)
print(f'Merged PR #{pr_number} in {repo}.')
return 0
def build_parser():
parser = argparse.ArgumentParser(description='Agent PR CI helpers for timmy-home.')
sub = parser.add_subparsers(dest='command', required=True)
classify = sub.add_parser('classify-risk')
classify.add_argument('--files-file')
classify.add_argument('files', nargs='*')
classify.set_defaults(func=cmd_classify_risk)
validate = sub.add_parser('validate-pr')
validate.add_argument('--event-path', required=True)
validate.set_defaults(func=cmd_validate_pr)
comment = sub.add_parser('comment')
comment.add_argument('--event-path', required=True)
comment.add_argument('--token', required=True)
comment.add_argument('--syntax', required=True)
comment.add_argument('--tests', required=True)
comment.add_argument('--criteria', required=True)
comment.add_argument('--risk', required=True)
comment.set_defaults(func=cmd_comment)
merge = sub.add_parser('merge')
merge.add_argument('--event-path', required=True)
merge.add_argument('--token', required=True)
merge.set_defaults(func=cmd_merge)
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,80 +1,170 @@
#!/usr/bin/env bash
# backup_pipeline.sh — Daily fleet backup pipeline (FLEET-008)
# Refs: timmy-home #561
# backup_pipeline.sh — Nightly encrypted Hermes backup pipeline
# Refs: timmy-home #693, timmy-home #561
set -euo pipefail
BACKUP_ROOT="/backups/timmy"
DATESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR="${BACKUP_ROOT}/${DATESTAMP}"
LOG_DIR="/var/log/timmy"
ALERT_LOG="${LOG_DIR}/backup_pipeline.log"
mkdir -p "$BACKUP_DIR" "$LOG_DIR"
DATESTAMP="${BACKUP_TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}"
BACKUP_SOURCE_DIR="${BACKUP_SOURCE_DIR:-${HOME}/.hermes}"
BACKUP_ROOT="${BACKUP_ROOT:-${HOME}/.timmy-backups/hermes}"
BACKUP_LOG_DIR="${BACKUP_LOG_DIR:-${BACKUP_ROOT}/logs}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}"
BACKUP_S3_URI="${BACKUP_S3_URI:-}"
BACKUP_NAS_TARGET="${BACKUP_NAS_TARGET:-}"
AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-}"
BACKUP_NAME="hermes-backup-${DATESTAMP}"
LOCAL_BACKUP_DIR="${BACKUP_ROOT}/${DATESTAMP}"
STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/timmy-backup.XXXXXX")"
PLAINTEXT_ARCHIVE="${STAGE_DIR}/${BACKUP_NAME}.tar.gz"
ENCRYPTED_ARCHIVE="${STAGE_DIR}/${BACKUP_NAME}.tar.gz.enc"
MANIFEST_PATH="${STAGE_DIR}/${BACKUP_NAME}.json"
ALERT_LOG="${BACKUP_LOG_DIR}/backup_pipeline.log"
PASSFILE_CLEANUP=""
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
OFFSITE_TARGET="${OFFSITE_TARGET:-}"
mkdir -p "$BACKUP_LOG_DIR"
log() { echo "[$(date -Iseconds)] $1" | tee -a "$ALERT_LOG"; }
log() {
echo "[$(date -Iseconds)] $1" | tee -a "$ALERT_LOG"
}
send_telegram() {
local msg="$1"
if [[ -n "$TELEGRAM_BOT_TOKEN" && -n "$TELEGRAM_CHAT_ID" ]]; then
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" -d "text=${msg}" >/dev/null 2>&1 || true
fail() {
log "ERROR: $1"
exit 1
}
cleanup() {
rm -f "$PLAINTEXT_ARCHIVE"
rm -rf "$STAGE_DIR"
if [[ -n "$PASSFILE_CLEANUP" && -f "$PASSFILE_CLEANUP" ]]; then
rm -f "$PASSFILE_CLEANUP"
fi
}
trap cleanup EXIT
resolve_passphrase_file() {
if [[ -n "${BACKUP_PASSPHRASE_FILE:-}" ]]; then
[[ -f "$BACKUP_PASSPHRASE_FILE" ]] || fail "BACKUP_PASSPHRASE_FILE does not exist: $BACKUP_PASSPHRASE_FILE"
echo "$BACKUP_PASSPHRASE_FILE"
return
fi
if [[ -n "${BACKUP_PASSPHRASE:-}" ]]; then
PASSFILE_CLEANUP="${STAGE_DIR}/backup.passphrase"
printf '%s' "$BACKUP_PASSPHRASE" > "$PASSFILE_CLEANUP"
chmod 600 "$PASSFILE_CLEANUP"
echo "$PASSFILE_CLEANUP"
return
fi
fail "Set BACKUP_PASSPHRASE_FILE or BACKUP_PASSPHRASE before running the backup pipeline."
}
sha256_file() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
else
python3 - <<'PY' "$path"
import hashlib
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
h = hashlib.sha256()
with path.open('rb') as f:
for chunk in iter(lambda: f.read(1024 * 1024), b''):
h.update(chunk)
print(h.hexdigest())
PY
fi
}
status=0
write_manifest() {
python3 - <<'PY' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8"
import json
import sys
manifest_path, source_dir, archive_name, archive_sha256, local_dir, s3_uri, nas_target, created_at = sys.argv[1:]
manifest = {
"created_at": created_at,
"source_dir": source_dir,
"archive_name": archive_name,
"archive_sha256": archive_sha256,
"encryption": {
"type": "openssl",
"cipher": "aes-256-cbc",
"pbkdf2": True,
"iterations": 200000,
},
"destinations": {
"local_dir": local_dir,
"s3_uri": s3_uri or None,
"nas_target": nas_target or None,
},
}
with open(manifest_path, 'w', encoding='utf-8') as handle:
json.dump(manifest, handle, indent=2)
handle.write('\n')
PY
}
# --- Gitea repositories ---
if [[ -d /root/gitea ]]; then
tar czf "${BACKUP_DIR}/gitea-repos.tar.gz" -C /root gitea 2>/dev/null || true
log "Backed up Gitea repos"
fi
upload_to_nas() {
local archive_path="$1"
local manifest_path="$2"
local target_root="$3"
# --- Agent configs and state ---
for wiz in bezalel allegro ezra timmy; do
if [[ -d "/root/wizards/${wiz}" ]]; then
tar czf "${BACKUP_DIR}/${wiz}-home.tar.gz" -C /root/wizards "${wiz}" 2>/dev/null || true
log "Backed up ${wiz} home"
local target_dir="${target_root%/}/${DATESTAMP}"
mkdir -p "$target_dir"
cp "$archive_path" "$manifest_path" "$target_dir/"
log "Uploaded backup to NAS target: $target_dir"
}
upload_to_s3() {
local archive_path="$1"
local manifest_path="$2"
command -v aws >/dev/null 2>&1 || fail "BACKUP_S3_URI is set but aws CLI is not installed."
local args=()
if [[ -n "$AWS_ENDPOINT_URL" ]]; then
args+=(--endpoint-url "$AWS_ENDPOINT_URL")
fi
done
# --- System configs ---
cp /etc/crontab "${BACKUP_DIR}/crontab" 2>/dev/null || true
cp -r /etc/systemd/system "${BACKUP_DIR}/systemd" 2>/dev/null || true
log "Backed up system configs"
aws "${args[@]}" s3 cp "$archive_path" "${BACKUP_S3_URI%/}/$(basename "$archive_path")"
aws "${args[@]}" s3 cp "$manifest_path" "${BACKUP_S3_URI%/}/$(basename "$manifest_path")"
log "Uploaded backup to S3 target: $BACKUP_S3_URI"
}
# --- Evennia worlds (if present) ---
if [[ -d /root/evennia ]]; then
tar czf "${BACKUP_DIR}/evennia-worlds.tar.gz" -C /root evennia 2>/dev/null || true
log "Backed up Evennia worlds"
[[ -d "$BACKUP_SOURCE_DIR" ]] || fail "BACKUP_SOURCE_DIR does not exist: $BACKUP_SOURCE_DIR"
[[ -n "$BACKUP_NAS_TARGET" || -n "$BACKUP_S3_URI" ]] || fail "Set BACKUP_NAS_TARGET or BACKUP_S3_URI for remote backup storage."
PASSFILE="$(resolve_passphrase_file)"
mkdir -p "$LOCAL_BACKUP_DIR"
log "Creating archive from $BACKUP_SOURCE_DIR"
tar -czf "$PLAINTEXT_ARCHIVE" -C "$(dirname "$BACKUP_SOURCE_DIR")" "$(basename "$BACKUP_SOURCE_DIR")"
log "Encrypting archive"
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
-pass "file:${PASSFILE}" \
-in "$PLAINTEXT_ARCHIVE" \
-out "$ENCRYPTED_ARCHIVE"
ARCHIVE_SHA256="$(sha256_file "$ENCRYPTED_ARCHIVE")"
CREATED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
write_manifest "$MANIFEST_PATH" "$BACKUP_SOURCE_DIR" "$(basename "$ENCRYPTED_ARCHIVE")" "$ARCHIVE_SHA256" "$LOCAL_BACKUP_DIR" "$BACKUP_S3_URI" "$BACKUP_NAS_TARGET" "$CREATED_AT"
cp "$ENCRYPTED_ARCHIVE" "$MANIFEST_PATH" "$LOCAL_BACKUP_DIR/"
rm -f "$PLAINTEXT_ARCHIVE"
log "Encrypted backup stored locally: ${LOCAL_BACKUP_DIR}/$(basename "$ENCRYPTED_ARCHIVE")"
if [[ -n "$BACKUP_NAS_TARGET" ]]; then
upload_to_nas "$ENCRYPTED_ARCHIVE" "$MANIFEST_PATH" "$BACKUP_NAS_TARGET"
fi
# --- Manifest ---
find "$BACKUP_DIR" -type f > "${BACKUP_DIR}/manifest.txt"
log "Backup manifest written"
# --- Offsite sync ---
if [[ -n "$OFFSITE_TARGET" ]]; then
if rsync -az --delete "${BACKUP_DIR}/" "${OFFSITE_TARGET}/${DATESTAMP}/" 2>/dev/null; then
log "Offsite sync completed"
else
log "WARNING: Offsite sync failed"
status=1
fi
if [[ -n "$BACKUP_S3_URI" ]]; then
upload_to_s3 "$ENCRYPTED_ARCHIVE" "$MANIFEST_PATH"
fi
# --- Retention: keep last 7 days ---
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
log "Retention applied (7 days)"
if [[ "$status" -eq 0 ]]; then
log "Backup pipeline completed: ${BACKUP_DIR}"
send_telegram "✅ Daily backup completed: ${DATESTAMP}"
else
log "Backup pipeline completed with WARNINGS: ${BACKUP_DIR}"
send_telegram "⚠️ Daily backup completed with warnings: ${DATESTAMP}"
fi
exit "$status"
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name '20*' -mtime "+${BACKUP_RETENTION_DAYS}" -exec rm -rf {} + 2>/dev/null || true
log "Retention applied (${BACKUP_RETENTION_DAYS} days)"
log "Backup pipeline completed successfully"

97
scripts/restore_backup.sh Normal file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# restore_backup.sh — Restore an encrypted Hermes backup archive
# Usage: restore_backup.sh /path/to/hermes-backup-YYYYmmdd-HHMMSS.tar.gz.enc /restore/root
set -euo pipefail
ARCHIVE_PATH="${1:-}"
RESTORE_ROOT="${2:-}"
STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/timmy-restore.XXXXXX")"
PLAINTEXT_ARCHIVE="${STAGE_DIR}/restore.tar.gz"
PASSFILE_CLEANUP=""
cleanup() {
rm -f "$PLAINTEXT_ARCHIVE"
rm -rf "$STAGE_DIR"
if [[ -n "$PASSFILE_CLEANUP" && -f "$PASSFILE_CLEANUP" ]]; then
rm -f "$PASSFILE_CLEANUP"
fi
}
trap cleanup EXIT
fail() {
echo "ERROR: $1" >&2
exit 1
}
resolve_passphrase_file() {
if [[ -n "${BACKUP_PASSPHRASE_FILE:-}" ]]; then
[[ -f "$BACKUP_PASSPHRASE_FILE" ]] || fail "BACKUP_PASSPHRASE_FILE does not exist: $BACKUP_PASSPHRASE_FILE"
echo "$BACKUP_PASSPHRASE_FILE"
return
fi
if [[ -n "${BACKUP_PASSPHRASE:-}" ]]; then
PASSFILE_CLEANUP="${STAGE_DIR}/backup.passphrase"
printf '%s' "$BACKUP_PASSPHRASE" > "$PASSFILE_CLEANUP"
chmod 600 "$PASSFILE_CLEANUP"
echo "$PASSFILE_CLEANUP"
return
fi
fail "Set BACKUP_PASSPHRASE_FILE or BACKUP_PASSPHRASE before restoring a backup."
}
sha256_file() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
else
python3 - <<'PY' "$path"
import hashlib
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
h = hashlib.sha256()
with path.open('rb') as f:
for chunk in iter(lambda: f.read(1024 * 1024), b''):
h.update(chunk)
print(h.hexdigest())
PY
fi
}
[[ -n "$ARCHIVE_PATH" ]] || fail "Usage: restore_backup.sh /path/to/archive.tar.gz.enc /restore/root"
[[ -n "$RESTORE_ROOT" ]] || fail "Usage: restore_backup.sh /path/to/archive.tar.gz.enc /restore/root"
[[ -f "$ARCHIVE_PATH" ]] || fail "Archive not found: $ARCHIVE_PATH"
if [[ "$ARCHIVE_PATH" == *.tar.gz.enc ]]; then
MANIFEST_PATH="${ARCHIVE_PATH%.tar.gz.enc}.json"
else
MANIFEST_PATH=""
fi
if [[ -n "$MANIFEST_PATH" && -f "$MANIFEST_PATH" ]]; then
EXPECTED_SHA="$(python3 - <<'PY' "$MANIFEST_PATH"
import json
import sys
with open(sys.argv[1], 'r', encoding='utf-8') as handle:
manifest = json.load(handle)
print(manifest['archive_sha256'])
PY
)"
ACTUAL_SHA="$(sha256_file "$ARCHIVE_PATH")"
[[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || fail "Archive SHA256 mismatch: expected $EXPECTED_SHA got $ACTUAL_SHA"
fi
PASSFILE="$(resolve_passphrase_file)"
mkdir -p "$RESTORE_ROOT"
openssl enc -d -aes-256-cbc -salt -pbkdf2 -iter 200000 \
-pass "file:${PASSFILE}" \
-in "$ARCHIVE_PATH" \
-out "$PLAINTEXT_ARCHIVE"
tar -xzf "$PLAINTEXT_ARCHIVE" -C "$RESTORE_ROOT"
echo "Restored backup into $RESTORE_ROOT"

View File

@@ -1,68 +0,0 @@
import pathlib
import sys
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'scripts'))
import agent_pr_gate # noqa: E402
class TestAgentPrGate(unittest.TestCase):
def test_classify_risk_low_for_docs_and_tests_only(self):
level = agent_pr_gate.classify_risk([
'docs/runbook.md',
'reports/daily-summary.md',
'tests/test_agent_pr_gate.py',
])
self.assertEqual(level, 'low')
def test_classify_risk_high_for_operational_paths(self):
level = agent_pr_gate.classify_risk([
'scripts/failover_monitor.py',
'deploy/playbook.yml',
])
self.assertEqual(level, 'high')
def test_validate_pr_body_requires_issue_ref_and_verification(self):
ok, details = agent_pr_gate.validate_pr_body(
'feat: add thing',
'What changed only\n\nNo verification section here.'
)
self.assertFalse(ok)
self.assertIn('issue reference', ' '.join(details).lower())
self.assertIn('verification', ' '.join(details).lower())
def test_validate_pr_body_accepts_issue_ref_and_verification(self):
ok, details = agent_pr_gate.validate_pr_body(
'feat: add thing (#562)',
'Refs #562\n\nVerification:\n- pytest -q\n'
)
self.assertTrue(ok)
self.assertEqual(details, [])
def test_build_comment_body_reports_failures_and_human_review(self):
body = agent_pr_gate.build_comment_body(
syntax_status='success',
tests_status='failure',
criteria_status='success',
risk_level='high',
)
self.assertIn('tests', body.lower())
self.assertIn('failure', body.lower())
self.assertIn('human review', body.lower())
def test_changed_files_file_loader_ignores_blanks(self):
with tempfile.NamedTemporaryFile('w+', delete=False) as handle:
handle.write('docs/one.md\n\nreports/two.md\n')
path = handle.name
try:
files = agent_pr_gate.read_changed_files(path)
finally:
pathlib.Path(path).unlink(missing_ok=True)
self.assertEqual(files, ['docs/one.md', 'reports/two.md'])
if __name__ == '__main__':
unittest.main()

View File

@@ -1,24 +0,0 @@
import pathlib
import unittest
import yaml
ROOT = pathlib.Path(__file__).resolve().parents[1]
WORKFLOW = ROOT / '.gitea' / 'workflows' / 'agent-pr-gate.yml'
class TestAgentPrWorkflow(unittest.TestCase):
def test_workflow_exists(self):
self.assertTrue(WORKFLOW.exists(), 'agent-pr-gate workflow should exist')
def test_workflow_has_pr_gate_and_reporting_jobs(self):
data = yaml.safe_load(WORKFLOW.read_text(encoding='utf-8'))
self.assertIn('pull_request', data.get('on', {}))
jobs = data.get('jobs', {})
self.assertIn('gate', jobs)
self.assertIn('report', jobs)
report_steps = jobs['report']['steps']
self.assertTrue(any('Auto-merge low-risk clean PRs' in (step.get('name') or '') for step in report_steps))
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BACKUP_SCRIPT = ROOT / "scripts" / "backup_pipeline.sh"
RESTORE_SCRIPT = ROOT / "scripts" / "restore_backup.sh"
class TestBackupPipeline(unittest.TestCase):
def setUp(self) -> None:
self.tempdir = tempfile.TemporaryDirectory()
self.base = Path(self.tempdir.name)
self.home = self.base / "home"
self.source_dir = self.home / ".hermes"
self.source_dir.mkdir(parents=True)
(self.source_dir / "sessions").mkdir()
(self.source_dir / "cron").mkdir()
(self.source_dir / "config.yaml").write_text("model: local-first\n")
(self.source_dir / "sessions" / "session.jsonl").write_text('{"role":"assistant","content":"hello"}\n')
(self.source_dir / "cron" / "jobs.json").write_text('{"jobs": 1}\n')
(self.source_dir / "state.db").write_bytes(b"sqlite-state")
self.backup_root = self.base / "backup-root"
self.nas_target = self.base / "nas-target"
self.restore_root = self.base / "restore-root"
self.log_dir = self.base / "logs"
self.passphrase_file = self.base / "backup.passphrase"
self.passphrase_file.write_text("correct horse battery staple\n")
def tearDown(self) -> None:
self.tempdir.cleanup()
def _env(self, *, include_remote: bool = True) -> dict[str, str]:
env = os.environ.copy()
env.update(
{
"HOME": str(self.home),
"BACKUP_SOURCE_DIR": str(self.source_dir),
"BACKUP_ROOT": str(self.backup_root),
"BACKUP_LOG_DIR": str(self.log_dir),
"BACKUP_PASSPHRASE_FILE": str(self.passphrase_file),
}
)
if include_remote:
env["BACKUP_NAS_TARGET"] = str(self.nas_target)
return env
def test_backup_encrypts_and_restore_round_trips(self) -> None:
backup = subprocess.run(
["bash", str(BACKUP_SCRIPT)],
capture_output=True,
text=True,
env=self._env(),
cwd=ROOT,
)
self.assertEqual(backup.returncode, 0, msg=backup.stdout + backup.stderr)
encrypted_archives = sorted(self.nas_target.rglob("*.tar.gz.enc"))
self.assertEqual(len(encrypted_archives), 1, msg=f"expected one encrypted archive, found: {encrypted_archives}")
archive_path = encrypted_archives[0]
self.assertNotIn(b"model: local-first", archive_path.read_bytes())
manifests = sorted(self.nas_target.rglob("*.json"))
self.assertEqual(len(manifests), 1, msg=f"expected one manifest, found: {manifests}")
plaintext_archives = sorted(self.backup_root.rglob("*.tar.gz")) + sorted(self.nas_target.rglob("*.tar.gz"))
self.assertEqual(plaintext_archives, [], msg=f"plaintext archives leaked: {plaintext_archives}")
restore = subprocess.run(
["bash", str(RESTORE_SCRIPT), str(archive_path), str(self.restore_root)],
capture_output=True,
text=True,
env=self._env(),
cwd=ROOT,
)
self.assertEqual(restore.returncode, 0, msg=restore.stdout + restore.stderr)
restored_hermes = self.restore_root / ".hermes"
self.assertTrue(restored_hermes.exists())
self.assertEqual((restored_hermes / "config.yaml").read_text(), "model: local-first\n")
self.assertEqual((restored_hermes / "sessions" / "session.jsonl").read_text(), '{"role":"assistant","content":"hello"}\n')
self.assertEqual((restored_hermes / "cron" / "jobs.json").read_text(), '{"jobs": 1}\n')
self.assertEqual((restored_hermes / "state.db").read_bytes(), b"sqlite-state")
def test_backup_requires_remote_target(self) -> None:
backup = subprocess.run(
["bash", str(BACKUP_SCRIPT)],
capture_output=True,
text=True,
env=self._env(include_remote=False),
cwd=ROOT,
)
self.assertNotEqual(backup.returncode, 0)
self.assertIn("BACKUP_NAS_TARGET or BACKUP_S3_URI", backup.stdout + backup.stderr)
if __name__ == "__main__":
unittest.main(verbosity=2)