neoagent 2.4.2-beta.5 → 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.
- package/README.md +48 -18
- package/flutter_app/lib/main_models.dart +28 -0
- package/flutter_app/lib/main_settings.dart +9 -0
- package/flutter_app/lib/src/backend_client.dart +8 -0
- package/package.json +1 -1
- package/server/db/database.js +112 -0
- package/server/http/routes.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +10069 -10049
- package/server/routes/mcp.js +9 -0
- package/server/routes/memory.js +35 -2
- package/server/routes/runtime.js +7 -1
- package/server/routes/settings.js +34 -1
- package/server/routes/skills.js +42 -0
- package/server/routes/task_webhooks.js +65 -0
- package/server/services/ai/engine.js +525 -24
- package/server/services/ai/learning.js +56 -8
- package/server/services/ai/providers/anthropic.js +40 -8
- package/server/services/ai/providers/google.js +10 -0
- package/server/services/ai/providers/openai.js +3 -15
- package/server/services/ai/providers/openaiCompatible.js +11 -0
- package/server/services/ai/repetitionGuard.js +60 -0
- package/server/services/ai/systemPrompt.js +52 -16
- package/server/services/ai/taskAnalysis.js +15 -6
- package/server/services/ai/toolEvidence.js +3 -1
- package/server/services/ai/toolRunner.js +43 -1
- package/server/services/ai/toolSelector.js +92 -46
- package/server/services/ai/tools.js +89 -9
- package/server/services/ai/usage.js +114 -0
- package/server/services/manager.js +34 -0
- package/server/services/memory/manager.js +170 -4
- package/server/services/security/capability_audit.js +107 -0
- package/server/services/tasks/adapters/index.js +1 -0
- package/server/services/tasks/adapters/webhook.js +14 -0
- package/server/services/tasks/runtime.js +1 -1
- package/server/services/tasks/webhooks.js +146 -0
- package/server/services/workspace/code_navigation.js +194 -0
- package/server/services/workspace/structured_data.js +93 -0
- package/server/utils/cloud-security.js +24 -2
|
@@ -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,
|
|
939
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
|
@@ -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:
|
|
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
|
+
};
|