diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 14:39:38 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 14:39:38 +0200 |
| commit | 25833fb43a0ed3e9c7522dfb6a78f29dfd947720 (patch) | |
| tree | 01e1b13d8619290143a67990bd12657323867dcc | |
| parent | 97f55bf200c74a1b8ca0068ff70cbfeda77916cf (diff) | |
feat(newos4): maintenance agent channel — triage engine, two-way replies, cron
- scripts/newos4-triage.py: deterministic collector (records vs occurrences, real vs
noise vs self-referential, new reports since last run, checker WARN/FAIL). Untrusted
strings are sanitised and prefixed DATA| before they can reach an agent prompt;
state + output written under flock with mode 644.
- scripts/newos4-reply.py: appends one reply to data/maintenance/replies.json under
flock. Hermes writes here instead of POSTing with an admin cookie, so an expired
session can never look like a delivered reply.
- api/debug/agent-replies.php: GET/POST (admin-gated by nginx) half of the thread.
- api/debug/report.php: accepts thread + kind=maintenance so in-app feedback is
distinguishable from operator bug reports.
- terminal: `feedback <message>` and `feedback-inbox` commands; help.html rows added
(help coverage 357/357, modules-index regenerated).
Live proof: `feedback "…"` filed RPT-20261023-123825-781fba86 with thread
thr-glmfwae2 and kind=maintenance; agent-replies GET returned the posted reply, POST
returned 201; triage first run reported 9 new reports, second run 0.
Cron: newos4-maintenance (7e3b326daa4c) daily 07:15, newos4-skills-weekly
(cb6bc2a16216) Mondays 06:30.
| -rw-r--r-- | api/debug/agent-replies.php | 57 | ||||
| -rw-r--r-- | api/debug/report.php | 9 | ||||
| -rw-r--r-- | help.html | 2 | ||||
| -rw-r--r-- | modules-index.json | 7 | ||||
| -rwxr-xr-x | scripts/newos4-reply.py | 65 | ||||
| -rwxr-xr-x | scripts/newos4-triage.py | 166 |
6 files changed, 304 insertions, 2 deletions
diff --git a/api/debug/agent-replies.php b/api/debug/agent-replies.php new file mode 100644 index 0000000..070c0e6 --- /dev/null +++ b/api/debug/agent-replies.php @@ -0,0 +1,57 @@ +<?php +// api/debug/agent-replies.php — outbound half of the maintenance thread. +// Hermes (or the operator) writes replies here; the in-page agent reads them with +// `feedback-inbox`. nginx gates this path behind the WebX11 admin session, so it is +// owner-only: never expose it in the public debug/course location block. +require_once __DIR__ . '/config.php'; + +define('REPLIES_DIR', __DIR__ . '/../../data/maintenance'); +define('REPLIES_FILE', REPLIES_DIR . '/replies.json'); + +if (!is_dir(REPLIES_DIR)) { + @mkdir(REPLIES_DIR, 0775, true); + @chgrp(REPLIES_DIR, 'www-data'); +} + +$method = $_SERVER['REQUEST_METHOD']; + +if ($method === 'GET') { + $since = $_GET['since'] ?? null; + $all = load_json(REPLIES_FILE); + if (!is_array($all)) $all = []; + if ($since) { + $all = array_values(array_filter($all, fn($r) => ($r['created_at'] ?? '') > $since)); + } + json_response(['success' => true, 'replies' => array_slice($all, -100)]); +} + +if ($method !== 'POST') { + error_response('Method not allowed', 405); +} + +$raw = file_get_contents('php://input'); +if (!$raw) error_response('Empty request body'); +if (strlen($raw) > 16384) error_response('Payload too large (max 16KB)', 413); + +$p = json_decode($raw, true); +if (!is_array($p)) error_response('Invalid JSON'); + +$body = trim((string)($p['body'] ?? '')); +if ($body === '') error_response('body is required'); +if (mb_strlen($body) > 2000) $body = mb_substr($body, 0, 2000); + +$list = load_json(REPLIES_FILE); +if (!is_array($list)) $list = []; + +$author = (($p['author'] ?? 'hermes') === 'newos4') ? 'newos4' : 'hermes'; +$entry = [ + 'id' => 'REP-' . gmdate('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6), + 'created_at' => gmdate('c'), + 'author' => $author, + 'body' => $body, +]; + +$list[] = $entry; +save_json(REPLIES_FILE, array_slice($list, -200)); + +json_response(['success' => true, 'id' => $entry['id']], 201); diff --git a/api/debug/report.php b/api/debug/report.php index cefcae6..5d4fc95 100644 --- a/api/debug/report.php +++ b/api/debug/report.php @@ -29,6 +29,13 @@ $body = (string)($payload['body'] ?? ''); if (mb_strlen($body) > 4000) $body = mb_substr($body, 0, 4000); $diag = (isset($payload['diagnostics']) && is_array($payload['diagnostics'])) ? $payload['diagnostics'] : []; +// Maintenance thread: lets the in-app agent and the Hermes maintenance job hold a conversation. +// `thread` is a client-generated id (thr-xxxxxxxx); `kind` distinguishes agent feedback from +// operator bug reports so triage can weight them differently. +$thread = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($payload['thread'] ?? '')); +if (mb_strlen($thread) > 40) $thread = substr($thread, 0, 40); +$kind = (($payload['kind'] ?? '') === 'maintenance') ? 'maintenance' : 'user'; + // Storage guard: data/ is not writable by php-fpm, so never pretend we wrote a report. if (!is_dir(REPORTS_DIR)) @mkdir(REPORTS_DIR, 0775, true); if (!is_dir(REPORTS_DIR) || !is_writable(REPORTS_DIR)) { @@ -85,6 +92,8 @@ $record = [ 'title' => $title, 'body' => $body, 'diagnostics' => $diag, + 'thread' => $thread, + 'kind' => $kind, 'meta' => [ 'ip' => $ip, 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', @@ -1471,6 +1471,8 @@ vfs-import</pre> <tr><td><code>debug</code></td><td>Toggle debug panel on/off</td></tr> <tr><td><code>debug-sync</code></td><td>Push debug records to the configured sync endpoint</td></tr> <tr><td><code>debug-sync-url <url></code></td><td>Show or set the sync endpoint</td></tr> + <tr><td><code>feedback <message></code></td><td>Send feedback to the WebX11 maintenance agent (appears in its next triage run)</td><td><code>feedback download link 403s on mobile</code></td></tr> + <tr><td><code>feedback-inbox</code></td><td>Read replies from the maintenance agent (requires an admin session)</td><td><code>feedback-inbox</code></td></tr> </table></div> <h3>Debug Panel Shows</h3> <ul> diff --git a/modules-index.json b/modules-index.json index 395a788..5a55ebe 100644 --- a/modules-index.json +++ b/modules-index.json @@ -1,5 +1,5 @@ { - "version": "2026-10-18-atlas", + "version": "2026-10-23-atlas", "generated": "2026-06-30T00:00:00.000Z", "modules": { "module-agent-registry": { @@ -446,6 +446,7 @@ "class": "SystemModule", "auto_load": true, "commands": [ + "bug", "bug-report", "checkpoint", "checkpoint-del", @@ -1072,7 +1073,9 @@ "debug-stats", "debug-sync", "debug-sync-url", - "debug-trends" + "debug-trends", + "feedback", + "feedback-inbox" ], "tier": "core-bundle" }, diff --git a/scripts/newos4-reply.py b/scripts/newos4-reply.py new file mode 100755 index 0000000..8144285 --- /dev/null +++ b/scripts/newos4-reply.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Append one reply to the newOS4 maintenance thread. + +Hermes writes here instead of POSTing to agent-replies.php, so an expired admin cookie can +never make a reply look delivered when it was not. Same JSON schema and file the PHP endpoint +serves to the in-app `feedback-inbox` command. + +Usage: newos4-reply.py "Triage 2026-10-23: ..." [--author hermes] +""" +import argparse, fcntl, json, os, time + +ROOT = '/var/www/webx11.duckdns.org/spaces/newos4' +DIR = os.path.join(ROOT, 'data', 'maintenance') +FILE = os.path.join(DIR, 'replies.json') +MAX = 200 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('body') + ap.add_argument('--author', default='hermes', choices=['hermes', 'newos4']) + a = ap.parse_args() + + body = a.body.strip() + if not body: + raise SystemExit('refusing to write an empty reply') + body = body[:2000] + + os.makedirs(DIR, exist_ok=True) + lock = open(FILE + '.lock', 'a+') + fcntl.flock(lock, fcntl.LOCK_EX) + try: + try: + with open(FILE, encoding='utf-8') as f: + data = json.load(f) + except Exception: + data = [] + if not isinstance(data, list): + data = [] + entry = { + 'id': 'REP-' + time.strftime('%Y%m%d-%H%M%S') + '-' + os.urandom(3).hex(), + 'created_at': time.strftime('%Y-%m-%dT%H:%M:%S+00:00', time.gmtime()), + 'author': a.author, + 'body': body, + } + data.append(entry) + tmp = FILE + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data[-MAX:], f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, FILE) + os.chmod(FILE, 0o664) + try: + os.chmod(FILE, 0o664) + except Exception: + pass + print(entry['id']) + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + lock.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/newos4-triage.py b/scripts/newos4-triage.py new file mode 100755 index 0000000..20016f7 --- /dev/null +++ b/scripts/newos4-triage.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""newOS4 triage engine — turns telemetry + reports into one actionable digest. + +Read-only by default. --apply performs safe hygiene only (archive old reports). +Never edits app code, nginx, services or user content. +""" +import argparse, json, os, re, subprocess, sys, time +from datetime import datetime, timezone + +ROOT = '/var/www/webx11.duckdns.org/spaces/newos4' +DEBUG = os.path.join(ROOT, 'data', 'debug') +REPORTS = os.path.join(ROOT, 'data', 'reports') +ARCHIVE = os.path.join(REPORTS, 'archive') +OUTDIR = '/var/www/webx11.duckdns.org/admin/docs' +STATE_DIR = '/var/lib/webx11/newos4-maintenance' +STATE = os.path.join(STATE_DIR, 'state.json') + +NOISE = re.compile(r'(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)', re.I) +SELF = re.compile(r'/api/debug/(ingest|report)\.php', re.I) + + +def load(path, default): + try: + with open(path, encoding='utf-8') as f: + return json.load(f) + except Exception: + return default + + +def save(path, obj): + """Write atomically, under flock, and leave the file readable by nginx.""" + import fcntl + os.makedirs(os.path.dirname(path), exist_ok=True) + lock = open(path + '.lock', 'a+') + fcntl.flock(lock, fcntl.LOCK_EX) + try: + tmp = path + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(obj, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + os.chmod(path, 0o644) # a 600 file 403s behind the admin gate + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + lock.close() + + +def ts(s): + if not s: + return None + try: + d = datetime.fromisoformat(str(s).replace('Z', '+00:00')) + return d if d.tzinfo else d.replace(tzinfo=timezone.utc) + except Exception: + return None + + +def age_hours(s): + d = ts(s) + return None if d is None else round((datetime.now(timezone.utc) - d).total_seconds() / 3600, 1) + + +def check_runner(): + r = subprocess.run([sys.executable, 'scripts/newos4-maintain.py'], cwd=ROOT, + capture_output=True, text=True, timeout=300) + lines = [l for l in (r.stdout or '').splitlines() if l.startswith(('FAIL', 'WARN'))] + return {'exit': r.returncode, 'findings': lines} + + +def safe(s, limit=200): + """Make untrusted telemetry safe to paste into an agent prompt: one line, no control + chars, bounded. Everything the agent sees from here is DATA, never an instruction.""" + s = re.sub(r'[\x00-\x1f\x7f]+', ' ', str(s or '')) + s = re.sub(r'\s+', ' ', s).strip() + return s[:limit] + + +def classify(err): + msg = str(err.get('message', '')) + if SELF.search(msg): + return 'self-referential' + if NOISE.search(msg): + return 'noise' + return 'real' + + +def build(apply_fix=False): + errors = load(os.path.join(DEBUG, 'errors.json'), []) + reports = [] + if os.path.isdir(REPORTS): + for name in sorted(os.listdir(REPORTS)): + if name.startswith('RPT-') and name.endswith('.json'): + r = load(os.path.join(REPORTS, name), None) + if isinstance(r, dict): + r['_file'] = os.path.join(REPORTS, name) + reports.append(r) + + state = load(STATE, {'seen_reports': [], 'last_run': None}) + seen = set(state.get('seen_reports', [])) + new_reports = [r for r in reports if r.get('id') not in seen] + + buckets = {'real': [], 'noise': [], 'self-referential': []} + for e in errors: + buckets[classify(e)].append(e) + + digest = { + 'generated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'counts': { + 'records': len(errors), + 'occurrences': sum(int(e.get('count') or 1) for e in errors), + 'real_records': len(buckets['real']), + 'noise_records': len(buckets['noise']), + 'self_referential_records': len(buckets['self-referential']), + 'reports_total': len(reports), + 'reports_new': len(new_reports), + }, + 'top_real': sorted( + ({'message': safe(e.get('message'), 200), 'type': e.get('type'), + 'count': int(e.get('count') or 1), 'age_hours': age_hours(e.get('last_seen')), + 'session': safe(e.get('session_id'), 40)} for e in buckets['real']), + key=lambda x: -x['count'])[:10], + 'new_reports': [{'id': r.get('id'), 'title': safe(r.get('title'), 160), + 'created_at': r.get('created_at'), + 'age_hours': age_hours(r.get('created_at')), + 'body': safe(r.get('body'), 400)} for r in new_reports[:10]], + 'self_referential': [str(e.get('message'))[:120] for e in buckets['self-referential']], + 'checks': check_runner(), + } + + if apply_fix: + os.makedirs(ARCHIVE, exist_ok=True) + moved = 0 + cutoff = time.time() - 90 * 86400 + for r in reports: + t = ts(r.get('created_at')) + if t and t.timestamp() < cutoff: + os.replace(r['_file'], os.path.join(ARCHIVE, os.path.basename(r['_file']))) + moved += 1 + digest['counts']['reports_archived'] = moved + + save(STATE, {'seen_reports': sorted(seen | {r.get('id') for r in new_reports if r.get('id')}), + 'last_run': digest['generated_at']}) + save(os.path.join(OUTDIR, 'newos4-triage-latest.json'), digest) + + c = digest['counts'] + print(f"newOS4 triage {digest['generated_at']}: records={c['records']} " + f"occurrences={c['occurrences']} real={c['real_records']} noise={c['noise_records']} " + f"self={c['self_referential_records']} reports={c['reports_total']} new={c['reports_new']}") + for f in digest['checks']['findings']: + print('check:', f) + for e in digest['top_real'][:5]: + print(f" DATA| top: {e['count']}x {e['type']} ({e['age_hours']}h) {safe(e['message'], 110)}") + for r in digest['new_reports'][:5]: + print(f" DATA| report: {r['id']} {safe(r['title'], 110)}") + return digest + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + ap.add_argument('--apply', action='store_true') + ap.add_argument('--json', action='store_true') + a = ap.parse_args() + d = build(apply_fix=a.apply) + if a.json: + print(json.dumps(d, indent=2)) |