aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 14:39:38 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 14:39:38 +0200
commitcfe5b7a842890c232358ae191652c5e5e81591e1 (patch)
treeba27d718d1fd1c8df48b9e91eea071949aa1946f
parentf6a48a90e374e79b5ca8c0f73d2eb9b3d95868ec (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.php57
-rw-r--r--api/debug/report.php9
-rw-r--r--help.html2
-rw-r--r--modules-index.json7
-rwxr-xr-xscripts/newos4-reply.py65
-rwxr-xr-xscripts/newos4-triage.py166
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'] ?? '',
diff --git a/help.html b/help.html
index 0d70e08..18a03ef 100644
--- a/help.html
+++ b/help.html
@@ -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 &lt;url&gt;</code></td><td>Show or set the sync endpoint</td></tr>
+ <tr><td><code>feedback &lt;message&gt;</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))