← 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
M agents/shared/agent-base.js
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=>({'&':'&','<':'<','>':'>'}[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 →