Compare commits

..

7 Commits

Author SHA1 Message Date
peptiderepo
f3f579f5a8 fix: QA remediation P1-D + P2 for infra/vps orchestrator (v1.8.0)
P1-D: bump package.json version 1.7.2 → 1.8.0 and add [1.8.0] CHANGELOG
entry documenting the infra/vps/ PRAB generation orchestrator addition.

P2 (security): replace StrictHostKeyChecking=no with StrictHostKeyChecking=accept-new
in all three SSH invocations in orchestrate-generation.sh. Add
UserKnownHostsFile=${KNOWN_HOSTS} (-o) to pin key verification to the
pre-seeded file rather than the root known_hosts.

install-orchestrator.sh: add ssh-keyscan step that pre-populates
/opt/prab-orchestrator/known_hosts with Hostinger's host key on first
install. Graceful degradation: if ssh-keyscan fails, logs WARN and
proceeds (accept-new will add the key on first real connect).

prab-generation.service: add justifying comment for User=root (root holds
the Hostinger SSH key + known_hosts in /root/.ssh/ and /opt/prab-orchestrator/).

Agent-Session: prab-reliability-remediation-20260622
2026-06-22 17:16:19 +00:00
peptiderepo
d922a8368f infra/vps: PRAB generation orchestrator (VPS-driven SSH, systemd timer)
Adds the VPS-side orchestrator for Option A (ADR 2026-06-22):
the Coolify KVM8 VPS (76.13.220.15) drives PRAutoBlogger generation
on prod synchronously over a held-open SSH session, escaping
Hostinger's ~10-min background-PHP-process kill.

Files:
  infra/vps/orchestrate-generation.sh
    - SSH to Hostinger; reaper sweep; wp prautoblogger generate --sync
    - Lock-aware: skips if run in-flight (plugin DB mutex)
    - Structured log lines (captured by systemd journal)
    - Failure alert: email to cto@peptiderepo.com via system mail
    - DRY_RUN=1 mode: echoes commands without executing generation
    - All config via env vars (SSH_KEY, SSH_HOST, etc.); no hardcoded secrets
    - SSH key referenced by PATH (/root/.ssh/hostinger_prod_ed25519)

  infra/vps/install-orchestrator.sh
    - Installs orchestrator to /opt/prab-orchestrator/
    - Installs systemd .service and .timer to /etc/systemd/system/
    - Does NOT enable or start timer (explicit post-QA cutover step)

  infra/vps/prab-generation.service
    - Type=oneshot, User=root, TimeoutStartSec=7200 (covers 32-min LLM calls)
    - Restart=no (timer retries next day on failure)

  infra/vps/prab-generation.timer
    - OnCalendar=*-*-* 06:00:00 UTC, Persistent=true, RandomizedDelaySec=300

DO NOT install or enable on the VPS this round — QA-gated, hold for CTO review.

Agent-Session: prab-reliability-20260622
2026-06-22 16:33:55 +00:00
peptiderepo
988ff07ff0 fix(deploy-health): use canonical peptide-repo org owner (#26)
Change const owner = 'peptiderepo' → 'peptide-repo' in deploy-health.yml.
This fixes the GitHub API owner ref after the user→org migration so the
monitor correctly queries repos under the peptide-repo org.

All uses:/repository: refs across the estate were already updated at migration
time — this was the sole remaining owner slug ref.

Agent-Session: claude/org-ref-fix-20260617

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-06-17 14:00:53 +07:00
peptiderepo
696b4d8062 test(smoke): match real key token, not the sk-or- placeholder (#25)
The write-only security check in 08-pr-vision-admin.spec.ts asserted
page.content() does not contain 'sk-or-'. The password input for the
API key renders with placeholder="sk-or-…" (an ellipsis format hint
shown when the key is admin-managed). This placeholder contains the
literal string 'sk-or-' so the naive toContain assertion trips even
though value="" and no real key is in the page.

Fix: replace toContain('sk-or-') with toMatch(/sk-or-v1-[A-Za-z0-9]{20,}/).
A real OpenRouter key uses the format 'sk-or-v1-<20+ alnum chars>';
the placeholder ellipsis 'sk-or-…' never matches this pattern, so the
false-positive is eliminated. The assertion still catches any real key
accidentally rendered in server-side HTML.

The value="" assertion is unchanged and continues to guard the core
write-only invariant independently.

Agent-Session: fix-smoke-key-assertion-20260617

Co-authored-by: peptiderepo <bot@peptiderepo.com>
2026-06-17 13:07:56 +07:00
peptiderepo
3f63c0ce77 docs(smoke): note why playwright install omits --with-deps (#24)
OS deps are pre-installed on the self-hosted peptide-vps runner host.
--with-deps invokes apt-get which fails under the runner's NoNewPrivileges
sandbox hardening. Flag was already dropped in e00f777; this adds an
inline comment so the intent is self-documenting for future maintainers.

Agent-Session: smoke-no-withdeps-20260617

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-06-17 12:12:49 +07:00
peptiderepo
e00f777cdb fix(ci): drop --with-deps from playwright install on self-hosted runner (v1.7.2) (#23)
Drop --with-deps from npx playwright install chromium. The peptide-vps runner has OS deps pre-installed; --with-deps invokes apt-get blocked by NoNewPrivileges hardening. Binary-only install verified for ghrunner via SSH.

Agent-Session: smoke-playwright-nodeps-20260617
2026-06-17 11:51:33 +07:00
peptiderepo
35602d624f ci: fix peptide-e2e reusable-workflow org ref (peptiderepo→peptide-repo) (#22)
Reusable workflow uses: refs do not follow GitHub transfer redirects.
Update owner string from peptiderepo to peptide-repo post org-migration.

Agent-Session: fix-e2e-ref-20260617
2026-06-17 11:15:12 +07:00
9 changed files with 323 additions and 8 deletions

View File

@@ -22,7 +22,7 @@ jobs:
- uses: actions/github-script@v7
with:
script: |
const owner = 'peptiderepo';
const owner = 'peptide-repo';
const repos = [
'prautoblogger','peptide-repo-core','peptide-search-ai',
'peptide-news-plugin','peptide-reconstitution-calculator','peptide-starter-theme'

View File

@@ -53,7 +53,8 @@ jobs:
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
# OS deps pre-installed on the self-hosted runner host; --with-deps needs sudo which the sandbox blocks
run: npx playwright install chromium
- name: Run smoke tests
env:

View File

@@ -1,6 +1,30 @@
# Changelog
## [1.7.1] - 2026-06-17
## [1.8.0] - 2026-06-23
### Added
- **`infra/vps/` PRAB generation orchestrator**: systemd timer + service + bash
script that SSHes from the Coolify KVM8 VPS to Hostinger and drives
`wp prautoblogger generate --sync` in a held-open SSH session. Sidesteps
Hostinger's ~10-min background-PHP-process kill that was blocking full
generation runs. Components:
- `orchestrate-generation.sh`: main script with DRY_RUN mode, structured
logging, reaper pre-sweep, failure email alert, and shellcheck-clean quoting.
- `prab-generation.service` / `prab-generation.timer`: systemd oneshot service
(2h timeout, no auto-restart) + daily 06:00 UTC timer with 5-min jitter.
- `install-orchestrator.sh`: one-time setup script; installs units but does NOT
enable the timer (requires explicit CEO greenlight post-QA).
- Security: `StrictHostKeyChecking=accept-new` with pre-seeded known_hosts for
the Hostinger IP; no hardcoded secrets; SSH key referenced by env-var path.
## [1.7.2] - 2026-06-17
### Fixed
- **CI**: Drop `--with-deps` from `npx playwright install chromium` in `smoke.yml`.
The `peptide-vps` self-hosted runner has OS-level Playwright deps pre-installed by
root; `--with-deps` invokes `apt-get` which requires `sudo`, blocked by the runner's
`NoNewPrivileges=true` systemd hardening. Binary-only install works without
elevated privileges.## [1.7.1] - 2026-06-17
### Changed
- **CI/CD**: Switch `deploy-staging`, `deploy-production`, and `smoke` jobs from

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# install-orchestrator.sh — One-time setup for the PRAB generation orchestrator on the VPS.
#
# Installs:
# 1. orchestrate-generation.sh -> /opt/prab-orchestrator/ (from this repo)
# 2. prab-generation.service -> /etc/systemd/system/
# 3. prab-generation.timer -> /etc/systemd/system/
#
# Does NOT enable or start the timer — that is an explicit post-QA cutover step.
# Does NOT write secrets — the SSH key at /root/.ssh/hostinger_prod_ed25519 must
# already be present (placed manually by the CTO from .env.credentials).
#
# Run as root on the KVM8 VPS (76.13.220.15):
# bash infra/vps/install-orchestrator.sh
#
# After install, validate with DRY_RUN=1 before enabling the timer:
# DRY_RUN=1 /opt/prab-orchestrator/orchestrate-generation.sh
# systemctl enable --now prab-generation.timer # only after CEO greenlight
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="/opt/prab-orchestrator"
SYSTEMD_DIR="/etc/systemd/system"
ORCHESTRATE_SH="${SCRIPT_DIR}/orchestrate-generation.sh"
SERVICE_FILE="${SCRIPT_DIR}/prab-generation.service"
TIMER_FILE="${SCRIPT_DIR}/prab-generation.timer"
# Must be root.
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: install-orchestrator.sh must be run as root." >&2
exit 1
fi
# Validate source files exist.
for f in "${ORCHESTRATE_SH}" "${SERVICE_FILE}" "${TIMER_FILE}"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: source file not found: $f" >&2
exit 1
fi
done
echo "Installing PRAB orchestrator to ${INSTALL_DIR}..."
mkdir -p "${INSTALL_DIR}"
install -m 0750 "${ORCHESTRATE_SH}" "${INSTALL_DIR}/orchestrate-generation.sh"
echo " OK: ${INSTALL_DIR}/orchestrate-generation.sh"
# Pre-seed known_hosts with Hostinger's host key so ssh(1) can verify
# the server identity without StrictHostKeyChecking=no.
# ssh-keyscan output is pinned here; re-run if Hostinger rotates its key.
KNOWN_HOSTS_FILE="${INSTALL_DIR}/known_hosts"
if [[ ! -f "${KNOWN_HOSTS_FILE}" ]]; then
echo "Pre-seeding known_hosts from Hostinger (${SSH_HOST:-145.223.107.228}:${SSH_PORT:-65002})..."
ssh-keyscan -p "${SSH_PORT:-65002}" "${SSH_HOST:-145.223.107.228}" \
> "${KNOWN_HOSTS_FILE}" 2>/dev/null \
&& chmod 0600 "${KNOWN_HOSTS_FILE}" \
&& echo " OK: ${KNOWN_HOSTS_FILE}" \
|| echo " WARN: ssh-keyscan failed — known_hosts not seeded; first SSH will add key via accept-new"
else
echo " OK: ${KNOWN_HOSTS_FILE} (already exists, not overwritten)"
fi
echo "Installing systemd units..."
install -m 0644 "${SERVICE_FILE}" "${SYSTEMD_DIR}/prab-generation.service"
install -m 0644 "${TIMER_FILE}" "${SYSTEMD_DIR}/prab-generation.timer"
echo " OK: ${SYSTEMD_DIR}/prab-generation.service"
echo " OK: ${SYSTEMD_DIR}/prab-generation.timer"
systemctl daemon-reload
echo " OK: systemctl daemon-reload"
echo ""
echo "Install complete. Timer is installed but NOT enabled."
echo "Next steps (post-QA + CEO greenlight only):"
echo " 1. Ensure SSH key exists: ls -la /root/.ssh/hostinger_prod_ed25519"
echo " 2. Validate with dry run: DRY_RUN=1 /opt/prab-orchestrator/orchestrate-generation.sh"
echo " 3. Run first real invocation: /opt/prab-orchestrator/orchestrate-generation.sh"
echo " 4. Enable timer: systemctl enable --now prab-generation.timer"
echo " 5. Confirm next run: systemctl list-timers prab-generation.timer"

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# orchestrate-generation.sh — VPS orchestrator for PRAutoBlogger article generation
#
# Runs as a systemd oneshot service (prab-generation.service) on the Coolify KVM8 VPS.
# SSHes to Hostinger and drives wp prautoblogger generate --sync in a held-open SSH
# session. SSH-attached processes survive Hostinger's ~10-min background-PHP-process kill.
#
# Usage:
# DRY_RUN=1 ./orchestrate-generation.sh # echo commands, no generation
# ./orchestrate-generation.sh # real run
#
# Alerting: on failure, sends email to cto@peptiderepo.com via system mail.
# Logs: structured log lines to stdout (captured by systemd journal).
# Lock: the plugin's Generation_Lock (DB mutex) prevents double-drive.
#
# Environment variables:
# DRY_RUN Set to "1" to echo SSH/WP-CLI commands without executing. Default: 0.
# LOGFILE Path for persistent log file. Default: /var/log/prab-generation.log.
# ALERT_EMAIL Failure alert recipient. Default: cto@peptiderepo.com.
# SSH_KEY Path to Hostinger SSH private key. Default: /root/.ssh/hostinger_prod_ed25519.
# SSH_HOST Hostinger SSH host. Default: 145.223.107.228.
# SSH_PORT Hostinger SSH port. Default: 65002.
# SSH_USER Hostinger SSH user. Default: u117248512.
# WP_PATH WordPress docroot on Hostinger. Default: /home/u117248512/domains/peptiderepo.com/public_html.
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration (override via environment; no hardcoded secrets)
# ---------------------------------------------------------------------------
DRY_RUN="${DRY_RUN:-0}"
LOGFILE="${LOGFILE:-/var/log/prab-generation.log}"
ALERT_EMAIL="${ALERT_EMAIL:-cto@peptiderepo.com}"
SSH_KEY="${SSH_KEY:-/root/.ssh/hostinger_prod_ed25519}"
SSH_HOST="${SSH_HOST:-145.223.107.228}"
SSH_PORT="${SSH_PORT:-65002}"
SSH_USER="${SSH_USER:-u117248512}"
WP_PATH="${WP_PATH:-/home/u117248512/domains/peptiderepo.com/public_html}"
# Known-hosts file pre-seeded by install-orchestrator.sh with Hostinger's host key.
# Pins the server identity; StrictHostKeyChecking=accept-new adds on first connect.
KNOWN_HOSTS="${KNOWN_HOSTS:-/opt/prab-orchestrator/known_hosts}"
# ---------------------------------------------------------------------------
# Logging helpers
# ---------------------------------------------------------------------------
PID="$$"
TS() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }
log() {
local msg="prab-generation[${PID}]: $*"
echo "$msg"
echo "$(TS) $msg" >> "${LOGFILE}" 2>/dev/null || true
}
die() {
log "FATAL: $*"
exit 1
}
# ---------------------------------------------------------------------------
# Validate required key before doing anything
# ---------------------------------------------------------------------------
if [[ "${DRY_RUN}" != "1" ]] && [[ ! -f "${SSH_KEY}" ]]; then
die "SSH key not found at ${SSH_KEY}. Run install-orchestrator.sh first."
fi
# ---------------------------------------------------------------------------
# DRY_RUN mode: echo commands and exit
# ---------------------------------------------------------------------------
if [[ "${DRY_RUN}" == "1" ]]; then
log "DRY_RUN=1 — echoing commands only, no generation"
log "WOULD RUN: ssh -i ${SSH_KEY} -p ${SSH_PORT} -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 ${SSH_USER}@${SSH_HOST} \\"
log " 'wp prautoblogger generate --sync --path=${WP_PATH} 2>&1'"
log "DRY_RUN complete"
exit 0
fi
# ---------------------------------------------------------------------------
# Run start
# ---------------------------------------------------------------------------
START_TS="$(TS)"
log "START ts=${START_TS}"
# ---------------------------------------------------------------------------
# Step 1: Reaper sweep — clear any stuck runs before starting.
# Belt-and-suspenders: the plugin's daily reaper cron handles this too, but
# running it explicitly here ensures a clean state before each generation.
# ---------------------------------------------------------------------------
log "REAPER_SWEEP: invoking Run_Reaper::reap()"
REAP_CMD="wp eval 'echo json_encode(PRAutoBlogger_Run_Reaper::reap());' --path=${WP_PATH} 2>&1"
REAP_OUT=""
# shellcheck disable=SC2029
REAP_OUT="$(ssh -i "${SSH_KEY}" \
-p "${SSH_PORT}" \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="${KNOWN_HOSTS}" \
-o ConnectTimeout=15 \
-o ServerAliveInterval=60 \
-o ServerAliveCountMax=30 \
"${SSH_USER}@${SSH_HOST}" \
"${REAP_CMD}" \
2>&1)" || true
log "REAPER_RESULT: ${REAP_OUT}"
# ---------------------------------------------------------------------------
# Step 2: Run wp prautoblogger generate --sync
# ---------------------------------------------------------------------------
log "SSH_CONNECT: ${SSH_USER}@${SSH_HOST}:${SSH_PORT} key=${SSH_KEY}"
WP_CMD="wp prautoblogger generate --sync --path=${WP_PATH} 2>&1"
RESULT_OUTPUT=""
EXIT_CODE=0
# shellcheck disable=SC2029
RESULT_OUTPUT="$(ssh -i "${SSH_KEY}" \
-p "${SSH_PORT}" \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="${KNOWN_HOSTS}" \
-o ConnectTimeout=15 \
-o ServerAliveInterval=60 \
-o ServerAliveCountMax=30 \
"${SSH_USER}@${SSH_HOST}" \
"${WP_CMD}" \
2>&1)" || EXIT_CODE=$?
# Extract the last JSON summary line (the one from WP_CLI::log(json_encode(...)))
LAST_JSON="$(echo "${RESULT_OUTPUT}" | grep -E '^\{' | tail -1 || true)"
LAST_LINE="$(echo "${RESULT_OUTPUT}" | tail -1)"
log "SSH_RESULT exit=${EXIT_CODE} json=${LAST_JSON:-none}"
# ---------------------------------------------------------------------------
# Step 3: Evaluate result
# ---------------------------------------------------------------------------
END_TS="$(TS)"
if [[ ${EXIT_CODE} -eq 0 ]]; then
log "END result=success ts=${END_TS}"
else
log "END result=failure exit=${EXIT_CODE} last_line=${LAST_LINE} ts=${END_TS}"
# Send failure alert via email (system mail; no external deps required).
# cto@peptiderepo.com forwards to peptiderepo@gmail.com via Cloudflare Email Routing.
ALERT_BODY="PRAutoBlogger generation FAILED on $(hostname) at ${END_TS}.
Exit code: ${EXIT_CODE}
Last output: ${LAST_LINE}
Full log: ${LOGFILE}
SSH: ${SSH_USER}@${SSH_HOST}:${SSH_PORT}
WP path: ${WP_PATH}"
echo "${ALERT_BODY}" \
| mail -s "PRAB generation FAILED @ ${END_TS}" "${ALERT_EMAIL}" 2>/dev/null \
|| log "ALERT_FAILED: could not send email to ${ALERT_EMAIL} (mail not configured?)"
exit "${EXIT_CODE}"
fi

View File

@@ -0,0 +1,22 @@
[Unit]
Description=PRAutoBlogger article generation (VPS orchestrator)
Documentation=https://github.com/peptide-repo/peptide-e2e/tree/main/infra/vps/
After=network.target
# Do not start if the timer fires while another instance is still running.
# Type=oneshot + TimeoutStartSec enforce this at the systemd level.
[Service]
Type=oneshot
# root owns /root/.ssh/hostinger_prod_ed25519 and /opt/prab-orchestrator/known_hosts;
# a dedicated service user would need its own keypair + sudo-less SSH access.
# Acceptable on this dedicated lab VPS (not shared); revisit if VPS is multi-tenant.
User=root
ExecStart=/opt/prab-orchestrator/orchestrate-generation.sh
StandardOutput=journal
StandardError=journal
SyslogIdentifier=prab-generation
# Allow up to 2 hours for a full generation run (orchestrate + draft LLM calls).
# The longest observed single LLM call was 1925s (~32 min); normal runs 2-5 min.
TimeoutStartSec=7200
# Do not restart on failure — the timer will retry the next day.
Restart=no

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Daily PRAutoBlogger generation at 06:00 UTC
Documentation=https://github.com/peptide-repo/peptide-e2e/tree/main/infra/vps/
Requires=prab-generation.service
[Timer]
# Fire daily at 06:00 UTC — before Hostinger's daily wp-cron window.
OnCalendar=*-*-* 06:00:00 UTC
# Persistent=true: if the VPS was down at 06:00 UTC, fire immediately on next
# boot so at most one day's generation is missed (not deferred indefinitely).
Persistent=true
# Up to 5 min random jitter to avoid thundering-herd if multiple VPS instances
# are ever added. Harmless with a single instance.
RandomizedDelaySec=300
# Accuracy within 1 minute (default is 1 minute; explicit for clarity).
AccuracySec=60
[Install]
WantedBy=timers.target

View File

@@ -1,6 +1,6 @@
{
"name": "peptide-e2e",
"version": "1.7.1",
"version": "1.8.0",
"description": "Playwright smoke tests for peptiderepo.com (staging + production)",
"private": true,
"scripts": {

View File

@@ -34,7 +34,11 @@
* The stored OpenRouter key must NEVER appear in page source.
* #prv_api_key renders with value="" on every page load regardless of
* whether a key is stored. This spec asserts the empty value and asserts
* that known API key prefixes ("sk-or-") are absent from the full body.
* that no structurally valid key token (regex /sk-or-v1-[A-Za-z0-9]{20,}/)
* appears in the full body HTML. A regex is used instead of a naive
* toContain('sk-or-') to avoid a false-positive on the password input's
* placeholder="sk-or-…" attribute (the ellipsis is a format hint, not a
* token body, and won't match the regex).
*
* @see peptiderepo/pr-vision includes/core/class-prv-admin-page.php
* @see peptiderepo/pr-vision includes/core/class-prv-settings-page.php
@@ -170,10 +174,18 @@ test.describe('WP Admin - PR Vision Settings page (API-key-manager)', () => {
const inputValue = await keyInput.getAttribute('value');
expect(inputValue ?? '').toBe('');
// Security check: known OpenRouter key prefixes must not appear anywhere in
// the page HTML -- guards against accidental server-side leakage.
// Security check: a *real* OpenRouter key (format "sk-or-v1-<20+ alnum chars>")
// must not appear anywhere in the page HTML -- guards against accidental
// server-side leakage.
//
// NOTE: We use a regex rather than the naive toContain('sk-or-') to avoid
// a false-positive: the password input carries placeholder="sk-or-…"
// (an ellipsis format-hint, not a token body) which contains the literal
// string "sk-or-" but is NOT a leaked key. A regex that requires the
// "v1-" segment and at least 20 alphanumeric characters only matches a
// structurally valid key, so the placeholder never trips it.
const html = await page.content();
expect(html).not.toContain('sk-or-');
expect(html).not.toMatch(/sk-or-v1-[A-Za-z0-9]{20,}/);
});
});