[object Object]

← back to Norma

agent-base: fix route shadowing + server-render status dashboard

3dbea14c54bad6f4ca66cac808bf53e0ec18ff9e · 2026-07-10 11:30:11 -0700 · Steve Abrams

Contrarian gate caught two real defects:
- app.use('*') 404 catch-all was registered inside createAgentServer,
  shadowing caller-added routes: instagram-agent /api/skill/reel + /story
  were 404. Now deferred to start() (called last) -> custom routes win.
  Verified reel/story now 200, bogus routes still 404.
- GET / dashboard fetched /api/status client-side, which was fragile re:
  Basic-auth on sub-requests. Now server-renders data inline (const S).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 3dbea14c54bad6f4ca66cac808bf53e0ec18ff9e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 10 11:30:11 2026 -0700

    agent-base: fix route shadowing + server-render status dashboard
    
    Contrarian gate caught two real defects:
    - app.use('*') 404 catch-all was registered inside createAgentServer,
      shadowing caller-added routes: instagram-agent /api/skill/reel + /story
      were 404. Now deferred to start() (called last) -> custom routes win.
      Verified reel/story now 200, bogus routes still 404.
    - GET / dashboard fetched /api/status client-side, which was fragile re:
      Basic-auth on sub-requests. Now server-renders data inline (const S).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 agents/shared/agent-base.js | 98 +++++++++++++++++++++++++--------------------
 1 file changed, 55 insertions(+), 43 deletions(-)

diff --git a/agents/shared/agent-base.js b/agents/shared/agent-base.js
index d2ed295..fbff635 100644
--- a/agents/shared/agent-base.js
+++ b/agents/shared/agent-base.js
@@ -103,11 +103,26 @@ function createAgentServer({ name, port, skills = {}, cronJobs = [] }) {
   });
 
   // ──────────────────────────────────────
-  // Read-only status dashboard (GET /) — behind auth
-  // Renders /api/status + /api/cron/status + /api/audit client-side.
+  // Read-only status dashboard (GET /) — behind auth.
+  // Data is server-RENDERED (not client-fetched) so it never depends on
+  // the browser re-sending Basic auth on a sub-request. Refresh = reload.
   // No write controls; cron start/stop stay API/skill-only by design.
   // ──────────────────────────────────────
-  app.get('/', (req, res) => {
+  app.get('/', async (req, res) => {
+    const cronStatus = scheduler.status();
+    let audit = [];
+    try { audit = await getRecentActions(name, 20); } catch { audit = []; }
+    const data = {
+      agent: name,
+      port,
+      uptime: process.uptime(),
+      skills: Object.keys(skills),
+      monitors: cronStatus.jobs.filter((j) => j.running).map((j) => j.name),
+      lastAction,
+      cron: cronStatus,
+      audit,
+    };
+    const DATA = JSON.stringify(data).replace(/</g, '\\u003c');
     res.type('html').send(`<!doctype html><html lang="en"><head>
 <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
 <title>${name} — status</title>
@@ -145,25 +160,18 @@ function createAgentServer({ name, port, skills = {}, cronJobs = [] }) {
 </main>
 <div class="foot">Control is API/skill-driven — see <code>/api/status</code>, <code>/api/audit?limit=50</code>, <code>/api/cron/status</code>, <code>POST /api/cron/{start,stop}</code>, or the <code>instagram-account-manager</code> skill.</div>
 <script>
+const S=${DATA};
 const esc=s=>String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
 const fmt=t=>{if(!t)return '<span class="muted">—</span>';const d=new Date(t);return isNaN(d)?esc(t):d.toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});};
-async function j(u){const r=await fetch(u,{headers:{Accept:'application/json'}});if(!r.ok)throw new Error(r.status);return r.json();}
-async function load(){
-  try{
-    const s=await j('/api/status');
-    document.getElementById('dot').style.background='var(--ok)';
-    document.getElementById('uptime').textContent='up '+Math.floor((s.uptime||0)/60)+'m';
-    document.getElementById('skills').innerHTML=(s.skills||[]).map(x=>'<span class="chip">'+esc(x)+'</span>').join('')||'<span class="muted">none</span>';
-    document.getElementById('monitors').innerHTML=(s.monitors||[]).map(x=>'<span class="chip">🟢 '+esc(x)+'</span>').join('');
-    const jobs=(s.cron&&s.cron.jobs)||[];
-    document.getElementById('cron').innerHTML=jobs.length?jobs.map(jb=>'<tr><td>'+esc(jb.name)+'</td><td><code>'+esc(jb.schedule)+'</code></td><td><span class="pill '+(jb.running?'on':'off')+'">'+(jb.running?'on':'off')+'</span></td><td>'+fmt(jb.lastRun)+'</td><td>'+(jb.lastError?'<span style="color:var(--bad)">error</span>':esc(jb.lastStatus||'—'))+'</td><td>'+(jb.runCount||0)+'</td></tr>').join(''):'<tr><td colspan="6" class="muted">no cron jobs</td></tr>';
-  }catch(e){document.getElementById('dot').style.background='var(--bad)';document.getElementById('cron').innerHTML='<tr><td colspan="6" style="color:var(--bad)">status error: '+esc(e.message)+'</td></tr>';}
-  try{
-    const a=await j('/api/audit?limit=20');const acts=(a.actions||[]);
-    document.getElementById('audit').innerHTML=acts.length?acts.map(x=>'<tr><td>'+fmt(x.timestamp||x.ts||x.time)+'</td><td>'+esc(x.action||x.type||x.event||'—')+'</td><td class="muted">'+esc(x.detail||x.summary||x.message||'')+'</td></tr>').join(''):'<tr><td colspan="3" class="muted">no recorded activity</td></tr>';
-  }catch(e){document.getElementById('audit').innerHTML='<tr><td colspan="3" class="muted">audit unavailable</td></tr>';}
-}
-load();setInterval(load,15000);
+document.getElementById('dot').style.background='var(--ok)';
+document.getElementById('uptime').textContent='up '+Math.floor((S.uptime||0)/60)+'m';
+document.getElementById('skills').innerHTML=(S.skills||[]).map(x=>'<span class="chip">'+esc(x)+'</span>').join('')||'<span class="muted">none</span>';
+document.getElementById('monitors').innerHTML=(S.monitors||[]).map(x=>'<span class="chip">🟢 '+esc(x)+'</span>').join('');
+const jobs=(S.cron&&S.cron.jobs)||[];
+document.getElementById('cron').innerHTML=jobs.length?jobs.map(jb=>'<tr><td>'+esc(jb.name)+'</td><td><code>'+esc(jb.schedule)+'</code></td><td><span class="pill '+(jb.running?'on':'off')+'">'+(jb.running?'on':'off')+'</span></td><td>'+fmt(jb.lastRun)+'</td><td>'+(jb.lastError?'<span style="color:var(--bad)">error</span>':esc(jb.lastStatus||'—'))+'</td><td>'+(jb.runCount||0)+'</td></tr>').join(''):'<tr><td colspan="6" class="muted">no cron jobs</td></tr>';
+const acts=(S.audit||[]);
+document.getElementById('audit').innerHTML=acts.length?acts.map(x=>'<tr><td>'+fmt(x.timestamp||x.ts||x.time)+'</td><td>'+esc(x.action||x.type||x.event||'—')+'</td><td class="muted">'+esc(x.detail||x.summary||x.message||'')+'</td></tr>').join(''):'<tr><td colspan="3" class="muted">no recorded activity</td></tr>';
+setTimeout(()=>location.reload(),15000);
 </script></body></html>`);
   });
 
@@ -283,36 +291,40 @@ load();setInterval(load,15000);
   }
 
   // ──────────────────────────────────────
-  // 404 handler for unmatched routes
+  // 404 + error fallbacks — registered by start() AFTER any custom routes
+  // the caller adds. Registering the '*' catch-all inline here shadowed
+  // caller routes (e.g. instagram-agent's /api/skill/reel + /story 404'd).
+  // Fixed 2026-07-10: defer registration to start(), which every agent
+  // calls LAST, so custom routes always come before the catch-all.
   // ──────────────────────────────────────
-  app.use('*', (req, res) => {
-    res.status(404).json({
-      error: 'Route not found',
-      agent: name,
-      availableEndpoints: [
-        'GET  /health',
-        'GET  /api/status',
-        'GET  /api/audit?limit=50',
-        'GET  /api/cron/status',
-        'POST /api/cron/start',
-        'POST /api/cron/stop',
-        ...skillNames.map((s) => `POST /api/skill/${s}`),
-      ],
+  function registerFallbacks() {
+    app.use('*', (req, res) => {
+      res.status(404).json({
+        error: 'Route not found',
+        agent: name,
+        availableEndpoints: [
+          'GET  /',
+          'GET  /health',
+          'GET  /api/status',
+          'GET  /api/audit?limit=50',
+          'GET  /api/cron/status',
+          'POST /api/cron/start',
+          'POST /api/cron/stop',
+          ...Object.keys(skills).map((s) => `POST /api/skill/${s}`),
+        ],
+      });
     });
-  });
-
-  // ──────────────────────────────────────
-  // Error handler
-  // ──────────────────────────────────────
-  app.use((err, req, res, _next) => {
-    console.error(`[${new Date().toISOString()}] [${name}] Unhandled error:`, err.message);
-    res.status(500).json({ error: 'Internal server error', message: err.message });
-  });
+    app.use((err, req, res, _next) => {
+      console.error(`[${new Date().toISOString()}] [${name}] Unhandled error:`, err.message);
+      res.status(500).json({ error: 'Internal server error', message: err.message });
+    });
+  }
 
   // ──────────────────────────────────────
   // Start function
   // ──────────────────────────────────────
   function start() {
+    registerFallbacks(); // after caller's custom routes → no shadowing
     app.listen(port, () => {
       console.log(`[${new Date().toISOString()}] [${name}] Agent running on port ${port}`);
       console.log(`[${new Date().toISOString()}] [${name}] Skills: ${Object.keys(skills).join(', ') || 'none'}`);

← 6f74352 Norma agents: add read-only status dashboard at GET / (behin  ·  back to Norma  ·  Norma instagram-agent: add RECOVERY.md (deps-location gotcha 46eb638 →