agentgate 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -2,7 +2,7 @@ import express from 'express';
2
2
  import cookieParser from 'cookie-parser';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
- import { validateApiKey, getAccountsByService, getCookieSecret, getMessagingMode } from './lib/db.js';
5
+ import { validateApiKey, getAccountsByService, getCookieSecret, getMessagingMode, checkServiceAccess } from './lib/db.js';
6
6
  import { connectHsync } from './lib/hsyncManager.js';
7
7
  import { initSocket } from './lib/socketManager.js';
8
8
  import githubRoutes, { serviceInfo as githubInfo } from './routes/github.js';
@@ -16,7 +16,10 @@ import jiraRoutes, { serviceInfo as jiraInfo } from './routes/jira.js';
16
16
  import fitbitRoutes, { serviceInfo as fitbitInfo } from './routes/fitbit.js';
17
17
  import queueRoutes from './routes/queue.js';
18
18
  import agentsRoutes from './routes/agents.js';
19
- import uiRoutes from './routes/ui.js';
19
+ import mementoRoutes from './routes/memento.js';
20
+ import uiRoutes from './routes/ui/index.js';
21
+ import webhooksRoutes from './routes/webhooks.js';
22
+ import servicesRoutes from './routes/services.js';
20
23
 
21
24
  // Aggregate service metadata from all routes
22
25
  const SERVICE_REGISTRY = {
@@ -36,7 +39,15 @@ const app = express();
36
39
  const PORT = process.env.PORT || 3050;
37
40
  const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
38
41
 
39
- app.use(express.json({ limit: '10mb' }));
42
+ app.use(express.json({
43
+ limit: '10mb',
44
+ verify: (req, res, buf) => {
45
+ // Capture raw body for webhook signature verification
46
+ if (req.originalUrl.startsWith('/webhooks')) {
47
+ req.rawBody = buf;
48
+ }
49
+ }
50
+ }));
40
51
  app.use(express.urlencoded({ extended: true }));
41
52
  app.use(cookieParser(getCookieSecret()));
42
53
  app.use('/public', express.static(join(__dirname, '../public')));
@@ -54,6 +65,11 @@ async function apiKeyAuth(req, res, next) {
54
65
  return res.status(401).json({ error: 'Invalid API key' });
55
66
  }
56
67
 
68
+ if (!valid.enabled) {
69
+ return res.status(403).json({ error: 'Agent is disabled' });
70
+ }
71
+
72
+
57
73
  req.apiKeyInfo = valid;
58
74
  next();
59
75
  }
@@ -66,17 +82,49 @@ function readOnlyEnforce(req, res, next) {
66
82
  next();
67
83
  }
68
84
 
69
- // API routes - require auth and read-only
85
+
86
+ // Service access control middleware factory
87
+ // Checks if the agent has access to the requested service/account
88
+ function serviceAccessCheck(serviceName) {
89
+ return (req, res, next) => {
90
+ // Extract accountName from the URL path (first segment after the service mount point)
91
+ // e.g., /api/github/monteslu/repos -> req.path = /monteslu/repos -> accountName = monteslu
92
+ const pathSegments = req.path.split('/').filter(Boolean);
93
+ const accountName = pathSegments[0];
94
+ if (!accountName) {
95
+ return next(); // No account specified, let the route handle it
96
+ }
97
+
98
+ const agentName = req.apiKeyInfo?.name;
99
+ if (!agentName) {
100
+ return next(); // No agent info, let other middleware handle auth
101
+ }
102
+
103
+ const access = checkServiceAccess(serviceName, accountName, agentName);
104
+ if (!access.allowed) {
105
+ return res.status(403).json({
106
+ error: `Agent '${agentName}' does not have access to service '${serviceName}/${accountName}'`,
107
+ reason: access.reason
108
+ });
109
+ }
110
+ next();
111
+ };
112
+ }
113
+
114
+ // API routes - require auth, read-only, and service access check
70
115
  // Pattern: /api/{service}/{accountName}/...
71
- app.use('/api/github', apiKeyAuth, readOnlyEnforce, githubRoutes);
72
- app.use('/api/bluesky', apiKeyAuth, readOnlyEnforce, blueskyRoutes);
73
- app.use('/api/reddit', apiKeyAuth, readOnlyEnforce, redditRoutes);
74
- app.use('/api/calendar', apiKeyAuth, readOnlyEnforce, calendarRoutes);
75
- app.use('/api/mastodon', apiKeyAuth, readOnlyEnforce, mastodonRoutes);
76
- app.use('/api/linkedin', apiKeyAuth, readOnlyEnforce, linkedinRoutes);
77
- app.use('/api/youtube', apiKeyAuth, readOnlyEnforce, youtubeRoutes);
78
- app.use('/api/jira', apiKeyAuth, readOnlyEnforce, jiraRoutes);
79
- app.use('/api/fitbit', apiKeyAuth, readOnlyEnforce, fitbitRoutes);
116
+ app.use('/api/github', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('github'), githubRoutes);
117
+ app.use('/api/bluesky', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('bluesky'), blueskyRoutes);
118
+ app.use('/api/reddit', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('reddit'), redditRoutes);
119
+ app.use('/api/calendar', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('calendar'), calendarRoutes);
120
+ app.use('/api/mastodon', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('mastodon'), mastodonRoutes);
121
+ app.use('/api/linkedin', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('linkedin'), linkedinRoutes);
122
+ app.use('/api/youtube', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('youtube'), youtubeRoutes);
123
+ app.use('/api/jira', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('jira'), jiraRoutes);
124
+ app.use('/api/fitbit', apiKeyAuth, readOnlyEnforce, serviceAccessCheck('fitbit'), fitbitRoutes);
125
+
126
+ // Service access management - admin API (requires auth)
127
+ app.use('/api/services', apiKeyAuth, servicesRoutes);
80
128
 
81
129
  // Queue routes - require auth but allow POST for submitting write requests
82
130
  // Pattern: /api/queue/{service}/{accountName}/submit
@@ -89,35 +137,59 @@ app.use('/api/agents', apiKeyAuth, (req, res, next) => {
89
137
  next();
90
138
  }, agentsRoutes);
91
139
 
140
+ // Memento routes - require auth, allow POST for creating mementos
141
+ app.use('/api/agents/memento', apiKeyAuth, (req, res, next) => {
142
+ req.apiKeyName = req.apiKeyInfo.name;
143
+ next();
144
+ }, mementoRoutes);
145
+
92
146
  // UI routes - no API key needed (local admin access)
93
147
  app.use('/ui', uiRoutes);
94
148
 
149
+ // Webhook routes - no API key needed (uses signature verification instead)
150
+ app.use('/webhooks', webhooksRoutes);
151
+
95
152
  // Agent readme endpoint - requires auth
96
153
  app.get('/api/readme', apiKeyAuth, (req, res) => {
97
154
  const accountsByService = getAccountsByService();
155
+ const agentName = req.apiKeyInfo?.name;
98
156
 
99
- // Build services object from registry
157
+ // Build services object from registry, filtering by agent access
100
158
  const services = {};
101
159
  for (const [key, info] of Object.entries(SERVICE_REGISTRY)) {
102
160
  const dbKey = info.dbKey || key;
103
- services[key] = {
104
- accounts: accountsByService[dbKey] || [],
105
- authType: info.authType,
106
- description: info.description
107
- };
161
+ const allAccounts = accountsByService[dbKey] || [];
162
+
163
+ // Filter accounts based on agent access
164
+ const accessibleAccounts = allAccounts.filter(accountName => {
165
+ const access = checkServiceAccess(key, accountName, agentName);
166
+ return access.allowed;
167
+ });
168
+
169
+ // Only include service if agent has access to at least one account
170
+ if (accessibleAccounts.length > 0) {
171
+ services[key] = {
172
+ accounts: accessibleAccounts,
173
+ authType: info.authType,
174
+ description: info.description
175
+ };
176
+ }
108
177
  }
109
178
 
110
- // Build endpoints object from registry
179
+ // Build endpoints object from registry (only for accessible services)
111
180
  const endpoints = {};
112
181
  for (const [key, info] of Object.entries(SERVICE_REGISTRY)) {
113
- endpoints[key] = {
114
- base: `/api/${key}/{accountName}`,
115
- description: info.description,
116
- docs: info.docs,
117
- examples: info.examples
118
- };
119
- if (info.writeGuidelines) {
120
- endpoints[key].writeGuidelines = info.writeGuidelines;
182
+ // Only include endpoint if agent has access to this service
183
+ if (services[key]) {
184
+ endpoints[key] = {
185
+ base: `/api/${key}/{accountName}`,
186
+ description: info.description,
187
+ docs: info.docs,
188
+ examples: info.examples
189
+ };
190
+ if (info.writeGuidelines) {
191
+ endpoints[key].writeGuidelines = info.writeGuidelines;
192
+ }
121
193
  }
122
194
  }
123
195
 
@@ -194,12 +266,15 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
194
266
  description: 'Withdraw your own pending submission (requires agent_withdraw_enabled setting)',
195
267
  method: 'DELETE',
196
268
  path: '/api/queue/{service}/{accountName}/status/{id}',
269
+ body: {
270
+ reason: 'Optional: Explain why you are withdrawing this request'
271
+ },
197
272
  constraints: [
198
273
  'Only the submitting agent can withdraw their own items',
199
274
  'Only works for "pending" status - cannot withdraw approved/completed/etc',
200
275
  'Requires admin to enable agent_withdraw_enabled setting'
201
276
  ],
202
- response: '{ success: true, message: "Queue entry withdrawn", id }'
277
+ response: '{ success: true, message: "Queue entry withdrawn", id, reason }'
203
278
  },
204
279
  statuses: {
205
280
  pending: 'Waiting for human approval',
@@ -327,10 +402,17 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
327
402
  body: { message: 'Your broadcast message' },
328
403
  response: '{ delivered: ["Agent1", "Agent2"], failed: [{ name: "Agent3", error: "HTTP 500" }], total: 3 }',
329
404
  notes: [
330
- 'Broadcasts go directly to agent webhooks - not stored in messages table',
405
+ 'Broadcasts are stored in the database and appear in message history',
331
406
  'Sender is automatically excluded from recipients',
332
- 'Requires messaging mode to be "supervised" or "open" (not "off")'
407
+ 'Requires messaging mode to be "supervised" or "open" (not "off")',
408
+ 'View broadcast history in Admin UI under Messages → Broadcast'
333
409
  ]
410
+ },
411
+ getBroadcasts: {
412
+ method: 'GET',
413
+ path: '/api/agents/messages',
414
+ description: 'Broadcasts appear in your regular message history with from="[BROADCAST]"',
415
+ note: 'No separate endpoint - broadcasts are included with regular messages'
334
416
  }
335
417
  },
336
418
  modes: {
@@ -344,7 +426,124 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
344
426
  'Maximum message length is 10KB'
345
427
  ]
346
428
  };
347
- })()
429
+ })(),
430
+ memento: {
431
+ description: 'Durable memory storage for agents. Store and retrieve memory snapshots tagged with keywords.',
432
+ design: {
433
+ appendOnly: 'Mementos are immutable once stored. New memories can be added but not edited.',
434
+ keywordTagging: 'Each memento has 1-10 keywords. Keywords are normalized (lowercase) and stemmed (Porter stemmer).',
435
+ twoStepRetrieval: 'Search returns metadata only. Fetch specific IDs to get full content. This prevents context bloat.',
436
+ tokenBudget: 'Recommended ~1.5-2K tokens per memento. Hard cap: 12KB characters.'
437
+ },
438
+ endpoints: {
439
+ store: {
440
+ method: 'POST',
441
+ path: '/api/agents/memento',
442
+ body: {
443
+ content: 'Your memory content (required)',
444
+ keywords: ['keyword1', 'keyword2', '...'],
445
+ model: 'Model at time of storage (optional)',
446
+ role: 'Agent role/tier (optional)'
447
+ },
448
+ response: '{ id, agent_id, keywords, created_at }'
449
+ },
450
+ listKeywords: {
451
+ method: 'GET',
452
+ path: '/api/agents/memento/keywords',
453
+ description: 'List all keywords you have used (returned in stemmed form, e.g., "games" → "game")',
454
+ response: '{ keywords: [{ keyword, count }, ...] }'
455
+ },
456
+ search: {
457
+ method: 'GET',
458
+ path: '/api/agents/memento/search?keywords=game,project&limit=10',
459
+ description: 'Search mementos by keyword. Returns metadata only (preview, not full content).',
460
+ response: '{ matches: [{ id, keywords, created_at, preview, match_count }, ...] }'
461
+ },
462
+ recent: {
463
+ method: 'GET',
464
+ path: '/api/agents/memento/recent?limit=5',
465
+ description: 'Get most recent mementos (metadata only)',
466
+ response: '{ mementos: [{ id, keywords, created_at, preview }, ...] }'
467
+ },
468
+ fetch: {
469
+ method: 'GET',
470
+ path: '/api/agents/memento/42,38,15',
471
+ description: 'Fetch full content by IDs (comma-separated, max 20)',
472
+ response: '{ mementos: [{ id, agent_id, model, role, keywords, content, created_at }, ...] }'
473
+ }
474
+ },
475
+ retrievalHierarchy: [
476
+ '1. Check current context first — already in conversation?',
477
+ '2. Query Memento — if not in context, search by keyword',
478
+ '3. Web search — if no memento, fall back to internet'
479
+ ],
480
+ notes: [
481
+ 'Each agent sees only their own mementos',
482
+ 'Keywords are stemmed: "games" matches "game", "running" matches "run"',
483
+ 'Maximum 10 keywords per memento',
484
+ 'Maximum 12KB content per memento'
485
+ ]
486
+ },
487
+ serviceAccess: {
488
+ description: 'Service-level access control. Restrict which agents can access specific services.',
489
+ accessModes: {
490
+ all: 'All agents can access (default)',
491
+ allowlist: 'Only listed agents can access',
492
+ denylist: 'All agents EXCEPT listed ones can access'
493
+ },
494
+ endpoints: {
495
+ list: {
496
+ method: 'GET',
497
+ path: '/api/services',
498
+ description: 'List all services with their access configuration',
499
+ response: '{ services: [{ service, account_name, access_mode, agent_count }, ...] }'
500
+ },
501
+ getAccess: {
502
+ method: 'GET',
503
+ path: '/api/services/:service/:account/access',
504
+ description: 'Get access config for a specific service/account',
505
+ response: '{ service, account_name, access_mode, agents: [{ name, allowed }, ...] }'
506
+ }
507
+ },
508
+ errorResponse: {
509
+ status: 403,
510
+ body: '{ error: "Agent \'X\' does not have access to service \'Y/Z\'", reason: "not_in_allowlist" }'
511
+ },
512
+ notes: [
513
+ 'Access checks apply to all service API calls',
514
+ 'Default mode is "all" (backwards compatible)',
515
+ 'Agent names are case-insensitive',
516
+ 'Admin UI shows visual indicators for restricted services'
517
+ ]
518
+ },
519
+ bypassAuth: {
520
+ description: 'Trusted agents can bypass the write queue entirely, executing write operations immediately without human approval.',
521
+ warning: 'Use with extreme caution. Only enable for agents you completely trust with unsupervised write access.',
522
+ setup: {
523
+ description: 'Configure in Admin UI under API Keys → Configure → Auth Bypass',
524
+ note: 'This is a per-agent setting managed by the admin'
525
+ },
526
+ behavior: {
527
+ enabled: [
528
+ 'All write operations (POST/PUT/DELETE) execute immediately',
529
+ 'No queue entries are created',
530
+ 'The agent is effectively operating unsupervised',
531
+ 'Reads work the same as before'
532
+ ],
533
+ disabled: 'Default behavior - writes are queued for human approval'
534
+ },
535
+ checkStatus: {
536
+ method: 'GET',
537
+ path: '/api/agents/status',
538
+ description: 'Check if your agent has bypass_auth enabled',
539
+ response: '{ mode, enabled, unread_count, bypass_auth: true|false }'
540
+ },
541
+ notes: [
542
+ 'Bypass applies to all services the agent has access to',
543
+ 'Useful for automation agents that need to perform routine operations',
544
+ 'Admin can revoke bypass at any time'
545
+ ]
546
+ }
348
547
  });
349
548
  });
350
549
 
@@ -504,3 +703,4 @@ server.on('error', (err) => {
504
703
  }
505
704
  process.exit(1);
506
705
  });
706
+
@@ -1,5 +1,5 @@
1
1
  // Agent notification delivery - sends webhooks to agent gateways
2
- import { getApiKeyByName, updateQueueNotification } from './db.js';
2
+ import { getApiKeyByName, updateQueueNotification, getQueueWarnings } from './db.js';
3
3
 
4
4
  // Send a notification to an agent's webhook
5
5
  export async function notifyAgent(agentName, payload) {
@@ -83,9 +83,90 @@ export async function notifyAgentQueueStatus(entry) {
83
83
  updateQueueNotification(entry.id, false, result.error);
84
84
  }
85
85
 
86
+ // Also notify any agents who warned on this item
87
+ await notifyWarningAgentsOnResolution(entry);
88
+
86
89
  return result;
87
90
  }
88
91
 
92
+ // Notify all agents who warned on a queue item when it's resolved
93
+ async function notifyWarningAgentsOnResolution(entry) {
94
+ const warnings = getQueueWarnings(entry.id);
95
+ if (!warnings || warnings.length === 0) {
96
+ return;
97
+ }
98
+
99
+ // Get unique warning agent IDs (excluding the submitter - they already get notified)
100
+ const warningAgents = [...new Set(warnings.map(w => w.agent_id))]
101
+ .filter(agentId => agentId !== entry.submitted_by);
102
+
103
+ if (warningAgents.length === 0) {
104
+ return;
105
+ }
106
+
107
+ const statusEmoji = {
108
+ completed: '✅',
109
+ failed: '❌',
110
+ rejected: '🚫',
111
+ approved: '✅'
112
+ };
113
+
114
+ for (const agentId of warningAgents) {
115
+ const payload = {
116
+ type: 'queue_warning_resolved',
117
+ entry: {
118
+ id: entry.id,
119
+ service: entry.service,
120
+ account_name: entry.account_name,
121
+ status: entry.status,
122
+ comment: entry.comment,
123
+ rejection_reason: entry.rejection_reason,
124
+ submitted_by: entry.submitted_by
125
+ },
126
+ // Human-readable for Clawdbot-style gateways
127
+ text: `${statusEmoji[entry.status] || '📋'} [agentgate] Queue #${entry.id.substring(0, 8)} you warned on was ${entry.status}\n→ ${entry.service}/${entry.account_name}\nSubmitted by: ${entry.submitted_by}${entry.rejection_reason ? `\nReason: ${entry.rejection_reason}` : ''}`,
128
+ mode: 'now'
129
+ };
130
+
131
+ // Fire and forget - don't block on warning agent notifications
132
+ notifyAgent(agentId, payload).catch(err => {
133
+ console.error(`[agentNotifier] Failed to notify warning agent ${agentId}:`, err.message);
134
+ });
135
+ }
136
+ }
137
+
138
+ // Notify agent about a warning on their queue submission
139
+ export async function notifyAgentQueueWarning(entry, warningAgent, warningMessage) {
140
+ const agentName = entry.submitted_by;
141
+ if (!agentName) {
142
+ return { success: false, error: 'No submitter on queue entry' };
143
+ }
144
+
145
+ const agent = getApiKeyByName(agentName);
146
+ if (!agent?.webhook_url) {
147
+ return { success: false, error: 'No webhook configured' };
148
+ }
149
+
150
+ const payload = {
151
+ type: 'queue_warning',
152
+ entry: {
153
+ id: entry.id,
154
+ service: entry.service,
155
+ account_name: entry.account_name,
156
+ status: entry.status,
157
+ comment: entry.comment
158
+ },
159
+ warning: {
160
+ from: warningAgent,
161
+ message: warningMessage
162
+ },
163
+ text: `⚠️ [agentgate] Warning on Queue #${entry.id.substring(0, 8)}\n→ ${entry.service}/${entry.account_name}\nFrom: ${warningAgent}\n"${warningMessage.substring(0, 200)}"`,
164
+ mode: 'now'
165
+ };
166
+
167
+ return await notifyAgent(agentName, payload);
168
+ }
169
+
89
170
  // Notify agent about a new message (delivered to recipient)
90
171
  export async function notifyAgentMessage(message) {
91
172
  const agentName = message.to_agent;