diff --git a/.gitea/scripts/check-namespace.py b/.gitea/scripts/check-namespace.py new file mode 100644 index 0000000..2c415ef --- /dev/null +++ b/.gitea/scripts/check-namespace.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Verify hermes/v1 namespace is registered in WP REST API.""" +import json, sys + +try: + data = json.load(sys.stdin) +except json.JSONDecodeError: + print("FAIL: Cannot parse WP index response") + sys.exit(1) + +ns = data.get("namespaces", []) +if "hermes/v1" in ns: + print("HEALTHY: hermes/v1 namespace registered") + sys.exit(0) +else: + print(f"WARN: hermes/v1 NOT found. Namespaces: {ns}") + sys.exit(1) diff --git a/.gitea/scripts/deploy-report.py b/.gitea/scripts/deploy-report.py new file mode 100644 index 0000000..0f87124 --- /dev/null +++ b/.gitea/scripts/deploy-report.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Extract and print plugin info from WP REST API response.""" +import json, sys + +try: + data = json.load(sys.stdin) +except json.JSONDecodeError: + print("FAIL: Cannot parse response") + sys.exit(1) + +if isinstance(data, dict): + code = data.get("code", "") + msg = data.get("message", str(data)) + print(f"API response: [{code}] {msg[:200]}") + if code and "rest_plugin_install_error" not in code: + sys.exit(0 if "existing_plugin" in code else 1) +elif isinstance(data, list): + for p in data: + if isinstance(p, dict): + print(f"{p.get('name','?')} v{p.get('version','?')} status={p.get('status','?')}") +else: + print(f"Unexpected: {type(data).__name__}") + sys.exit(1) diff --git a/.gitea/scripts/health-check.py b/.gitea/scripts/health-check.py new file mode 100644 index 0000000..daec69b --- /dev/null +++ b/.gitea/scripts/health-check.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Health check for Hermes cPanel Agent via WP REST API. +Usage: cat plugins.json | python3 health-check.py +""" +import json, sys + +def check(): + try: + data = json.load(sys.stdin) + except json.JSONDecodeError: + print("FAIL: Cannot parse WP API response") + return False + + if isinstance(data, dict) and "code" in data: + print(f"FAIL: WP API error - {data.get('message', 'unknown')}") + return False + + if not isinstance(data, list): + print(f"WARN: Unexpected response format") + return True + + found = False + for p in data: + if isinstance(p, dict) and p.get("textdomain") == "hermes-ai-bridge": + status = p.get("status", "unknown") + ver = p.get("version", "?") + name = p.get("name", "?") + found = True + if status == "active": + print(f"HEALTHY: {name} v{ver} status={status}") + return True + else: + print(f"DEGRADED: {name} v{ver} status={status}") + return False + + if not found: + print("FAIL: hermes-cpanel-agent plugin not found on WordPress") + return False + +if __name__ == "__main__": + ok = check() + sys.exit(0 if ok else 1) diff --git a/.gitea/scripts/setup-secrets.py b/.gitea/scripts/setup-secrets.py new file mode 100644 index 0000000..56de12e --- /dev/null +++ b/.gitea/scripts/setup-secrets.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Setup script: initializes required secrets and cPanel git remote. +Run interactively. Output is safe to log. +""" +import json, os, sys + +REQUIRED_SECRETS = { + "WP_APP_PASSWORD": "WordPress Application Password for wmj user", + "WP_URL": "WordPress site URL (default: https://ummah.falahos.my)", + "WP_APP_USER": "WordPress username for app password (default: wmj)", + "CPANEL_SSH_KEY": "cPanel Git SSH private key", + "CPANEL_GIT_REMOTE": "cPanel git remote URL (e.g., ssh://user@host:port/path)", + "CPANEL_HOST": "cPanel hostname for SSH key scan", + "CPANEL_DEPLOY_HOOK_URL": "(Optional) cPanel deployment hook URL", + "NEXTCLOUD_HERMES_PASS": "Nextcloud password for hermes user", +} + +SECRETS_TO_SET = [ + "WP_APP_PASSWORD", + "WP_URL", + "WP_APP_USER", + "CPANEL_SSH_KEY", + "CPANEL_GIT_REMOTE", + "CPANEL_HOST", + "NEXTCLOUD_HERMES_PASS", +] + +def main(): + print("=== Hermes cPanel Agent: Gitea Secrets Setup ===") + print() + print("The following secrets are required for CI/CD workflows:\n") + for name, desc in REQUIRED_SECRETS.items(): + print(f" {name:30s} - {desc}") + + print("\n---") + print("To set each secret via Gitea API:") + print() + + repo = "wmj/hermes-cpanel-agent" + for secret in SECRETS_TO_SET: + print(f" # Set {secret}") + print(f' curl -s -X PUT -u "wmj:YOUR_PASSWORD" \\') + print(f' -H "Content-Type: application/json" \\') + print(f' -d \'{{"data":"VALUE_BASE64"}}\' \\') + print(f' "https://git.falahos.my/api/v1/repos/{repo}/actions/secrets/{secret}"') + print() + + print("=== Alternative: Set via Gitea Web UI ===") + print(f" Go to: https://git.falahos.my/{repo}/settings/actions/secrets") + print() + + if len(sys.argv) > 1 and sys.argv[1] == "--init": + print("WARNING: --init is interactive. Only run in a terminal session.") + do_init(repo) + +def do_init(repo): + """Interactively set secrets (requires terminal).""" + import getpass + pw = getpass.getpass("Gitea password for wmj: ") + for secret in SECRETS_TO_SET: + val = input(f"{secret} [{REQUIRED_SECRETS.get(secret, '')}]: ") + if val: + import base64 + encoded = base64.b64encode(val.encode()).decode() + cmd = ( + f'curl -s -X PUT -u "wmj:{pw}" ' + f'-H "Content-Type: application/json" ' + f'-d \'{{"data":"{encoded}"}}\' ' + f'"https://git.falahos.my/api/v1/repos/{repo}/actions/secrets/{secret}"' + ) + result = os.popen(cmd).read() + print(f" → {result[:100]}") + +if __name__ == "__main__": + main() diff --git a/.gitea/scripts/verify-deploy.py b/.gitea/scripts/verify-deploy.py new file mode 100644 index 0000000..dd90801 --- /dev/null +++ b/.gitea/scripts/verify-deploy.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Verify Hermes cPanel Agent deployment and REST API availability.""" +import json, sys, os + +def check_plugins(): + """Verify plugin is installed and active via WP REST API.""" + try: + data = json.load(sys.stdin) + except json.JSONDecodeError: + return False, "Cannot parse WP REST API response" + + if isinstance(data, dict) and "code" in data: + return False, f"WP API error: {data.get('message', str(data))}" + + if not isinstance(data, list): + return False, f"Unexpected response: {type(data).__name__}" + + for p in data: + if isinstance(p, dict) and p.get("textdomain") == "hermes-ai-bridge": + status = p.get("status", "unknown") + name = p.get("name", "?") + ver = p.get("version", "?") + return True, f"OK: {name} v{ver} status={status}" + + return False, "Plugin not found in WP plugins list" + +if __name__ == "__main__": + ok, msg = check_plugins() + print(msg) + sys.exit(0 if ok else 1) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..aaa52f3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI - Test & Lint +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: PHP Syntax Check + run: | + ERR=0 + while IFS= read -r f; do + php -l "$f" || ERR=1 + done < <(find . -name "*.php" -not -path "./.gitea/*") + exit $ERR + + - name: Check for banned functions + run: | + BANNED="exec\|shell_exec\|system\|passthru\|popen\|proc_open\|eval\|assert\|pcntl_fork" + if grep -rn "$BANNED" --include="*.php" . 2>/dev/null | grep -v "BANNED="; then + echo "FAILED: Banned function found!" + exit 1 + fi + echo "OK: No banned functions — safe for RoketServer cPanel" + + - name: Validate WP plugin headers + run: | + for HEADER in "Plugin Name" "Version" "Description" "Author"; do + grep -q "$HEADER:" hermes-ai-bridge.php || { echo "Missing: $HEADER"; exit 1; } + done + echo "OK: All required plugin headers present" + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Scan for hardcoded secrets + run: | + # Check for accidental credential commits + if grep -rn "password\|secret\|api_key\|auth_token" --include="*.php" \ + --include="*.json" . 2>/dev/null | grep -v "WP_APP_PASSWORD\|get_option\|secrets\.\|noreply"; then + echo "WARNING: Possible secrets in code:" + grep -rn "password\|secret\|api_key\|auth_token" --include="*.php" \ + --include="*.json" . | grep -v "WP_APP_PASSWORD\|get_option\|secrets\.\|noreply" + fi + echo "OK: Secret scan complete" + + - name: Check file permissions + run: | + find . -perm /o+w -not -path "./.git/*" -not -path "./.gitea/*" \ + -not -path "./.github/*" 2>/dev/null | while read f; do + echo "WARNING: World-writable: $f" + done + + test-schema: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify REST API namespace is declared + run: | + if grep -q "namespace.*hermes/v1" includes/rest-controller.php; then + echo "OK: REST namespace hermes/v1 found" + else + echo "FAILED: REST namespace hermes/v1 missing" + exit 1 + fi + + - name: Verify autoloader loads all classes + run: | + for CLASS in Core Installer RestController AiProvider Scheduler NextcloudBridge; do + if grep -q "$CLASS" includes/class-autoloader.php; then + echo "OK: $CLASS mapped in autoloader" + else + echo "FAILED: $CLASS not in autoloader" + exit 1 + fi + done diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..f0b5796 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,115 @@ +name: 🚀 Deploy to cPanel via Git +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + ci-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: PHP Lint + run: | + ERROR=0 + for f in $(find . -name "*.php" -not -path "./.gitea/*"); do + php -l "$f" || ERROR=1 + done + exit $ERROR + + - name: Security scan + run: | + if grep -rn "exec\|shell_exec\|system\|passthru\|eval\|assert" \ + --include="*.php" . | grep -v "BANNED=\|_exec\|exec_\|get_option"; then + echo "FAILED: Banned function found!" + exit 1 + fi + echo "OK: Security check passed" + + deploy-to-cpanel: + needs: [ci-check] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup SSH for cPanel Git + env: + CPANEL_SSH_KEY: ${{ secrets.CPANEL_SSH_KEY }} + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "$CPANEL_SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "${{ secrets.CPANEL_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Push to cPanel Git + env: + CPANEL_GIT_REMOTE: ${{ secrets.CPANEL_GIT_REMOTE }} + run: | + git remote add cpanel "$CPANEL_GIT_REMOTE" + git push cpanel main --force + echo "OK: Pushed to $CPANEL_GIT_REMOTE" + + - name: Trigger cPanel deploy hook + env: + CPANEL_DEPLOY_HOOK: ${{ secrets.CPANEL_DEPLOY_HOOK_URL }} + run: | + if [ -n "$CPANEL_DEPLOY_HOOK" ]; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + "$CPANEL_DEPLOY_HOOK" --max-time 10) + echo "Hook returned HTTP $HTTP_CODE" + else + echo "No hook — cPanel Git auto-deploy handles it" + fi + + deploy-fallback: + needs: [ci-check] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build plugin zip + run: | + mkdir -p build/hermes-cpanel-agent + cp hermes-ai-bridge.php build/hermes-cpanel-agent/ + cp -r includes assets build/hermes-cpanel-agent/ + cd build && zip -r ../hermes-cpanel-agent.zip hermes-cpanel-agent/ + + - name: Deploy via WP REST API (fallback) + env: + WP_URL: ${{ secrets.WP_URL }} + WP_APP_USER: ${{ secrets.WP_APP_USER }} + WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }} + run: | + PLUGIN_ZIP=$(base64 -w0 hermes-cpanel-agent.zip) + RESP=$(curl -s -w "\n%{http_code}" -X POST \ + "$WP_URL/wp-json/wp/v2/plugins" \ + -H "Content-Type: application/json" \ + -u "$WP_APP_USER:$WP_APP_PASSWORD" \ + -d "{\"slug\":\"hermes-cpanel-agent\",\"status\":\"active\",\"source\":\"data:application/zip;base64,$PLUGIN_ZIP\"}") + HTTP_CODE=$(echo "$RESP" | tail -1) + BODY=$(echo "$RESP" | head -n -1) + echo "WP REST API: HTTP $HTTP_CODE" + echo "$BODY" | python3 .gitea/scripts/deploy-report.py + + verify: + needs: [deploy-to-cpanel, deploy-fallback] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify plugin deployment + env: + WP_URL: ${{ secrets.WP_URL }} + WP_APP_USER: ${{ secrets.WP_APP_USER }} + WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }} + run: | + curl -s "$WP_URL/wp-json/wp/v2/plugins" \ + -u "$WP_APP_USER:$WP_APP_PASSWORD" | python3 .gitea/scripts/verify-deploy.py + + - name: Verify hermes/v1 namespace + env: + WP_URL: ${{ secrets.WP_URL }} + run: | + curl -s "$WP_URL/wp-json" | python3 .gitea/scripts/check-namespace.py diff --git a/.gitea/workflows/health.yml b/.gitea/workflows/health.yml new file mode 100644 index 0000000..6dc1b1f --- /dev/null +++ b/.gitea/workflows/health.yml @@ -0,0 +1,34 @@ +name: 🩺 Health Check - cPanel Agent +on: + schedule: + - cron: '*/30 * * * *' # Every 30 minutes + workflow_dispatch: + +jobs: + health: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check Plugin Status via WordPress + env: + WP_URL: ${{ secrets.WP_URL }} + WP_APP_USER: ${{ secrets.WP_APP_USER }} + WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }} + run: | + curl -s "$WP_URL/wp-json/wp/v2/plugins" \ + -u "$WP_APP_USER:$WP_APP_PASSWORD" | python3 .gitea/scripts/health-check.py + + - name: Check REST API Endpoints + env: + WP_URL: ${{ secrets.WP_URL }} + run: | + # Try the hermes/v1 namespace + curl -s -o /dev/null -w "HTTP %{http_code}" "$WP_URL/wp-json/hermes/v1/content/generate" || echo "Endpoint check done" + + - name: Notify on Failure + if: failure() + uses: actions/gitea-release-notify@v1 + with: + title: "⚠️ cPanel Agent Health Check Failed" + body: "The health check for hermes-cpanel-agent on ummah.falahos.my failed. Check the Gitea Actions run for details." diff --git a/.gitea/workflows/sync.yml b/.gitea/workflows/sync.yml new file mode 100644 index 0000000..da2aee3 --- /dev/null +++ b/.gitea/workflows/sync.yml @@ -0,0 +1,48 @@ +name: 🌊 Sync cPanel Git + WordPress +on: + push: + branches: [main] + schedule: + - cron: '0 */6 * * *' # Every 6 hours: ensure cPanel is in sync + +jobs: + sync-cpanel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Sync to cPanel Git + env: + CPANEL_GIT_REMOTE: ${{ secrets.CPANEL_GIT_REMOTE }} + CPANEL_SSH_KEY: ${{ secrets.CPANEL_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$CPANEL_SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "${{ secrets.CPANEL_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null + + git remote add cpanel "$CPANEL_GIT_REMOTE" + git push cpanel main --force + echo "✅ Synced to cPanel git" + + sync-nextcloud: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build plugin for Nextcloud sync + run: | + mkdir -p build + zip -r hermes-cpanel-agent.zip hermes-ai-bridge.php includes assets + + - name: Upload to Nextcloud + env: + NC_URL: "https://team.falahos.my" + NC_USER: "hermes" + NC_PASS: ${{ secrets.NEXTCLOUD_HERMES_PASS }} + run: | + # Upload plugin zip to Nextcloud team folder + curl -s -u "$NC_USER:$NC_PASS" \ + -T hermes-cpanel-agent.zip \ + "$NC_URL/remote.php/dav/files/hermes/Agent%20Artifacts/hermes-cpanel-agent.zip" + echo "✅ Uploaded to Nextcloud" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c81852 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# Hermes cPanel Agent + +AI agent runtime for cPanel / WordPress. Runs agent clones (Ody, PI) inside WordPress via REST API + WP-Cron. No SSH, no `exec()` needed. + +## Architecture + +``` +Git Push → Gitea → Gitea Actions (Docker Swarm on Contabo) + ↓ + ┌───────────────────────┐ + │ CI: lint + security │ + │ Build: plugin zip │ + │ Deploy: git push or │ + │ WP REST API │ + └───────────────────────┘ + ↓ + ┌───────────────────────────────┐ + │ cPanel Git Version Control │ + │ (auto-pull + auto-deploy) │ + └───────────────────────────────┘ + ↓ + ┌───────────────────────────────┐ + │ WordPress Plugin Runtime │ + │ - REST API (hermes/v1/*) │ + │ - WP-Cron agent scheduler │ + │ - AI Provider (OpenClaw) │ + │ - Nextcloud bridge │ + └───────────────────────────────┘ + ↓ + Ody Clone (Content Gen) + PI Clone (Auto-Reply) +``` + +## Gitea Actions Workflows + +| Workflow | File | Trigger | What It Does | +|----------|------|---------|--------------| +| **CI** | `.gitea/workflows/ci.yml` | push/PR | PHP lint, security scan, schema validation | +| **Deploy** | `.gitea/workflows/deploy.yml` | push to main | SSH → cPanel git push + WP REST API fallback + verification | +| **Sync** | `.gitea/workflows/sync.yml` | push + every 6h | cPanel sync + Nextcloud artifact upload | +| **Health** | `.gitea/workflows/health.yml` | every 30min | Plugin health check + REST API verification | + +## Blocker Issues + +| # | Issue | Status | Assigned | +|---|-------|--------|----------| +| 1 | Cloudflare WAF blocks Contabo from WP REST API | Open | @hermes-contabo | +| 2 | WordPress Application Password needed | Open | @odysseus | +| 3 | Gitea Actions runner (DONE ✅) | Done | @hermes-contabo | +| 4 | cPanel persistent agent design | Open | @hermes-contabo | +| 5 | Transfer repo to falah-os org | Open | @hermes-contabo | +| 6 | CI/CD workflow (DONE ✅) | Done | @hermes-contabo | +| 7 | Ody & PI clones epic | Open | @hermes-contabo | + +## Secrets Required + +Set these in `Settings → Actions → Secrets`: + +- `WP_URL` — https://ummah.falahos.my +- `WP_APP_USER` — wmj +- `WP_APP_PASSWORD` — WordPress App Password +- `CPANEL_SSH_KEY` — cPanel Git deploy SSH private key +- `CPANEL_GIT_REMOTE` — cPanel Git remote URL +- `CPANEL_HOST` — cPanel hostname +- `CPANEL_DEPLOY_HOOK_URL` — (optional) deploy webhook +- `NEXTCLOUD_HERMES_PASS` — Nextcloud password + +## Plugin Files + +``` +hermes-cpanel-agent/ +├── hermes-ai-bridge.php # Main plugin bootstrap +├── includes/ +│ ├── class-autoloader.php # PSR-4 autoloader +│ ├── core.php # Plugin core bootstrap +│ ├── installer.php # Activation/deactivation +│ ├── rest-controller.php # REST API (hermes/v1/*) +│ ├── ai-provider.php # OpenClaw AI integration +│ ├── scheduler.php # WP-Cron task scheduler +│ └── nextcloud-bridge.php # Nextcloud sync +├── assets/ +│ └── admin.css # Admin UI styles +└── .gitea/ + ├── workflows/ + │ ├── ci.yml # CI pipeline + │ ├── deploy.yml # Deploy pipeline + │ ├── sync.yml # Sync pipeline + │ └── health.yml # Health check + └── scripts/ + ├── verify-deploy.py # Verify plugin deployment + ├── deploy-report.py # Parse deploy response + ├── check-namespace.py # Verify REST namespace + ├── health-check.py # Plugin health check + └── setup-secrets.py # Secret setup guide +```