neoagent 3.0.1-beta.5 → 3.0.1-beta.7

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 CHANGED
@@ -43,9 +43,8 @@ npm install -g neoagent
43
43
  neoagent install
44
44
  ```
45
45
 
46
- <p>
46
+ <p align="center">
47
47
  <a href="https://github.com/NeoLabs-Systems/NeoAgent/releases/latest"><img alt="Download macOS app" src="https://img.shields.io/badge/macOS_app-download-black?style=flat-square&logo=apple&logoColor=white"></a>
48
- &nbsp;The macOS desktop app includes a graphical installer — open it and use the <strong>Server</strong> tab instead of the CLI.
49
48
  </p>
50
49
 
51
50
  Open `http://localhost:3333`, create the first account, and configure a model.
@@ -910,7 +910,7 @@ p { margin: 0; text-wrap: pretty; }
910
910
  <div class="wrap">
911
911
  <div class="hero-grid">
912
912
  <div class="hero-copy">
913
- <div class="pill"><span class="tag">New</span> <b>NeoAgent is now in private beta</b></div>
913
+ <div class="pill"><span class="tag">New</span> <b>NeoAgent is now in beta</b></div>
914
914
  <h1>
915
915
  <span class="h1-line"><span class="w" style="--i:0">One</span> <span class="w" style="--i:1">agent</span> <span class="w" style="--i:2">for</span> <span class="w" style="--i:3">your</span></span>
916
916
  <span class="h1-line"><span class="w" style="--i:4"><em>whole</em></span> <span class="w" style="--i:5"><em>digital</em></span> <span class="w" style="--i:6"><em>life</em></span></span>
@@ -1194,7 +1194,7 @@ p { margin: 0; text-wrap: pretty; }
1194
1194
  <section class="section-tight" id="cta" data-screen-label="cta">
1195
1195
  <div class="wrap">
1196
1196
  <div class="final reveal">
1197
- <span class="eyebrow" style="display:block;margin-bottom:20px;">Private beta · Limited spots</span>
1197
+ <span class="eyebrow" style="display:block;margin-bottom:20px;">Beta · Limited spots</span>
1198
1198
  <h2>The agent that just <em>handles it.</em></h2>
1199
1199
  <p>Be first to put NeoAgent to work across your devices, channels, and life. We'll send your invite as spots open up.</p>
1200
1200
  <div class="hero-actions" style="justify-content:center;">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.0.1-beta.5",
3
+ "version": "3.0.1-beta.7",
4
4
  "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -54,7 +54,8 @@
54
54
  "benchmark:tokens": "node scripts/benchmark-token-cost.js",
55
55
  "benchmark:memory": "node scripts/benchmark-memory.js",
56
56
  "benchmark:memorybench:install": "node scripts/install-memorybench-provider.js",
57
- "release": "npx semantic-release"
57
+ "release": "npx semantic-release",
58
+ "audit:ci": "npm audit --omit=dev --audit-level=high"
58
59
  },
59
60
  "repository": {
60
61
  "type": "git",
@@ -103,7 +104,7 @@
103
104
  "telnyx": "^5.51.0",
104
105
  "tesseract.js": "^7.0.0",
105
106
  "uuid": "^11.1.0",
106
- "ws": "^8.19.0"
107
+ "ws": "^8.21.0"
107
108
  },
108
109
  "overrides": {
109
110
  "axios": "^1.15.2",
@@ -113,7 +114,8 @@
113
114
  "protobufjs": "^7.5.5",
114
115
  "serialize-javascript": "^7.0.5",
115
116
  "undici": "^6.24.0",
116
- "webpackbar": "^7.0.0"
117
+ "webpackbar": "^7.0.0",
118
+ "ws": "^8.21.0"
117
119
  },
118
120
  "devDependencies": {
119
121
  "@docusaurus/core": "3.10.0",
@@ -1879,7 +1879,7 @@ function migrateIntegrationProviderConfigsTable() {
1879
1879
  }
1880
1880
 
1881
1881
  function migrateIntegrationSecretStorage() {
1882
- try {
1882
+ const migrate = db.transaction(() => {
1883
1883
  const connectionRows = db
1884
1884
  .prepare('SELECT id, credentials_json FROM integration_connections')
1885
1885
  .all();
@@ -1891,11 +1891,7 @@ function migrateIntegrationSecretStorage() {
1891
1891
  if (!current || isEncryptedValue(current)) continue;
1892
1892
  updateConnection.run(encryptValue(current), row.id);
1893
1893
  }
1894
- } catch {
1895
- // Preserve startup even if a row cannot be re-encrypted.
1896
- }
1897
1894
 
1898
- try {
1899
1895
  const stateRows = db
1900
1896
  .prepare('SELECT id, code_verifier FROM integration_oauth_states')
1901
1897
  .all();
@@ -1907,11 +1903,7 @@ function migrateIntegrationSecretStorage() {
1907
1903
  if (!current || isEncryptedValue(current)) continue;
1908
1904
  updateState.run(encryptValue(current), row.id);
1909
1905
  }
1910
- } catch {
1911
- // Preserve startup even if a row cannot be re-encrypted.
1912
- }
1913
1906
 
1914
- try {
1915
1907
  const providerRows = db
1916
1908
  .prepare('SELECT id, config_json FROM integration_provider_configs')
1917
1909
  .all();
@@ -1923,8 +1915,13 @@ function migrateIntegrationSecretStorage() {
1923
1915
  if (!current || isEncryptedValue(current)) continue;
1924
1916
  updateProviderConfig.run(encryptValue(current), row.id);
1925
1917
  }
1926
- } catch {
1927
- // Preserve startup even if a row cannot be re-encrypted.
1918
+ });
1919
+
1920
+ try {
1921
+ migrate();
1922
+ } catch (err) {
1923
+ console.error('[DB] Integration secret migration failed — startup cannot continue with partially migrated secrets:', err.message);
1924
+ throw err;
1928
1925
  }
1929
1926
  }
1930
1927
 
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ const Sqlite = require('better-sqlite3');
4
+ const { DATA_DIR } = require('../../runtime/paths');
5
+
6
+ const sessionsDb = new Sqlite(`${DATA_DIR}/sessions.db`);
7
+
8
+ module.exports = sessionsDb;
@@ -1,15 +1,13 @@
1
1
  'use strict';
2
2
 
3
3
  const session = require('express-session');
4
- const Sqlite = require('better-sqlite3');
5
4
  const SQLiteStore = require('better-sqlite3-session-store')(session);
6
5
  const helmet = require('helmet');
7
6
  const cors = require('cors');
8
- const { DATA_DIR } = require('../../runtime/paths');
9
7
  const { logRequestSummary } = require('../utils/logger');
10
8
  const { getSessionSecret } = require('../services/account/session_secret');
11
9
 
12
- const sessionsDb = new Sqlite(`${DATA_DIR}/sessions.db`);
10
+ const sessionsDb = require('../db/sessions_db');
13
11
  const LEGACY_SESSION_EXPIRE_FALLBACK = 0;
14
12
 
15
13
  function boolEnv(name, fallback = false) {
@@ -89,7 +87,9 @@ function buildHelmetOptions({ secureCookies }) {
89
87
  }
90
88
 
91
89
  return {
92
- strictTransportSecurity: false,
90
+ strictTransportSecurity: secureCookies
91
+ ? { maxAge: Number(process.env.NEOAGENT_HSTS_MAX_AGE ?? 86400), includeSubDomains: false }
92
+ : false,
93
93
  crossOriginOpenerPolicy: false,
94
94
  crossOriginResourcePolicy: { policy: 'same-site' },
95
95
  originAgentCluster: false,
@@ -1 +1 @@
1
- 5a04b7963f3a3e683486fb212ebbfe09
1
+ 60f1b48c2d24c4a137efc1a6529d413f
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"77e2e94772b6eb43759e34ed1ad7da4674e19c
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "68949357" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "2202305600" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -135597,7 +135597,7 @@ r===$&&A.b()
135597
135597
  p.push(A.jV(q,A.jd(!1,new A.a_(B.uM,A.db(new A.cC(B.jA,new A.a89(r,q),q),q,q),q),!1,B.I,!0),q,q,0,0,0,q))}r=!1
135598
135598
  if(!s.ay)if(!s.ch){r=s.e
135599
135599
  r===$&&A.b()
135600
- r=B.b.u("mqimox8o-0a587b7").length!==0&&r.b}if(r){r=s.d
135600
+ r=B.b.u("mqj7s6w1-467cdbb").length!==0&&r.b}if(r){r=s.d
135601
135601
  r===$&&A.b()
135602
135602
  r=r.aP&&!r.ai?84:0
135603
135603
  s=s.e
@@ -141319,7 +141319,7 @@ $S:0}
141319
141319
  A.a_p.prototype={}
141320
141320
  A.T7.prototype={
141321
141321
  nd(a){var s=this
141322
- if(B.b.u("mqimox8o-0a587b7").length===0||s.a!=null)return
141322
+ if(B.b.u("mqj7s6w1-467cdbb").length===0||s.a!=null)return
141323
141323
  s.B_()
141324
141324
  s.a=A.ot(B.S2,new A.bde(s))},
141325
141325
  B_(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
@@ -141337,7 +141337,7 @@ if(!t.f.b(k)){s=1
141337
141337
  break}i=J.a3(k,"buildId")
141338
141338
  h=i==null?null:B.b.u(J.p(i))
141339
141339
  j=h==null?"":h
141340
- if(J.bj(j)===0||J.e(j,"mqimox8o-0a587b7")){s=1
141340
+ if(J.bj(j)===0||J.e(j,"mqj7s6w1-467cdbb")){s=1
141341
141341
  break}n.b=!0
141342
141342
  n.F()
141343
141343
  p=2
@@ -141354,7 +141354,7 @@ case 2:return A.i(o.at(-1),r)}})
141354
141354
  return A.k($async$B_,r)},
141355
141355
  vI(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
141356
141356
  var $async$vI=A.h(function(a2,a3){if(a2===1){o.push(a3)
141357
- s=p}for(;;)switch(s){case 0:if(B.b.u("mqimox8o-0a587b7").length===0||n.c){s=1
141357
+ s=p}for(;;)switch(s){case 0:if(B.b.u("mqj7s6w1-467cdbb").length===0||n.c){s=1
141358
141358
  break}n.c=!0
141359
141359
  n.F()
141360
141360
  p=4
@@ -64,8 +64,11 @@ router.get('/login', (req, res) => {
64
64
 
65
65
  router.post('/api/login', loginLimiter, express.json(), async (req, res) => {
66
66
  const { username, password } = req.body || {};
67
- const expectedUsername = process.env.ADMIN_USERNAME || 'admin';
68
- const expectedPassword = process.env.ADMIN_PASSWORD || 'admin';
67
+ const expectedUsername = process.env.ADMIN_USERNAME;
68
+ const expectedPassword = process.env.ADMIN_PASSWORD;
69
+ if (!expectedUsername || !expectedPassword) {
70
+ return res.status(503).json({ error: 'Admin interface is not configured. Set ADMIN_USERNAME and ADMIN_PASSWORD environment variables.' });
71
+ }
69
72
  if (username !== expectedUsername || password !== expectedPassword) {
70
73
  return res.status(401).json({ error: 'Invalid credentials' });
71
74
  }
@@ -81,11 +84,14 @@ router.post('/api/login', loginLimiter, express.json(), async (req, res) => {
81
84
  res.json({ ok: false, requiresTwoFactor: true });
82
85
  });
83
86
  }
84
- req.session.isAdmin = true;
85
- req.session.cookie.maxAge = ADMIN_SESSION_TTL;
86
- req.session.save((err) => {
87
+ req.session.regenerate((err) => {
87
88
  if (err) return res.status(500).json({ error: 'Session error' });
88
- res.json({ ok: true });
89
+ req.session.isAdmin = true;
90
+ req.session.cookie.maxAge = ADMIN_SESSION_TTL;
91
+ req.session.save((saveErr) => {
92
+ if (saveErr) return res.status(500).json({ error: 'Session error' });
93
+ res.json({ ok: true });
94
+ });
89
95
  });
90
96
  });
91
97
 
@@ -99,12 +105,14 @@ router.post('/api/2fa/verify', loginLimiter, express.json(), async (req, res) =>
99
105
  const adminTwoFactor = require('../services/account/admin_two_factor');
100
106
  const valid = await adminTwoFactor.verifyCode(code);
101
107
  if (!valid) return res.status(401).json({ error: 'Invalid code — try again' });
102
- req.session.adminPendingTwoFactor = false;
103
- req.session.isAdmin = true;
104
- req.session.cookie.maxAge = ADMIN_SESSION_TTL;
105
- req.session.save((err) => {
108
+ req.session.regenerate((err) => {
106
109
  if (err) return res.status(500).json({ error: 'Session error' });
107
- res.json({ ok: true });
110
+ req.session.isAdmin = true;
111
+ req.session.cookie.maxAge = ADMIN_SESSION_TTL;
112
+ req.session.save((saveErr) => {
113
+ if (saveErr) return res.status(500).json({ error: 'Session error' });
114
+ res.json({ ok: true });
115
+ });
108
116
  });
109
117
  } catch (err) {
110
118
  res.status(500).json({ error: err.message });
@@ -559,7 +567,11 @@ router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
559
567
  const { DATA_DIR } = require('../../runtime/paths');
560
568
  const { id } = req.params;
561
569
  if (!id) return res.status(400).json({ error: 'Missing user id' });
570
+ if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'Invalid user id' });
562
571
  try {
572
+ const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
573
+ if (!user) return res.status(404).json({ error: 'User not found' });
574
+
563
575
  // Collect artifact paths before deletion
564
576
  const artifacts = db.prepare('SELECT storage_path FROM artifacts WHERE user_id = ?').all(id);
565
577
 
@@ -585,16 +597,22 @@ router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
585
597
  });
586
598
  erase(id);
587
599
 
588
- // Clean up artifact files on disk
600
+ // Clean up artifact files on disk — containment-checked
601
+ const artifactsRoot = path.resolve(path.join(DATA_DIR, 'artifacts'));
589
602
  for (const artifact of artifacts) {
590
603
  try {
591
- const abs = path.isAbsolute(artifact.storage_path)
604
+ const abs = path.resolve(path.isAbsolute(artifact.storage_path)
592
605
  ? artifact.storage_path
593
- : path.join(DATA_DIR, artifact.storage_path);
594
- fs.rmSync(abs, { force: true });
606
+ : path.join(DATA_DIR, artifact.storage_path));
607
+ if (abs.startsWith(artifactsRoot + path.sep)) {
608
+ fs.rmSync(abs, { force: true });
609
+ }
595
610
  } catch {}
596
611
  }
597
- try { fs.rmSync(path.join(DATA_DIR, 'artifacts', id), { recursive: true, force: true }); } catch {}
612
+ const userArtifactDir = path.resolve(path.join(DATA_DIR, 'artifacts', id));
613
+ if (userArtifactDir.startsWith(artifactsRoot + path.sep)) {
614
+ try { fs.rmSync(userArtifactDir, { recursive: true, force: true }); } catch {}
615
+ }
598
616
 
599
617
  res.json({ ok: true });
600
618
  } catch (err) {
@@ -16,7 +16,7 @@ router.post('/geofence', async (req, res) => {
16
16
  const userRow = db.prepare('SELECT id FROM users WHERE id = ?').get(req.session.userId);
17
17
  if (!userRow) return res.status(401).json({ error: 'Unauthorized' });
18
18
 
19
- console.log(`[Triggers] Geofence entered: ${label} by user ${req.session.userId}`);
19
+ console.log(`[Triggers] Geofence event for user ${req.session.userId}`);
20
20
 
21
21
  res.json({ success: true, message: 'Geofence trigger processed' });
22
22
  } catch (err) {
@@ -51,7 +51,7 @@ router.post('/notification', async (req, res) => {
51
51
  const userRow = db.prepare('SELECT id FROM users WHERE id = ?').get(req.session.userId);
52
52
  if (!userRow) return res.status(401).json({ error: 'Unauthorized' });
53
53
 
54
- console.log(`[Triggers] Notification received: ${app_package} - ${title} for user ${req.session.userId}`);
54
+ console.log(`[Triggers] Notification event for user ${req.session.userId}`);
55
55
 
56
56
  db.prepare(`
57
57
  INSERT INTO notification_history (user_id, app_package, title, body, action_taken)
@@ -1,19 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
- const Sqlite = require('better-sqlite3');
5
- const { DATA_DIR } = require('../../../runtime/paths');
6
4
  const db = require('../../db/database');
5
+ const sessionsDb = require('../../db/sessions_db');
7
6
  const { clientIpFromRequest, lookupIpLocation } = require('./geoip');
8
7
  const { getSessionSecret } = require('./session_secret');
9
8
 
10
- const sessionsDb = new Sqlite(`${DATA_DIR}/sessions.db`);
11
- try {
12
- sessionsDb.exec('CREATE TABLE IF NOT EXISTS sessions (sid PRIMARY KEY, sess, expire)');
13
- } catch {
14
- // The primary session middleware owns schema normalization.
15
- }
16
-
17
9
  function sessionHash(sessionId) {
18
10
  return crypto.createHmac('sha256', getSessionSecret()).update(String(sessionId || '')).digest('hex');
19
11
  }
@@ -21,7 +21,9 @@ const { shouldAcceptTaskComplete } = require('../completion');
21
21
  const { shortenRunId, summarizeForLog } = require('../logFormat');
22
22
  const { runConversation } = require('./conversation_loop');
23
23
  const {
24
+ buildChurnAssessmentPrompt,
24
25
  buildCompletionDecisionPrompt,
26
+ normalizeChurnAssessment,
25
27
  normalizeCompletionDecision,
26
28
  resolveRunGoalContext,
27
29
  } = require('./completion_judge');
@@ -812,6 +814,46 @@ class AgentEngine {
812
814
  };
813
815
  }
814
816
 
817
+ async assessChurnState({
818
+ provider,
819
+ providerName,
820
+ model,
821
+ messages,
822
+ analysis,
823
+ plan,
824
+ toolExecutions,
825
+ readOnlyCount,
826
+ alreadyRead,
827
+ iteration,
828
+ options,
829
+ }) {
830
+ const runMeta = options?.runId ? this.getRunMeta(options.runId) : null;
831
+ const goalContext = resolveRunGoalContext(runMeta, analysis, plan);
832
+ const response = await this.requestStructuredJson({
833
+ provider,
834
+ providerName,
835
+ model,
836
+ messages,
837
+ prompt: buildChurnAssessmentPrompt({
838
+ readOnlyCount,
839
+ alreadyRead,
840
+ goalContext,
841
+ toolExecutions,
842
+ iteration,
843
+ }),
844
+ maxTokens: 200,
845
+ normalize: (raw) => normalizeChurnAssessment(raw),
846
+ fallback: { assessment: 'churn', reason: 'churn assessment unavailable' },
847
+ reasoningEffort: this.getReasoningEffort(providerName, options),
848
+ telemetry: options,
849
+ phase: 'churn_assessment',
850
+ });
851
+ return {
852
+ assessment: response.value,
853
+ usage: response.usage,
854
+ };
855
+ }
856
+
815
857
  async refreshConversationState({
816
858
  conversationId,
817
859
  runId,
@@ -241,12 +241,61 @@ function normalizeCompletionDecision(raw, fallbackStatus = 'continue') {
241
241
  };
242
242
  }
243
243
 
244
+ /**
245
+ * Ask the model to self-assess whether it is making genuine progress or
246
+ * spinning in analysis-paralysis. This replaces the hardcoded nudge threshold
247
+ * with an AI-controlled signal: the model knows its own plan better than any
248
+ * regex over tool names can infer.
249
+ *
250
+ * Intentionally lightweight — the response is capped at 200 tokens and the
251
+ * prompt is self-contained so the model can answer without re-reading history.
252
+ */
253
+ function buildChurnAssessmentPrompt({
254
+ readOnlyCount,
255
+ alreadyRead,
256
+ goalContext,
257
+ toolExecutions,
258
+ iteration,
259
+ }) {
260
+ const lines = [
261
+ 'Return JSON only.',
262
+ 'Self-assess your current loop state — are you making genuine progress or spinning?',
263
+ 'Schema: {"assessment":"progressing|churn|blocked","reason":"one short concrete sentence"}',
264
+ '',
265
+ `Context: ${readOnlyCount} consecutive iteration(s) with only read/search/inspect operations — no concrete state changes yet.`,
266
+ alreadyRead ? `Already inspected: ${alreadyRead}.` : '',
267
+ goalContext.effectiveGoal ? `Goal: ${goalContext.effectiveGoal}` : '',
268
+ goalContext.successCriteria.length > 0
269
+ ? `Success criteria:\n${goalContext.successCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n')}`
270
+ : '',
271
+ `Iteration: ${iteration}`,
272
+ `Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 6) || 'none'}`,
273
+ '',
274
+ 'Assessment rules:',
275
+ '- "progressing": You are systematically gathering necessary context and the next concrete action is already determined — you know exactly what to do next.',
276
+ '- "churn": You are re-reading/re-searching information already in context, or exploring without a clear next concrete step. Accept the nudge and act.',
277
+ '- "blocked": No concrete action is available in this run. You have all the evidence needed to deliver a truthful final answer or a specific external blocker.',
278
+ ];
279
+ return lines.filter(Boolean).join('\n');
280
+ }
281
+
282
+ function normalizeChurnAssessment(raw) {
283
+ const allowed = new Set(['progressing', 'churn', 'blocked']);
284
+ const assessment = String(raw?.assessment || '').trim().toLowerCase();
285
+ return {
286
+ assessment: allowed.has(assessment) ? assessment : 'churn',
287
+ reason: String(raw?.reason || '').trim().slice(0, 300),
288
+ };
289
+ }
290
+
244
291
  module.exports = {
292
+ buildChurnAssessmentPrompt,
245
293
  buildCompletionDecisionPrompt,
246
294
  buildGoalContractPrompt,
247
295
  goalContractFromAnalysis,
248
296
  goalContractFromPlan,
249
297
  mergeGoalContracts,
298
+ normalizeChurnAssessment,
250
299
  normalizeCompletionDecision,
251
300
  normalizeGoalContract,
252
301
  resolveRunGoalContext,
@@ -49,7 +49,7 @@ const {
49
49
  selectDeliverableWorkflow,
50
50
  validateDeliverableExecution,
51
51
  } = require('../deliverables');
52
- const { buildLoopPolicy, resolveToolResultLimits } = require('../loopPolicy');
52
+ const { buildLoopPolicy, resolveToolResultLimits, resolveChurnNudgeThreshold } = require('../loopPolicy');
53
53
  const { globalHooks } = require('../hooks');
54
54
  const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('../completion');
55
55
  const { enforceRateLimits } = require('../rate_limits');
@@ -541,7 +541,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
541
541
  ensureDefaultAiSettings(userId, agentId);
542
542
  const aiSettings = getAiSettings(userId, agentId);
543
543
 
544
- enforceRateLimits(userId);
544
+ const { releaseReservation } = enforceRateLimits(userId);
545
+
546
+ try {
545
547
 
546
548
  const runId = options.runId || uuidv4();
547
549
  const conversationId = options.conversationId;
@@ -895,8 +897,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
895
897
  let directAnswerEligible = false;
896
898
  let analysisUsage = 0;
897
899
 
898
- try {
899
- if (options.skipTaskAnalysis === true) {
900
+ if (options.skipTaskAnalysis === true) {
900
901
  analysis = buildSkipTaskAnalysisResult(options.forceMode);
901
902
  } else {
902
903
  const analysisResult = await runWithModelFallback('task analysis', () => engine.analyzeTask({
@@ -1180,73 +1181,152 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1180
1181
  messages = steeringAtLoopStart.messages;
1181
1182
  messages = sanitizeConversationMessages(messages);
1182
1183
 
1183
- // Analysis-paralysis gate: fire at the start of every iteration where
1184
- // the agent has spent N turns only reading/listing/searching without
1185
- // taking any concrete action. Escalates in urgency each turn.
1184
+ // Analysis-paralysis gate (EVE-style: AI self-assessment instead of
1185
+ // hardcoded counters). The nudge threshold is derived from the goal
1186
+ // contract complexity/autonomy_level that the model set during task
1187
+ // analysis, making the loop budget sensitivity AI-controlled.
1188
+ //
1189
+ // Flow:
1190
+ // readOnlyCount >= maxConsecutiveReadOnlyIterations → hard safety net,
1191
+ // force wrap-up unconditionally (same as before).
1192
+ // readOnlyCount >= churnNudgeThreshold → ask the model to self-assess:
1193
+ // "progressing" → give grace (partial counter reset, loop continues).
1194
+ // "churn" → inject the nudge so the model can course-correct.
1195
+ // "blocked" → trigger the force wrap-up early (AI-authorised).
1186
1196
  if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1187
1197
  const readOnlyCount = engine.getRunMeta(runId)?.consecutiveReadOnlyIterations || 0;
1188
- if (readOnlyCount >= 3) {
1189
- const alreadyRead = summarizeReadTargets(toolExecutions);
1190
- messages.push({
1191
- role: 'system',
1192
- content: buildReadOnlyChurnGuidance({ readOnlyCount, alreadyRead }),
1193
- });
1194
- }
1195
- if (readOnlyCount >= loopPolicy.maxConsecutiveReadOnlyIterations) {
1196
- const alreadyRead = summarizeReadTargets(toolExecutions);
1197
- console.warn(
1198
- `[Run ${shortenRunId(runId)}] no_progress_wrapup readOnlyCount=${readOnlyCount}`
1199
- );
1200
- engine.updateRunProgress(runId, {
1201
- currentPhase: 'model',
1202
- currentStep: 'model:no_progress_wrapup',
1203
- currentTool: null,
1204
- currentStepStartedAt: isoNow(),
1205
- });
1206
- let wrapTokens = 0;
1207
- try {
1208
- const wrapResponse = await withModelCallTimeout(
1209
- provider.chat(
1210
- sanitizeConversationMessages([
1211
- ...messages,
1212
- {
1213
- role: 'system',
1214
- content: buildNoProgressWrapupPrompt({
1215
- readOnlyCount,
1216
- alreadyRead,
1217
- platform: options?.source || null,
1218
- }),
1219
- },
1220
- ]),
1221
- [],
1222
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1223
- ),
1224
- options,
1225
- 'No-progress wrap-up',
1226
- );
1227
- wrapTokens = wrapResponse.usage?.totalTokens || 0;
1228
- lastContent = sanitizeModelOutput(wrapResponse.content || '', { model });
1229
- } catch (wrapErr) {
1230
- console.warn(`[Run ${shortenRunId(runId)}] no_progress_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
1231
- }
1232
- totalTokens += wrapTokens;
1233
- const usableWrap = normalizeOutgoingMessage(lastContent, options?.source || null);
1234
- if (!usableWrap) {
1235
- lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1198
+
1199
+ if (readOnlyCount > 0) {
1200
+ const runGoalCtx = resolveRunGoalContext(engine.getRunMeta(runId), analysis, plan);
1201
+ const churnNudgeThreshold = resolveChurnNudgeThreshold(runGoalCtx.goalContract);
1202
+
1203
+ let triggerForceWrapup = false;
1204
+ let forceWrapupSource = 'hard_limit';
1205
+ // Compute alreadyRead lazily — only needed at or above the nudge threshold.
1206
+ let alreadyRead = '';
1207
+
1208
+ if (readOnlyCount >= loopPolicy.maxConsecutiveReadOnlyIterations) {
1209
+ alreadyRead = summarizeReadTargets(toolExecutions);
1210
+ triggerForceWrapup = true;
1211
+ } else if (readOnlyCount >= churnNudgeThreshold) {
1212
+ alreadyRead = summarizeReadTargets(toolExecutions);
1213
+ let churnResult;
1214
+ try {
1215
+ churnResult = await engine.assessChurnState({
1216
+ provider,
1217
+ providerName,
1218
+ model,
1219
+ messages,
1220
+ analysis,
1221
+ plan,
1222
+ toolExecutions,
1223
+ readOnlyCount,
1224
+ alreadyRead,
1225
+ iteration,
1226
+ options: { ...options, triggerSource, runId, userId, agentId },
1227
+ });
1228
+ } catch (churnErr) {
1229
+ console.warn(`[Run ${shortenRunId(runId)}] churn_assessment failed: ${summarizeForLog(churnErr?.message || churnErr, 120)}`);
1230
+ churnResult = { assessment: { assessment: 'churn', reason: '' }, usage: 0 };
1231
+ }
1232
+ totalTokens += churnResult.usage || 0;
1233
+ engine.recordRunEvent(userId, runId, 'churn_assessment', {
1234
+ assessment: churnResult.assessment.assessment,
1235
+ reason: churnResult.assessment.reason,
1236
+ readOnlyCount,
1237
+ churnNudgeThreshold,
1238
+ iteration,
1239
+ }, { agentId });
1240
+
1241
+ const churnVerdict = churnResult.assessment.assessment;
1242
+ if (churnVerdict === 'blocked') {
1243
+ triggerForceWrapup = true;
1244
+ forceWrapupSource = 'ai_blocked';
1245
+ } else if (churnVerdict === 'progressing') {
1246
+ // Model is genuinely on track — partially reset so it gets
1247
+ // re-assessed after one more read-only turn rather than immediately.
1248
+ const iterMeta = engine.getRunMeta(runId);
1249
+ if (iterMeta) {
1250
+ iterMeta.consecutiveReadOnlyIterations = Math.max(0, churnNudgeThreshold - 1);
1251
+ }
1252
+ } else {
1253
+ // 'churn' — model acknowledges it is spinning; inject the nudge
1254
+ // so it can course-correct in the next iteration.
1255
+ messages.push({
1256
+ role: 'system',
1257
+ content: buildReadOnlyChurnGuidance({ readOnlyCount, alreadyRead }),
1258
+ });
1259
+ }
1236
1260
  }
1237
- messages.push({ role: 'assistant', content: lastContent });
1238
- if (conversationId) {
1239
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1240
- .run(conversationId, 'assistant', lastContent, usableWrap ? wrapTokens : 0);
1261
+
1262
+ if (triggerForceWrapup) {
1263
+ console.warn(
1264
+ `[Run ${shortenRunId(runId)}] no_progress_wrapup source=${forceWrapupSource} readOnlyCount=${readOnlyCount}`
1265
+ );
1266
+ engine.updateRunProgress(runId, {
1267
+ currentPhase: 'model',
1268
+ currentStep: 'model:no_progress_wrapup',
1269
+ currentTool: null,
1270
+ currentStepStartedAt: isoNow(),
1271
+ });
1272
+ let wrapTokens = 0;
1273
+ try {
1274
+ const wrapResponse = await withModelCallTimeout(
1275
+ provider.chat(
1276
+ sanitizeConversationMessages([
1277
+ ...messages,
1278
+ {
1279
+ role: 'system',
1280
+ content: buildNoProgressWrapupPrompt({
1281
+ readOnlyCount,
1282
+ alreadyRead,
1283
+ platform: options?.source || null,
1284
+ }),
1285
+ },
1286
+ ]),
1287
+ [],
1288
+ { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1289
+ ),
1290
+ options,
1291
+ 'No-progress wrap-up',
1292
+ );
1293
+ wrapTokens = wrapResponse.usage?.totalTokens || 0;
1294
+ lastContent = sanitizeModelOutput(wrapResponse.content || '', { model });
1295
+ } catch (wrapErr) {
1296
+ console.warn(`[Run ${shortenRunId(runId)}] no_progress_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
1297
+ }
1298
+ totalTokens += wrapTokens;
1299
+ const usableWrap = normalizeOutgoingMessage(lastContent, options?.source || null);
1300
+ if (!usableWrap) {
1301
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1302
+ }
1303
+ messages.push({ role: 'assistant', content: lastContent });
1304
+ if (conversationId) {
1305
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1306
+ .run(conversationId, 'assistant', lastContent, usableWrap ? wrapTokens : 0);
1307
+ }
1308
+ engine.recordRunEvent(userId, runId, 'no_progress_wrapup_delivered', {
1309
+ iteration,
1310
+ readOnlyCount,
1311
+ source: usableWrap ? 'model' : 'deterministic',
1312
+ forceWrapupSource,
1313
+ stepIndex,
1314
+ }, { agentId });
1315
+ // This wrap-up is a forced, tool-less terminal answer: the model had no
1316
+ // way to call send_message itself. On automatic background runs the plain
1317
+ // result is normally gated, which would silently drop this. Mark it so the
1318
+ // task runtime delivers it — a stuck/blocked scheduled task must still
1319
+ // surface its result instead of going silent.
1320
+ if (
1321
+ (triggerSource === 'schedule' || triggerSource === 'tasks')
1322
+ && options.deliveryState
1323
+ && !engine.activeRuns.get(runId)?.messagingSent
1324
+ ) {
1325
+ options.deliveryState.terminalWrapup = true;
1326
+ }
1327
+ directAnswerEligible = true;
1328
+ break;
1241
1329
  }
1242
- engine.recordRunEvent(userId, runId, 'no_progress_wrapup_delivered', {
1243
- iteration,
1244
- readOnlyCount,
1245
- source: usableWrap ? 'model' : 'deterministic',
1246
- stepIndex,
1247
- }, { agentId });
1248
- directAnswerEligible = true;
1249
- break;
1250
1330
  }
1251
1331
  }
1252
1332
 
@@ -2550,6 +2630,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2550
2630
  }
2551
2631
 
2552
2632
  throw err;
2633
+ } finally {
2634
+ releaseReservation();
2553
2635
  }
2554
2636
  }
2555
2637
 
@@ -176,4 +176,21 @@ function resolveToolResultLimits(toolName, policy) {
176
176
  return { softLimit: soft, hardLimit: hard };
177
177
  }
178
178
 
179
- module.exports = { buildLoopPolicy, getToolCategory, resolveToolResultLimits };
179
+ /**
180
+ * Resolve the read-only churn nudge threshold from the current goal contract.
181
+ * The AI itself sets complexity and autonomy_level during task analysis, so
182
+ * this threshold is indirectly AI-controlled rather than a hardcoded constant.
183
+ *
184
+ * Simple tasks get nudged sooner (2 read-only turns) because exploration is
185
+ * rarely needed. Complex/high-autonomy tasks get more latitude (5 turns) before
186
+ * the churn self-assessment fires.
187
+ */
188
+ function resolveChurnNudgeThreshold(goalContract) {
189
+ const complexity = String(goalContract?.complexity || 'standard').toLowerCase();
190
+ const autonomyLevel = String(goalContract?.autonomyLevel || goalContract?.autonomy_level || 'normal').toLowerCase();
191
+ if (complexity === 'simple') return 2;
192
+ if (complexity === 'complex' || autonomyLevel === 'high') return 5;
193
+ return 3;
194
+ }
195
+
196
+ module.exports = { buildLoopPolicy, getToolCategory, resolveToolResultLimits, resolveChurnNudgeThreshold };
@@ -2,6 +2,10 @@
2
2
 
3
3
  const db = require('../../db/database');
4
4
 
5
+ // In-process reservation map: userId -> reserved token count for in-flight runs.
6
+ // Prevents concurrent run starts from collectively bypassing the per-user budget.
7
+ const _reservations = new Map();
8
+
5
9
  const DEFAULT_RATE_LIMIT_4H = 2_500_000;
6
10
  const DEFAULT_RATE_LIMIT_WEEKLY = 10_000_000;
7
11
 
@@ -83,7 +87,7 @@ function nextDecreaseAt(rows, durationMs, usage, limit) {
83
87
  return null;
84
88
  }
85
89
 
86
- function getRateLimitSnapshot(userId) {
90
+ function getRateLimitSnapshot(userId, { includeReservations = false } = {}) {
87
91
  const userLimits = db.prepare(
88
92
  'SELECT rate_limit_4h, rate_limit_weekly FROM users WHERE id = ?',
89
93
  ).get(userId);
@@ -100,6 +104,7 @@ function getRateLimitSnapshot(userId) {
100
104
  fourHourIsCustom: customFourHour != null,
101
105
  weeklyIsCustom: customWeekly != null,
102
106
  };
107
+ const reserved = includeReservations ? (_reservations.get(String(userId)) || 0) : 0;
103
108
  const usage = {};
104
109
  const remaining = {};
105
110
  const reached = {};
@@ -107,7 +112,8 @@ function getRateLimitSnapshot(userId) {
107
112
 
108
113
  for (const [windowKey, config] of Object.entries(WINDOWS)) {
109
114
  const rows = usageRows(userId, config.durationMs);
110
- const used = rows.reduce((total, row) => total + Number(row.tokens || 0), 0);
115
+ const committed = rows.reduce((total, row) => total + Number(row.tokens || 0), 0);
116
+ const used = committed + reserved;
111
117
  const limit = limits[windowKey];
112
118
  usage[windowKey] = used;
113
119
  remaining[windowKey] = limit == null ? null : Math.max(0, limit - used);
@@ -130,14 +136,31 @@ function getRateLimitSnapshot(userId) {
130
136
  }
131
137
 
132
138
  function enforceRateLimits(userId) {
133
- const snapshot = getRateLimitSnapshot(userId);
139
+ const snapshot = getRateLimitSnapshot(userId, { includeReservations: true });
134
140
  if (snapshot.reached.fourHour) {
135
141
  throw new RateLimitExceededError('fourHour', snapshot);
136
142
  }
137
143
  if (snapshot.reached.weekly) {
138
144
  throw new RateLimitExceededError('weekly', snapshot);
139
145
  }
140
- return snapshot;
146
+ // Reserve a placeholder so concurrent starts see this run as in-flight.
147
+ // The reservation is released (or reconciled) when the run completes.
148
+ const key = String(userId);
149
+ const limits = snapshot.limits;
150
+ const reserve = Math.max(limits.fourHour || 0, limits.weekly || 0, 1);
151
+ _reservations.set(key, (_reservations.get(key) || 0) + reserve);
152
+ return { snapshot, releaseReservation: () => releaseReservation(userId, reserve) };
153
+ }
154
+
155
+ function releaseReservation(userId, amount) {
156
+ const key = String(userId);
157
+ const current = _reservations.get(key) || 0;
158
+ const next = current - amount;
159
+ if (next <= 0) {
160
+ _reservations.delete(key);
161
+ } else {
162
+ _reservations.set(key, next);
163
+ }
141
164
  }
142
165
 
143
166
  module.exports = {
@@ -147,4 +170,5 @@ module.exports = {
147
170
  configuredDefaultLimits,
148
171
  enforceRateLimits,
149
172
  getRateLimitSnapshot,
173
+ releaseReservation,
150
174
  };
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { analyzeImageForUser } = require('./imageAnalysis');
4
+ const { isPrivateHost } = require('../../utils/cloud-security');
4
5
  const db = require('../../db/database');
5
6
  const { DATA_DIR } = require('../../../runtime/paths');
6
7
  const { isMainAgent } = require('../agents/manager');
@@ -1737,6 +1738,9 @@ async function executeTool(toolName, args, context, engine) {
1737
1738
  }
1738
1739
 
1739
1740
  case 'browser_navigate': {
1741
+ const { validateCloudUrl } = require('../../utils/cloud-security');
1742
+ const urlCheck = validateCloudUrl(args.url);
1743
+ if (!urlCheck.allowed) return { error: 'URL is not allowed: blocked scheme or private/internal network address.' };
1740
1744
  const { provider, backend } = await bc();
1741
1745
  if (!provider) return { error: 'Browser controller not available' };
1742
1746
  return { ...await provider.navigate(args.url, {
@@ -2578,6 +2582,22 @@ async function executeTool(toolName, args, context, engine) {
2578
2582
  }
2579
2583
 
2580
2584
  case 'http_request': {
2585
+ let parsedUrl;
2586
+ try { parsedUrl = new URL(args.url); } catch {
2587
+ return { error: 'Invalid URL' };
2588
+ }
2589
+ const scheme = parsedUrl.protocol.replace(/:$/, '').toLowerCase();
2590
+ if (!['http', 'https'].includes(scheme)) {
2591
+ return { error: 'URL scheme not allowed. Only http and https are permitted.' };
2592
+ }
2593
+ const h = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '');
2594
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost')) {
2595
+ return { error: 'Loopback addresses are not permitted.' };
2596
+ }
2597
+ const allowPrivate = process.env.NEOAGENT_HTTP_ALLOW_PRIVATE !== 'false';
2598
+ if (!allowPrivate && isPrivateHost(parsedUrl.hostname)) {
2599
+ return { error: 'Private/internal network addresses are not permitted.' };
2600
+ }
2581
2601
  const controller = new AbortController();
2582
2602
  const timeoutMs = args.timeout_ms || 30000;
2583
2603
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -2594,11 +2614,28 @@ async function executeTool(toolName, args, context, engine) {
2594
2614
  }
2595
2615
  }
2596
2616
  const res = await fetch(args.url, options);
2597
- const text = await res.text();
2617
+ const MAX_BODY = 512 * 1024;
2618
+ const reader = res.body.getReader();
2619
+ const chunks = [];
2620
+ let total = 0;
2621
+ let truncated = false;
2622
+ while (true) {
2623
+ const { done, value } = await reader.read();
2624
+ if (done) break;
2625
+ total += value.length;
2626
+ if (total > MAX_BODY) {
2627
+ const take = MAX_BODY - (total - value.length);
2628
+ if (take > 0) chunks.push(value.slice(0, take));
2629
+ truncated = true;
2630
+ break;
2631
+ }
2632
+ chunks.push(value);
2633
+ }
2634
+ const text = Buffer.concat(chunks.map(c => Buffer.from(c))).toString('utf-8');
2598
2635
  return {
2599
2636
  status: res.status,
2600
2637
  headers: Object.fromEntries(res.headers.entries()),
2601
- body: text.length > 50000 ? text.slice(0, 50000) + '\n...[truncated]' : text
2638
+ body: truncated ? text + '\n...[truncated]' : text,
2602
2639
  };
2603
2640
  } catch (err) {
2604
2641
  if (err.name === 'AbortError') return { error: `Request timed out after ${timeoutMs} ms` };
@@ -28,11 +28,14 @@ function rejectUpgrade(socket, statusCode, message) {
28
28
  }
29
29
 
30
30
  function remoteAddressFromRequest(req) {
31
- const forwarded = req.headers?.['x-forwarded-for'];
32
- if (typeof forwarded === 'string' && forwarded.trim()) {
33
- return forwarded.split(',')[0].trim();
31
+ const directPeer = req.socket?.remoteAddress || 'unknown';
32
+ if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') {
33
+ const forwarded = req.headers?.['x-forwarded-for'];
34
+ if (typeof forwarded === 'string' && forwarded.trim()) {
35
+ return forwarded.split(',')[0].trim();
36
+ }
34
37
  }
35
- return req.socket?.remoteAddress || 'unknown';
38
+ return directPeer;
36
39
  }
37
40
 
38
41
  function createUpgradeThrottleObserver() {
@@ -5,13 +5,16 @@ const crypto = require('crypto');
5
5
  const SECRET_PREFIX = 'enc:v1:';
6
6
 
7
7
  function getSecretMaterial() {
8
- const secret = String(process.env.SESSION_SECRET || '').trim();
9
- if (!secret) {
8
+ // Prefer a dedicated data-encryption key so rotating SESSION_SECRET
9
+ // does not invalidate persisted credentials. Fall back to SESSION_SECRET
10
+ // for existing deployments that haven't set NEOAGENT_ENCRYPTION_KEY yet.
11
+ const key = String(process.env.NEOAGENT_ENCRYPTION_KEY || process.env.SESSION_SECRET || '').trim();
12
+ if (!key) {
10
13
  throw new Error(
11
- 'Official integrations require SESSION_SECRET to be configured.',
14
+ 'Official integrations require NEOAGENT_ENCRYPTION_KEY (or SESSION_SECRET) to be configured.',
12
15
  );
13
16
  }
14
- return secret;
17
+ return key;
15
18
  }
16
19
 
17
20
  function getKey() {
@@ -233,7 +233,7 @@ class TelnyxVoicePlatform extends BasePlatform {
233
233
  if (!this._hasSession(ccId)) return;
234
234
  const sess = this._session(ccId);
235
235
  if (!sess.awaitingSecret) return;
236
- console.log(`[TelnyxVoice] Secret code timeout for ${ccId.slice(-8)} (${sess.callerNumber})`);
236
+ console.log(`[TelnyxVoice] Secret code timeout for ${ccId.slice(-8)}`);
237
237
  this._banNumber(sess.callerNumber);
238
238
  this._endSession(ccId);
239
239
  try { await this._hangupCall(ccId); } catch {}
@@ -535,7 +535,7 @@ class TelnyxVoicePlatform extends BasePlatform {
535
535
  if (this.voiceSecret && sess.secretDigits.length >= this.voiceSecret.length) {
536
536
  this._cancelSecretTimer(ccId);
537
537
  if (sess.secretDigits === this.voiceSecret) {
538
- console.log(`[TelnyxVoice] Secret accepted for ${ccId.slice(-8)} (${sess.callerNumber})`);
538
+ console.log(`[TelnyxVoice] Secret accepted for ${ccId.slice(-8)}`);
539
539
  sess.awaitingSecret = false;
540
540
  sess.secretDigits = '';
541
541
  sess.isProcessing = true;
@@ -613,7 +613,7 @@ class TelnyxVoicePlatform extends BasePlatform {
613
613
  break;
614
614
  }
615
615
 
616
- console.log(`[TelnyxVoice] Transcript [${sess.callerNumber}]: ${transcript}`);
616
+ console.log(`[TelnyxVoice] Transcript received for ${ccId.slice(-8)} (${transcript.length} chars)`);
617
617
 
618
618
  // Mark as thinking — gates call.playback.ended so think-audio events
619
619
  // don't corrupt session state while the agent is processing.
@@ -210,7 +210,7 @@ class WhatsAppPlatform extends BasePlatform {
210
210
  response_format: 'text'
211
211
  });
212
212
  content = (typeof transcription === 'string' ? transcription : transcription?.text || '').trim() || '[Voice Note - empty audio]';
213
- console.log(`[WhatsApp] Voice note transcribed: "${content.slice(0, 80)}"`);
213
+ console.log(`[WhatsApp] Voice note transcribed (${content.length} chars)`);
214
214
  } catch (transcribeErr) {
215
215
  console.error('[WhatsApp] Audio transcription failed:', transcribeErr.message);
216
216
  content = '[Voice Note - transcription failed]';
@@ -29,6 +29,12 @@ function ensureRecordingDirs() {
29
29
  fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
30
30
  }
31
31
 
32
+ function validateSourceKey(key) {
33
+ if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key)) {
34
+ throw new Error(`Invalid sourceKey "${key}": only lowercase alphanumeric, hyphens, and underscores are allowed.`);
35
+ }
36
+ }
37
+
32
38
  class RecordingManager {
33
39
  constructor(io) {
34
40
  this.io = io;
@@ -241,10 +247,8 @@ class RecordingManager {
241
247
  throw new Error('Recording session is not accepting more chunks.');
242
248
  }
243
249
 
244
- const sourceKey = `${metadata.sourceKey || ''}`.trim();
245
- if (!sourceKey) {
246
- throw new Error('sourceKey is required.');
247
- }
250
+ const sourceKey = `${metadata.sourceKey || ''}`.trim().toLowerCase();
251
+ validateSourceKey(sourceKey);
248
252
 
249
253
  const source = this.#getSessionSourceByKey(sessionId, sourceKey);
250
254
 
@@ -289,6 +293,10 @@ class RecordingManager {
289
293
  const endMs = Math.max(startMs, Number(metadata.endMs) || startMs);
290
294
  const extension = this.#extensionForMime(mimeType);
291
295
  const fileDir = path.join(RECORDINGS_DIR, `user-${userId}`, sessionId, sourceKey);
296
+ const sessionRoot = path.resolve(path.join(RECORDINGS_DIR, `user-${userId}`, sessionId));
297
+ if (!path.resolve(fileDir).startsWith(sessionRoot + path.sep)) {
298
+ throw new Error('Invalid recording path');
299
+ }
292
300
  fs.mkdirSync(fileDir, { recursive: true });
293
301
  const filePath = path.join(fileDir, `${String(sequenceIndex).padStart(6, '0')}${extension}`);
294
302
  const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
@@ -1113,9 +1121,7 @@ class RecordingManager {
1113
1121
 
1114
1122
  return inputs.map((item, index) => {
1115
1123
  const sourceKey = `${item?.sourceKey || item?.id || `source-${index}`}`.trim().toLowerCase();
1116
- if (!sourceKey) {
1117
- throw new Error('Every recording source needs a sourceKey.');
1118
- }
1124
+ validateSourceKey(sourceKey);
1119
1125
  if (seen.has(sourceKey)) {
1120
1126
  throw new Error(`Duplicate recording source: ${sourceKey}`);
1121
1127
  }
@@ -801,7 +801,13 @@ class TaskRuntime {
801
801
  if (!targets.length) return null;
802
802
  const resultText = stringifyTaskResult(result).trim();
803
803
  const resultLooksLikeError = Boolean(result?.error);
804
- if (!allowPlainResultFallback && !resultLooksLikeError) {
804
+ // A forced terminal wrap-up (read-only/blocked hard-stop) is the model's final
805
+ // answer produced WITHOUT the ability to call send_message itself. Gating it
806
+ // would silently drop a stuck scheduled task's result, so deliver it even on an
807
+ // automatic run. Ordinary mid-run plain text (model had send_message available
808
+ // and chose not to use it) is still gated below.
809
+ const forcedTerminal = deliveryState?.terminalWrapup === true && Boolean(resultText);
810
+ if (!allowPlainResultFallback && !resultLooksLikeError && !forcedTerminal) {
805
811
  // Automatic run produced substantive text but never made an explicit
806
812
  // send_message decision (a deliberate no_response would have short-circuited
807
813
  // above). We suppress to avoid recurring-check spam, but surface it so a
@@ -52,6 +52,9 @@ class VoiceRuntimeManager {
52
52
  agentId,
53
53
  );
54
54
  const resolvedSessionId = String(sessionId || randomUUID()).trim();
55
+ if (this.sessions.has(resolvedSessionId)) {
56
+ throw new Error('Voice session ID collision: a session with this ID already exists.');
57
+ }
55
58
  const adapter = this.#createAdapter(voiceSettings.liveProvider);
56
59
  await adapter.open();
57
60
 
@@ -153,9 +156,12 @@ class VoiceRuntimeManager {
153
156
  });
154
157
  }
155
158
 
156
- async closeSession(sessionId, reason = 'closed') {
159
+ async closeSession(sessionId, reason = 'closed', userId = null) {
157
160
  const session = this.getSession(sessionId);
158
161
  if (!session) return;
162
+ if (userId != null && session.userId != null && String(session.userId) !== String(userId)) {
163
+ throw new Error('Voice session access denied.');
164
+ }
159
165
  if (reason === 'socket_disconnected') {
160
166
  await this.abortActiveRun(session.id, 'voice_disconnect');
161
167
  }
@@ -164,8 +170,8 @@ class VoiceRuntimeManager {
164
170
  await session.close(reason);
165
171
  }
166
172
 
167
- async beginInput(sessionId, options = {}) {
168
- const session = this.#requireSession(sessionId);
173
+ async beginInput(sessionId, options = {}, userId = null) {
174
+ const session = this.#requireSession(sessionId, userId);
169
175
  await session.interruptOutput();
170
176
  await this.abortActiveRun(session.id, 'voice_interrupt');
171
177
  session.resetTurnState();
@@ -176,13 +182,13 @@ class VoiceRuntimeManager {
176
182
  await session.setState('listening');
177
183
  }
178
184
 
179
- async appendInputAudio(sessionId, audioBytes, options = {}) {
180
- const session = this.#requireSession(sessionId);
185
+ async appendInputAudio(sessionId, audioBytes, options = {}, userId = null) {
186
+ const session = this.#requireSession(sessionId, userId);
181
187
  return session.adapter.appendAudioChunk(session, audioBytes, options);
182
188
  }
183
189
 
184
- async commitInput(sessionId, options = {}) {
185
- const session = this.#requireSession(sessionId);
190
+ async commitInput(sessionId, options = {}, userId = null) {
191
+ const session = this.#requireSession(sessionId, userId);
186
192
  if (session.inputBytes === 0) {
187
193
  return { transcript: '' };
188
194
  }
@@ -207,8 +213,8 @@ class VoiceRuntimeManager {
207
213
  };
208
214
  }
209
215
 
210
- async interruptSession(sessionId) {
211
- const session = this.#requireSession(sessionId);
216
+ async interruptSession(sessionId, userId = null) {
217
+ const session = this.#requireSession(sessionId, userId);
212
218
  await this.abortActiveRun(session.id, 'voice_interrupt');
213
219
  await session.interruptOutput();
214
220
  session.resetTurnState();
@@ -378,11 +384,14 @@ class VoiceRuntimeManager {
378
384
  return new OpenAiLiveRelayAdapter();
379
385
  }
380
386
 
381
- #requireSession(sessionId) {
387
+ #requireSession(sessionId, userId = null) {
382
388
  const session = this.getSession(sessionId);
383
389
  if (!session) {
384
390
  throw new Error('Voice session was not found.');
385
391
  }
392
+ if (userId != null && session.userId != null && String(session.userId) !== String(userId)) {
393
+ throw new Error('Voice session access denied.');
394
+ }
386
395
  return session;
387
396
  }
388
397
 
@@ -27,11 +27,14 @@ function rejectUpgrade(socket, statusCode, message) {
27
27
  }
28
28
 
29
29
  function remoteAddressFromRequest(req) {
30
- const forwarded = req.headers?.['x-forwarded-for'];
31
- if (typeof forwarded === 'string' && forwarded.trim()) {
32
- return forwarded.split(',')[0].trim();
30
+ const directPeer = req.socket?.remoteAddress || 'unknown';
31
+ if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') {
32
+ const forwarded = req.headers?.['x-forwarded-for'];
33
+ if (typeof forwarded === 'string' && forwarded.trim()) {
34
+ return forwarded.split(',')[0].trim();
35
+ }
33
36
  }
34
- return req.socket?.remoteAddress || 'unknown';
37
+ return directPeer;
35
38
  }
36
39
 
37
40
  function createUpgradeLimiter() {
@@ -544,7 +544,7 @@ function setupWebSocket(io, services) {
544
544
  await voiceRuntimeManager.beginInput(sessionId, {
545
545
  mimeType: toOptionalString(data?.mimeType, 128),
546
546
  turnId: toOptionalString(data?.turnId, 128),
547
- });
547
+ }, userId);
548
548
  } catch (err) {
549
549
  console.error(`[WS] voice:input_start failed for user ${userId}:`, err);
550
550
  socket.emit('voice:error', {
@@ -600,7 +600,7 @@ function setupWebSocket(io, services) {
600
600
  mimeType: toOptionalString(data?.mimeType, 128),
601
601
  turnId,
602
602
  sequence,
603
- });
603
+ }, userId);
604
604
  socket.emit('voice:chunk_ack', {
605
605
  sessionId,
606
606
  turnId,
@@ -672,7 +672,7 @@ function setupWebSocket(io, services) {
672
672
  finalSequence: toBoundedInt(data?.finalSequence, -1, -1, 1_000_000),
673
673
  promptHint: toOptionalString(data?.promptHint, 2000),
674
674
  metadata,
675
- });
675
+ }, userId);
676
676
  } catch (err) {
677
677
  console.error(`[WS] voice:input_commit failed for user ${userId}:`, err);
678
678
  socket.emit('voice:error', {
@@ -696,7 +696,7 @@ function setupWebSocket(io, services) {
696
696
  if (!sessionId) {
697
697
  return socket.emit('voice:error', { error: 'sessionId is required' });
698
698
  }
699
- await voiceRuntimeManager.interruptSession(sessionId);
699
+ await voiceRuntimeManager.interruptSession(sessionId, userId);
700
700
  } catch (err) {
701
701
  console.error(`[WS] voice:interrupt failed for user ${userId}:`, err);
702
702
  socket.emit('voice:error', {
@@ -720,7 +720,7 @@ function setupWebSocket(io, services) {
720
720
  if (!sessionId) {
721
721
  return socket.emit('voice:error', { error: 'sessionId is required' });
722
722
  }
723
- await voiceRuntimeManager.closeSession(sessionId, 'client_closed');
723
+ await voiceRuntimeManager.closeSession(sessionId, 'client_closed', userId);
724
724
  socket.data.voiceSessionIds?.delete(sessionId);
725
725
  } catch (err) {
726
726
  console.error(`[WS] voice:session_close failed for user ${userId}:`, err);