neoagent 2.4.2-beta.4 → 2.4.2-beta.6

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.
Files changed (62) hide show
  1. package/.env.example +0 -9
  2. package/README.md +48 -18
  3. package/docs/configuration.md +0 -10
  4. package/flutter_app/lib/main.dart +0 -1
  5. package/flutter_app/lib/main_controller.dart +0 -160
  6. package/flutter_app/lib/main_models.dart +28 -0
  7. package/flutter_app/lib/main_operations.dart +17 -1
  8. package/flutter_app/lib/main_runtime.dart +0 -22
  9. package/flutter_app/lib/main_settings.dart +9 -0
  10. package/flutter_app/lib/main_shared.dart +0 -161
  11. package/flutter_app/lib/src/backend_client.dart +8 -0
  12. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
  13. package/flutter_app/pubspec.lock +0 -8
  14. package/flutter_app/pubspec.yaml +0 -1
  15. package/package.json +1 -1
  16. package/server/admin/admin.css +23 -0
  17. package/server/admin/admin.js +17 -19
  18. package/server/admin/analytics.js +254 -48
  19. package/server/admin/index.html +9 -2
  20. package/server/db/database.js +112 -0
  21. package/server/http/routes.js +1 -0
  22. package/server/public/.last_build_id +1 -1
  23. package/server/public/assets/AssetManifest.bin +1 -1
  24. package/server/public/assets/AssetManifest.bin.json +1 -1
  25. package/server/public/assets/NOTICES +0 -183
  26. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  27. package/server/public/flutter_bootstrap.js +2 -2
  28. package/server/public/main.dart.js +77723 -78469
  29. package/server/routes/admin.js +73 -24
  30. package/server/routes/mcp.js +9 -0
  31. package/server/routes/memory.js +35 -2
  32. package/server/routes/runtime.js +5 -2
  33. package/server/routes/settings.js +34 -1
  34. package/server/routes/skills.js +42 -0
  35. package/server/routes/task_webhooks.js +65 -0
  36. package/server/services/ai/engine.js +525 -24
  37. package/server/services/ai/learning.js +56 -8
  38. package/server/services/ai/models.js +4 -4
  39. package/server/services/ai/providers/anthropic.js +40 -8
  40. package/server/services/ai/providers/google.js +10 -0
  41. package/server/services/ai/providers/openai.js +3 -15
  42. package/server/services/ai/providers/openaiCompatible.js +11 -0
  43. package/server/services/ai/repetitionGuard.js +60 -0
  44. package/server/services/ai/systemPrompt.js +52 -16
  45. package/server/services/ai/taskAnalysis.js +15 -6
  46. package/server/services/ai/toolEvidence.js +3 -1
  47. package/server/services/ai/toolRunner.js +43 -1
  48. package/server/services/ai/toolSelector.js +92 -46
  49. package/server/services/ai/tools.js +89 -9
  50. package/server/services/ai/usage.js +114 -0
  51. package/server/services/manager.js +34 -0
  52. package/server/services/memory/manager.js +170 -4
  53. package/server/services/security/capability_audit.js +107 -0
  54. package/server/services/tasks/adapters/index.js +1 -0
  55. package/server/services/tasks/adapters/webhook.js +14 -0
  56. package/server/services/tasks/runtime.js +1 -1
  57. package/server/services/tasks/webhooks.js +146 -0
  58. package/server/services/workspace/code_navigation.js +194 -0
  59. package/server/services/workspace/structured_data.js +93 -0
  60. package/server/utils/cloud-security.js +24 -2
  61. package/flutter_app/lib/src/analytics_service.dart +0 -294
  62. package/server/config/analytics.js +0 -30
@@ -932,13 +932,44 @@ class MemoryManager {
932
932
  this._deleteMemoryIndex(memoryId);
933
933
 
934
934
  for (const fact of facts) {
935
+ const factId = uuidv4();
936
+ const validFrom = fact.eventTime
937
+ || metadata?.validFrom
938
+ || metadata?.eventTime
939
+ || null;
940
+ const conflict = fact.predicate === 'detail'
941
+ ? null
942
+ : db.prepare(
943
+ `SELECT id, object
944
+ FROM memory_facts
945
+ WHERE user_id = ? AND agent_id = ?
946
+ AND lower(subject) = lower(?)
947
+ AND lower(predicate) = lower(?)
948
+ AND status = 'active'
949
+ ORDER BY learned_at DESC, created_at DESC
950
+ LIMIT 1`
951
+ ).get(userId, agentId, fact.subject, fact.predicate);
952
+ const supersedesFactId = conflict && String(conflict.object) !== String(fact.object)
953
+ ? conflict.id
954
+ : null;
955
+ if (supersedesFactId) {
956
+ db.prepare(
957
+ `UPDATE memory_facts
958
+ SET status = 'superseded',
959
+ valid_to = COALESCE(?, datetime('now')),
960
+ invalidated_at = datetime('now'),
961
+ updated_at = datetime('now')
962
+ WHERE id = ?`
963
+ ).run(validFrom, supersedesFactId);
964
+ }
935
965
  db.prepare(
936
966
  `INSERT INTO memory_facts (
937
967
  id, memory_id, user_id, agent_id, subject, predicate, object, category,
938
- confidence, event_time, metadata_json, created_at, updated_at
939
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`
968
+ confidence, event_time, valid_from, learned_at, status, supersedes_fact_id,
969
+ metadata_json, created_at, updated_at
970
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, datetime('now'), datetime('now'))`
940
971
  ).run(
941
- uuidv4(),
972
+ factId,
942
973
  memoryId,
943
974
  userId,
944
975
  agentId,
@@ -948,6 +979,9 @@ class MemoryManager {
948
979
  normalizeMemoryCategory(fact.category),
949
980
  Math.max(0, Math.min(1, Number(fact.confidence) || 0.7)),
950
981
  fact.eventTime || null,
982
+ validFrom,
983
+ now,
984
+ supersedesFactId,
951
985
  JSON.stringify(parseJsonObject(fact.metadata, {})),
952
986
  );
953
987
  }
@@ -1229,7 +1263,7 @@ class MemoryManager {
1229
1263
  const scope = normalizeScope(options.scope, agentId);
1230
1264
  const limit = Math.max(1, Math.min(Number(topK) || 6, 50));
1231
1265
 
1232
- const all = db.prepare(
1266
+ let all = db.prepare(
1233
1267
  `SELECT id, category, content, summary, importance, confidence, embedding, access_count, created_at, updated_at,
1234
1268
  scope_type, scope_id, source_type, source_id, source_label, stale_after_days, metadata_json
1235
1269
  FROM memories
@@ -1242,6 +1276,30 @@ class MemoryManager {
1242
1276
  LIMIT 800`
1243
1277
  ).all(userId, agentId, scope.scopeType, scope.scopeId);
1244
1278
 
1279
+ const validAt = String(options.validAt || options.at || '').trim();
1280
+ const knownAt = String(options.knownAt || '').trim();
1281
+ if (validAt || knownAt) {
1282
+ const eligibleRows = db.prepare(
1283
+ `SELECT DISTINCT memory_id
1284
+ FROM memory_facts
1285
+ WHERE user_id = ? AND agent_id = ?
1286
+ AND (? = '' OR COALESCE(valid_from, event_time, created_at) <= ?)
1287
+ AND (? = '' OR valid_to IS NULL OR valid_to > ?)
1288
+ AND (? = '' OR COALESCE(learned_at, created_at) <= ?)`
1289
+ ).all(
1290
+ userId,
1291
+ agentId,
1292
+ validAt,
1293
+ validAt,
1294
+ validAt,
1295
+ validAt,
1296
+ knownAt,
1297
+ knownAt,
1298
+ );
1299
+ const eligibleIds = new Set(eligibleRows.map((row) => row.memory_id));
1300
+ all = all.filter((memory) => eligibleIds.has(memory.id));
1301
+ }
1302
+
1245
1303
  if (!all.length) return [];
1246
1304
 
1247
1305
  const queryVec = await getEmbedding(query, await getActiveProvider(userId, agentId));
@@ -1444,6 +1502,9 @@ class MemoryManager {
1444
1502
  }
1445
1503
 
1446
1504
  updateCore(userId, key, value, options = {}) {
1505
+ if (options.confirmed !== true) {
1506
+ throw new Error('Core memory updates require explicit confirmation.');
1507
+ }
1447
1508
  const agentId = this._agentId(userId, options);
1448
1509
  const strVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
1449
1510
  db.prepare(
@@ -1458,6 +1519,111 @@ class MemoryManager {
1458
1519
  db.prepare(`DELETE FROM core_memory WHERE user_id = ? AND agent_id = ? AND key = ?`).run(userId, agentId, key);
1459
1520
  }
1460
1521
 
1522
+ listFacts(userId, options = {}) {
1523
+ const agentId = this._agentId(userId, options);
1524
+ const status = String(options.status || '').trim();
1525
+ const subject = String(options.subject || '').trim();
1526
+ const predicate = String(options.predicate || '').trim();
1527
+ const validAt = String(options.validAt || '').trim();
1528
+ const knownAt = String(options.knownAt || '').trim();
1529
+ const limit = Math.max(1, Math.min(Number(options.limit) || 100, 500));
1530
+ return db.prepare(
1531
+ `SELECT
1532
+ f.id, f.memory_id, f.subject, f.predicate, f.object, f.category,
1533
+ f.confidence, f.event_time, f.valid_from, f.valid_to, f.learned_at,
1534
+ f.invalidated_at, f.status, f.supersedes_fact_id, f.metadata_json,
1535
+ f.created_at, f.updated_at,
1536
+ m.source_type, m.source_id, m.source_label
1537
+ FROM memory_facts f
1538
+ JOIN memories m ON m.id = f.memory_id
1539
+ WHERE f.user_id = ? AND f.agent_id = ?
1540
+ AND (? = '' OR f.status = ?)
1541
+ AND (? = '' OR lower(f.subject) = lower(?))
1542
+ AND (? = '' OR lower(f.predicate) = lower(?))
1543
+ AND (? = '' OR COALESCE(f.valid_from, f.event_time, f.created_at) <= ?)
1544
+ AND (? = '' OR f.valid_to IS NULL OR f.valid_to > ?)
1545
+ AND (? = '' OR COALESCE(f.learned_at, f.created_at) <= ?)
1546
+ ORDER BY COALESCE(f.learned_at, f.created_at) DESC
1547
+ LIMIT ?`
1548
+ ).all(
1549
+ userId,
1550
+ agentId,
1551
+ status,
1552
+ status,
1553
+ subject,
1554
+ subject,
1555
+ predicate,
1556
+ predicate,
1557
+ validAt,
1558
+ validAt,
1559
+ validAt,
1560
+ validAt,
1561
+ knownAt,
1562
+ knownAt,
1563
+ limit,
1564
+ ).map((row) => ({
1565
+ id: row.id,
1566
+ memoryId: row.memory_id,
1567
+ subject: row.subject,
1568
+ predicate: row.predicate,
1569
+ object: row.object,
1570
+ category: row.category,
1571
+ confidence: Number(row.confidence || 0),
1572
+ eventTime: row.event_time || null,
1573
+ validFrom: row.valid_from || null,
1574
+ validTo: row.valid_to || null,
1575
+ learnedAt: row.learned_at || row.created_at,
1576
+ invalidatedAt: row.invalidated_at || null,
1577
+ status: row.status || 'active',
1578
+ supersedesFactId: row.supersedes_fact_id || null,
1579
+ sourceRef: {
1580
+ sourceType: row.source_type || null,
1581
+ sourceId: row.source_id || null,
1582
+ sourceLabel: row.source_label || null,
1583
+ },
1584
+ metadata: parseJsonObject(row.metadata_json, {}),
1585
+ }));
1586
+ }
1587
+
1588
+ reconcileFacts(userId, options = {}) {
1589
+ const agentId = this._agentId(userId, options);
1590
+ const minimumConfidence = Math.min(Math.max(Number(options.minimumConfidence ?? 0.35), 0), 1);
1591
+ const now = new Date().toISOString();
1592
+ const expired = db.prepare(
1593
+ `UPDATE memory_facts
1594
+ SET status = 'expired', invalidated_at = COALESCE(invalidated_at, datetime('now')),
1595
+ updated_at = datetime('now')
1596
+ WHERE user_id = ? AND agent_id = ? AND status = 'active'
1597
+ AND valid_to IS NOT NULL AND valid_to <= ?`
1598
+ ).run(userId, agentId, now).changes;
1599
+ const lowConfidence = db.prepare(
1600
+ `UPDATE memory_facts
1601
+ SET status = 'review', updated_at = datetime('now')
1602
+ WHERE user_id = ? AND agent_id = ? AND status = 'active' AND confidence < ?`
1603
+ ).run(userId, agentId, minimumConfidence).changes;
1604
+ const duplicates = db.prepare(
1605
+ `SELECT subject, predicate, object, GROUP_CONCAT(id) AS ids, COUNT(*) AS count
1606
+ FROM memory_facts
1607
+ WHERE user_id = ? AND agent_id = ? AND status = 'active'
1608
+ GROUP BY lower(subject), lower(predicate), lower(object)
1609
+ HAVING COUNT(*) > 1`
1610
+ ).all(userId, agentId);
1611
+ let invalidatedDuplicates = 0;
1612
+ for (const group of duplicates) {
1613
+ const ids = String(group.ids || '').split(',').filter(Boolean);
1614
+ const keep = ids.pop();
1615
+ for (const id of ids) {
1616
+ invalidatedDuplicates += db.prepare(
1617
+ `UPDATE memory_facts
1618
+ SET status = 'duplicate', invalidated_at = datetime('now'),
1619
+ supersedes_fact_id = COALESCE(supersedes_fact_id, ?), updated_at = datetime('now')
1620
+ WHERE id = ?`
1621
+ ).run(keep, id).changes;
1622
+ }
1623
+ }
1624
+ return { expired, lowConfidence, invalidatedDuplicates };
1625
+ }
1626
+
1461
1627
  // ─────────────────────────────────────────────────────────────────────────
1462
1628
  // Conversation State
1463
1629
  // ─────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const db = require('../../db/database');
5
+ const { AGENT_DATA_DIR } = require('../../../runtime/paths');
6
+
7
+ const SKILLS_ROOT = path.resolve(AGENT_DATA_DIR, 'skills');
8
+ const SECRET_KEY = /(^|_)(secret|token|password|api_key|private_key)($|_)/i;
9
+ const SHELL_METACHAR_RE = /[;&|`$\n\r(){}\\<>]/;
10
+
11
+ function walkObject(value, visitor, currentPath = '') {
12
+ if (!value || typeof value !== 'object') return;
13
+ for (const [key, child] of Object.entries(value)) {
14
+ const nextPath = currentPath ? `${currentPath}.${key}` : key;
15
+ visitor(key, child, nextPath);
16
+ walkObject(child, visitor, nextPath);
17
+ }
18
+ }
19
+
20
+ class CapabilityAuditService {
21
+ constructor(options = {}) {
22
+ this.mcpClient = options.mcpClient;
23
+ this.skillRunner = options.skillRunner;
24
+ }
25
+
26
+ auditMcp(userId, options = {}) {
27
+ const rows = db.prepare(
28
+ 'SELECT id, name, command, config, enabled FROM mcp_servers WHERE user_id = ?'
29
+ ).all(userId);
30
+ return rows.map((row) => {
31
+ const findings = [];
32
+ let config = {};
33
+ try { config = JSON.parse(row.config || '{}'); } catch {
34
+ findings.push({ severity: 'high', code: 'invalid_config_json', message: 'Configuration is not valid JSON.' });
35
+ }
36
+ walkObject(config, (key, value, fieldPath) => {
37
+ if (SECRET_KEY.test(key) && typeof value === 'string' && value && !value.startsWith('enc:v1:')) {
38
+ findings.push({
39
+ severity: 'high',
40
+ code: 'plaintext_secret',
41
+ message: `Sensitive field ${fieldPath} is stored without application encryption.`,
42
+ });
43
+ }
44
+ });
45
+ try {
46
+ const endpoint = new URL(row.command);
47
+ if (!['https:', 'http:'].includes(endpoint.protocol)) {
48
+ findings.push({ severity: 'high', code: 'unsupported_transport', message: 'MCP endpoint uses an unsupported protocol.' });
49
+ }
50
+ } catch {
51
+ findings.push({ severity: 'high', code: 'invalid_endpoint', message: 'MCP endpoint is not a valid URL.' });
52
+ }
53
+ const tools = this.mcpClient?.getAllTools?.(userId, options) || [];
54
+ for (const tool of tools.filter((item) => Number(item.serverId) === Number(row.id))) {
55
+ if (!tool.name || !tool.inputSchema || tool.inputSchema.type !== 'object') {
56
+ findings.push({
57
+ severity: 'medium',
58
+ code: 'invalid_tool_schema',
59
+ message: `Tool ${tool.name || 'unknown'} does not expose a valid object input schema.`,
60
+ });
61
+ }
62
+ }
63
+ return {
64
+ id: row.id,
65
+ name: row.name,
66
+ enabled: Boolean(row.enabled),
67
+ findingCount: findings.length,
68
+ findings,
69
+ };
70
+ });
71
+ }
72
+
73
+ auditSkills() {
74
+ return Array.from(this.skillRunner?.skills?.values?.() || []).map((skill) => {
75
+ const findings = [];
76
+ const resolvedPath = path.resolve(skill.filePath);
77
+ const relative = path.relative(SKILLS_ROOT, resolvedPath);
78
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
79
+ findings.push({ severity: 'high', code: 'path_escape', message: 'Skill file is outside the managed skills directory.' });
80
+ }
81
+ const command = String(skill.metadata?.command || '');
82
+ if (command) {
83
+ const bare = command.replace(/\{[^{}]*\}/g, '');
84
+ if (SHELL_METACHAR_RE.test(bare)) {
85
+ findings.push({ severity: 'high', code: 'unsafe_command_template', message: 'Command template contains unsafe shell metacharacters.' });
86
+ }
87
+ }
88
+ const dependencies = Array.isArray(skill.metadata?.dependencies) ? skill.metadata.dependencies : [];
89
+ for (const dependency of dependencies) {
90
+ if (typeof dependency !== 'string' || !/^(?:@[\w.-]+\/)?[\w.-]+(?:@[\w.*^~<>=|-]+)?$/.test(dependency)) {
91
+ findings.push({ severity: 'medium', code: 'untrusted_dependency', message: 'Dependency metadata contains an unsupported package reference.' });
92
+ }
93
+ }
94
+ return {
95
+ name: skill.name,
96
+ enabled: skill.metadata?.enabled !== false,
97
+ autoCreated: skill.metadata?.auto_created === true,
98
+ findingCount: findings.length,
99
+ findings,
100
+ };
101
+ });
102
+ }
103
+ }
104
+
105
+ module.exports = {
106
+ CapabilityAuditService,
107
+ };
@@ -3,6 +3,7 @@
3
3
  module.exports = [
4
4
  require('./schedule'),
5
5
  require('./manual'),
6
+ require('./webhook'),
6
7
  require('./gmail_message_received'),
7
8
  require('./outlook_email_received'),
8
9
  require('./slack_message_received'),
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ type: 'webhook',
5
+ label: 'Signed Webhook',
6
+ async validateConfig(config = {}) {
7
+ return {
8
+ sourceLabel: String(config.sourceLabel || config.source_label || '').trim() || null,
9
+ };
10
+ },
11
+ summarize(config = {}) {
12
+ return config.sourceLabel ? `Signed webhook from ${config.sourceLabel}` : 'Signed webhook';
13
+ },
14
+ };
@@ -194,7 +194,7 @@ class TaskRuntime {
194
194
  label: adapter.label,
195
195
  providerKey: adapter.providerKey || null,
196
196
  appKey: adapter.appKey || null,
197
- available: adapter.type === 'schedule' || adapter.type === 'manual'
197
+ available: ['schedule', 'manual', 'webhook'].includes(adapter.type)
198
198
  ? true
199
199
  : this._hasConnectedApp(userId, agentId, adapter.providerKey, adapter.appKey),
200
200
  }));
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { v4: uuidv4 } = require('uuid');
5
+ const db = require('../../db/database');
6
+ const { encryptValue, decryptValue } = require('../integrations/secrets');
7
+
8
+ const MAX_PAYLOAD_BYTES = 256 * 1024;
9
+ const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
10
+
11
+ function safeEqual(left, right) {
12
+ const a = Buffer.from(String(left || ''), 'utf8');
13
+ const b = Buffer.from(String(right || ''), 'utf8');
14
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
15
+ }
16
+
17
+ class TaskWebhookService {
18
+ constructor(options = {}) {
19
+ this.taskRuntime = options.taskRuntime;
20
+ }
21
+
22
+ rotateSecret(userId, taskId) {
23
+ const task = this.taskRuntime.taskRepository.getTaskById(taskId, userId);
24
+ if (!task) throw new Error('Task not found.');
25
+ if (task.trigger_type !== 'webhook') throw new Error('Task does not use the webhook trigger.');
26
+ const secret = crypto.randomBytes(32).toString('base64url');
27
+ const fingerprint = crypto.createHash('sha256').update(secret).digest('hex').slice(0, 16);
28
+ db.prepare(
29
+ `INSERT INTO task_webhook_secrets (
30
+ task_id, user_id, secret_encrypted, secret_fingerprint
31
+ ) VALUES (?, ?, ?, ?)
32
+ ON CONFLICT(task_id) DO UPDATE SET
33
+ secret_encrypted = excluded.secret_encrypted,
34
+ secret_fingerprint = excluded.secret_fingerprint,
35
+ rotated_at = datetime('now')`
36
+ ).run(taskId, userId, encryptValue(secret), fingerprint);
37
+ return { taskId, secret, fingerprint };
38
+ }
39
+
40
+ getConfiguration(userId, taskId) {
41
+ const task = this.taskRuntime.taskRepository.getTaskById(taskId, userId);
42
+ if (!task) throw new Error('Task not found.');
43
+ const row = db.prepare(
44
+ `SELECT secret_fingerprint, rotated_at, created_at
45
+ FROM task_webhook_secrets WHERE task_id = ? AND user_id = ?`
46
+ ).get(taskId, userId);
47
+ return {
48
+ taskId,
49
+ configured: Boolean(row),
50
+ fingerprint: row?.secret_fingerprint || null,
51
+ rotatedAt: row?.rotated_at || null,
52
+ createdAt: row?.created_at || null,
53
+ };
54
+ }
55
+
56
+ listDeliveries(userId, taskId, limit = 50) {
57
+ return db.prepare(
58
+ `SELECT id, request_id, payload_hash, status, response_json, created_at, completed_at
59
+ FROM task_webhook_deliveries
60
+ WHERE task_id = ? AND user_id = ?
61
+ ORDER BY created_at DESC LIMIT ?`
62
+ ).all(taskId, userId, Math.min(Math.max(Number(limit || 50), 1), 200)).map((row) => ({
63
+ id: row.id,
64
+ requestId: row.request_id,
65
+ payloadHash: row.payload_hash,
66
+ status: row.status,
67
+ response: (() => {
68
+ try { return JSON.parse(row.response_json || '{}'); } catch { return {}; }
69
+ })(),
70
+ createdAt: row.created_at,
71
+ completedAt: row.completed_at,
72
+ }));
73
+ }
74
+
75
+ async deliver(taskId, headers, rawBody, payload) {
76
+ if (Buffer.byteLength(rawBody || '', 'utf8') > MAX_PAYLOAD_BYTES) {
77
+ const error = new Error('Webhook payload exceeds 256 KiB.');
78
+ error.status = 413;
79
+ throw error;
80
+ }
81
+ const timestamp = Number(headers['x-neoagent-timestamp']);
82
+ const requestId = String(headers['x-neoagent-request-id'] || '').trim();
83
+ const supplied = String(headers['x-neoagent-signature'] || '').replace(/^sha256=/, '');
84
+ if (!requestId || !Number.isFinite(timestamp) || !supplied) {
85
+ const error = new Error('Missing webhook authentication headers.');
86
+ error.status = 401;
87
+ throw error;
88
+ }
89
+ if (Math.abs(Date.now() - timestamp) > MAX_CLOCK_SKEW_MS) {
90
+ const error = new Error('Webhook timestamp is outside the allowed window.');
91
+ error.status = 401;
92
+ throw error;
93
+ }
94
+ const secretRow = db.prepare(
95
+ `SELECT secrets.user_id, secrets.secret_encrypted, tasks.enabled, tasks.trigger_type
96
+ FROM task_webhook_secrets AS secrets
97
+ JOIN scheduled_tasks AS tasks ON tasks.id = secrets.task_id
98
+ WHERE secrets.task_id = ?`
99
+ ).get(taskId);
100
+ if (!secretRow || !secretRow.enabled || secretRow.trigger_type !== 'webhook') {
101
+ const error = new Error('Webhook task is unavailable.');
102
+ error.status = 404;
103
+ throw error;
104
+ }
105
+ const expected = crypto.createHmac('sha256', decryptValue(secretRow.secret_encrypted))
106
+ .update(`${timestamp}.${rawBody || ''}`)
107
+ .digest('hex');
108
+ if (!safeEqual(expected, supplied)) {
109
+ const error = new Error('Invalid webhook signature.');
110
+ error.status = 401;
111
+ throw error;
112
+ }
113
+ const deliveryId = uuidv4();
114
+ const payloadHash = crypto.createHash('sha256').update(rawBody || '').digest('hex');
115
+ try {
116
+ db.prepare(
117
+ `INSERT INTO task_webhook_deliveries (
118
+ id, task_id, user_id, request_id, payload_hash, status
119
+ ) VALUES (?, ?, ?, ?, ?, 'accepted')`
120
+ ).run(deliveryId, taskId, secretRow.user_id, requestId, payloadHash);
121
+ } catch (err) {
122
+ if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
123
+ const replayError = new Error('Webhook request ID was already used.');
124
+ replayError.status = 409;
125
+ throw replayError;
126
+ }
127
+ throw err;
128
+ }
129
+ const result = await this.taskRuntime.fireTaskFromTrigger(taskId, secretRow.user_id, {
130
+ fingerprint: requestId,
131
+ timestamp: new Date(timestamp).toISOString(),
132
+ context: { webhook: payload },
133
+ });
134
+ db.prepare(
135
+ `UPDATE task_webhook_deliveries
136
+ SET status = ?, response_json = ?, completed_at = datetime('now')
137
+ WHERE id = ?`
138
+ ).run(result?.error ? 'failed' : 'completed', JSON.stringify(result || {}), deliveryId);
139
+ return { deliveryId, accepted: !result?.error, result };
140
+ }
141
+ }
142
+
143
+ module.exports = {
144
+ TaskWebhookService,
145
+ MAX_PAYLOAD_BYTES,
146
+ };