let-them-talk 3.4.1 → 3.4.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.4.2] - 2026-03-15
4
+
5
+ ### Security — CSRF Protection
6
+ - Required `X-LTT-Request` custom header on all POST/PUT/DELETE requests
7
+ - `lttFetch` wrapper in dashboard automatically includes the header
8
+ - Malicious cross-origin pages cannot set custom headers without CORS preflight approval
9
+ - Removed wildcard `Access-Control-Allow-Origin: *` in LAN mode — now uses explicit trusted origins only
10
+ - Empty Origin/Referer no longer auto-trusted — requires custom header as minimum protection
11
+
12
+ ### Security — LAN Auth Token
13
+ - Auto-generated 32-char hex token when LAN mode is enabled
14
+ - Token required for all non-localhost requests (via `?token=` query param or `X-LTT-Token` header)
15
+ - Token included in QR code URL — phone scans and it just works
16
+ - Token displayed in phone access modal with explanation
17
+ - New token generated each time LAN mode is toggled on
18
+ - Token persists across server restarts via `.lan-token` file
19
+ - Localhost access never requires a token
20
+
21
+ ### Security — Content Security Policy
22
+ - CSP header added to dashboard HTML response
23
+ - `script-src 'unsafe-inline'` for inline handlers, blocks `eval()` and external scripts
24
+ - `connect-src 'self'` restricts API calls to same origin
25
+ - `font-src`, `style-src`, `img-src` scoped to required sources only
26
+
27
+ ### Fixed
28
+ - CSRF brace imbalance that trapped GET handlers inside POST-only block
29
+ - LAN token not forwarded from phone URL to API calls and SSE
30
+ - Redundant nested origin check collapsed to single condition
31
+
3
32
  ## [3.4.1] - 2026-03-15
4
33
 
5
34
  ### Added
package/LICENSE CHANGED
@@ -6,7 +6,7 @@ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
6
6
  Parameters
7
7
 
8
8
  Licensor: Dekelelz
9
- Licensed Work: Let Them Talk v3.4.1
9
+ Licensed Work: Let Them Talk v3.4.2
10
10
  The Licensed Work is (c) 2024-2026 Dekelelz.
11
11
  Additional Use Grant: You may make use of the Licensed Work, provided that
12
12
  you may not use the Licensed Work for a Commercial
package/cli.js CHANGED
@@ -8,7 +8,7 @@ const command = process.argv[2];
8
8
 
9
9
  function printUsage() {
10
10
  console.log(`
11
- Let Them Talk — Agent Bridge v3.4.1
11
+ Let Them Talk — Agent Bridge v3.4.2
12
12
  MCP message broker for inter-agent communication
13
13
  Supports: Claude Code, Gemini CLI, Codex CLI
14
14
 
package/dashboard.html CHANGED
@@ -2842,7 +2842,7 @@
2842
2842
  </div>
2843
2843
  </div>
2844
2844
  <div class="app-footer">
2845
- <span>Let Them Talk v3.4.1</span>
2845
+ <span>Let Them Talk v3.4.2</span>
2846
2846
  </div>
2847
2847
  <div class="profile-popup" id="profile-popup" onclick="event.stopPropagation()">
2848
2848
  <div class="profile-popup-header">
@@ -2897,6 +2897,26 @@ var POLL_INTERVAL = 2000;
2897
2897
  var SLEEP_THRESHOLD = 60; // seconds
2898
2898
  var lastMessageCount = 0;
2899
2899
  var autoScroll = true;
2900
+ // CSRF protection — all mutating requests must include this header
2901
+ // Read LAN token from URL on page load (phone access via QR code)
2902
+ var _lttToken = (function() {
2903
+ try { var p = new URLSearchParams(window.location.search); var t = p.get('token'); if (t) sessionStorage.setItem('ltt-token', t); return sessionStorage.getItem('ltt-token') || ''; } catch(e) { return ''; }
2904
+ })();
2905
+
2906
+ function lttFetch(url, opts) {
2907
+ opts = opts || {};
2908
+ // Append LAN token to URL if present (needed for phone/LAN access)
2909
+ if (_lttToken) {
2910
+ var sep = url.indexOf('?') >= 0 ? '&' : '?';
2911
+ url = url + sep + 'token=' + encodeURIComponent(_lttToken);
2912
+ }
2913
+ if (opts.method && opts.method !== 'GET') {
2914
+ if (!opts.headers) opts.headers = {};
2915
+ opts.headers['X-LTT-Request'] = '1';
2916
+ }
2917
+ return fetch(url, opts);
2918
+ }
2919
+
2900
2920
  var activeThread = null;
2901
2921
  var activeProject = ''; // empty = default/local
2902
2922
  var cachedHistory = [];
@@ -3227,7 +3247,7 @@ function renderAgents(agents) {
3227
3247
 
3228
3248
  function sendNudge(agentName) {
3229
3249
  var body = JSON.stringify({ to: agentName, content: 'Hey ' + agentName + ', the user is waiting for you. Please check for new messages and continue your work.' });
3230
- fetch('/api/inject' + projectParam(), {
3250
+ lttFetch('/api/inject' + projectParam(), {
3231
3251
  method: 'POST',
3232
3252
  headers: { 'Content-Type': 'application/json' },
3233
3253
  body: body
@@ -3285,7 +3305,7 @@ function doInject() {
3285
3305
  if (!target || !content) return;
3286
3306
 
3287
3307
  var body = JSON.stringify({ to: target, content: content });
3288
- fetch('/api/inject' + projectParam(), {
3308
+ lttFetch('/api/inject' + projectParam(), {
3289
3309
  method: 'POST',
3290
3310
  headers: { 'Content-Type': 'application/json' },
3291
3311
  body: body
@@ -3612,7 +3632,7 @@ var cachedReadReceipts = {};
3612
3632
 
3613
3633
  function fetchReadReceipts() {
3614
3634
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3615
- fetch('/api/read-receipts' + pq).then(function(r) { return r.json(); }).then(function(data) {
3635
+ lttFetch('/api/read-receipts' + pq).then(function(r) { return r.json(); }).then(function(data) {
3616
3636
  cachedReadReceipts = data || {};
3617
3637
  }).catch(function() {});
3618
3638
  }
@@ -3752,7 +3772,7 @@ function deleteMessage(msgId) {
3752
3772
  var preview = msg.content.substring(0, 60) + (msg.content.length > 60 ? '...' : '');
3753
3773
  if (!confirm('Delete this message from ' + msg.from + '?\n\n"' + preview + '"')) return;
3754
3774
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3755
- fetch('/api/message' + pq, {
3775
+ lttFetch('/api/message' + pq, {
3756
3776
  method: 'DELETE',
3757
3777
  headers: { 'Content-Type': 'application/json' },
3758
3778
  body: JSON.stringify({ id: msgId })
@@ -3802,7 +3822,7 @@ function saveEditMessage() {
3802
3822
  var content = document.getElementById('edit-msg-content').value.trim();
3803
3823
  if (!content) return;
3804
3824
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3805
- fetch('/api/message' + pq, {
3825
+ lttFetch('/api/message' + pq, {
3806
3826
  method: 'PUT',
3807
3827
  headers: { 'Content-Type': 'application/json' },
3808
3828
  body: JSON.stringify({ id: msgId, content: content })
@@ -3994,7 +4014,7 @@ function renderAgentStats() {
3994
4014
 
3995
4015
  function fetchActivity() {
3996
4016
  var pq = projectParam();
3997
- fetch('/api/timeline' + pq).then(function(r) { return r.json(); }).then(function(data) {
4017
+ lttFetch('/api/timeline' + pq).then(function(r) { return r.json(); }).then(function(data) {
3998
4018
  renderActivityHeatmap(data);
3999
4019
  }).catch(function() {});
4000
4020
  }
@@ -4174,7 +4194,7 @@ var cachedTasks = [];
4174
4194
 
4175
4195
  function fetchTasks() {
4176
4196
  var pq = projectParam();
4177
- fetch('/api/tasks' + pq).then(function(r) { return r.json(); }).then(function(tasks) {
4197
+ lttFetch('/api/tasks' + pq).then(function(r) { return r.json(); }).then(function(tasks) {
4178
4198
  cachedTasks = Array.isArray(tasks) ? tasks : [];
4179
4199
  renderTasks();
4180
4200
  }).catch(function() {
@@ -4265,7 +4285,7 @@ function buildTaskCard(t) {
4265
4285
  }
4266
4286
 
4267
4287
  function updateTaskStatus(taskId, newStatus) {
4268
- fetch('/api/tasks' + projectParam(), {
4288
+ lttFetch('/api/tasks' + projectParam(), {
4269
4289
  method: 'POST',
4270
4290
  headers: { 'Content-Type': 'application/json' },
4271
4291
  body: JSON.stringify({ task_id: taskId, status: newStatus })
@@ -4322,7 +4342,7 @@ var AGENT_COLORS = ['#58a6ff', '#f78166', '#7ee787', '#d2a8ff', '#ffa657', '#ff7
4322
4342
 
4323
4343
  function fetchStats() {
4324
4344
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
4325
- fetch('/api/stats' + pq).then(function(r) { return r.json(); }).then(function(data) {
4345
+ lttFetch('/api/stats' + pq).then(function(r) { return r.json(); }).then(function(data) {
4326
4346
  renderStats(data);
4327
4347
  }).catch(function(e) { console.error('Stats fetch failed:', e); });
4328
4348
  }
@@ -4571,7 +4591,7 @@ function saveProfile() {
4571
4591
  };
4572
4592
  if (avatar) body.avatar = avatar;
4573
4593
 
4574
- fetch('/api/profiles' + projectParam(), {
4594
+ lttFetch('/api/profiles' + projectParam(), {
4575
4595
  method: 'POST',
4576
4596
  headers: { 'Content-Type': 'application/json' },
4577
4597
  body: JSON.stringify(body)
@@ -4587,7 +4607,7 @@ function saveProfile() {
4587
4607
 
4588
4608
  function fetchWorkspaces() {
4589
4609
  var pq = projectParam();
4590
- fetch('/api/workspaces' + pq).then(function(r) { return r.json(); }).then(function(data) {
4610
+ lttFetch('/api/workspaces' + pq).then(function(r) { return r.json(); }).then(function(data) {
4591
4611
  renderWorkspaces(data);
4592
4612
  }).catch(function() {});
4593
4613
  }
@@ -4634,7 +4654,7 @@ function renderWorkspaces(data) {
4634
4654
 
4635
4655
  function fetchWorkflows() {
4636
4656
  var pq = projectParam();
4637
- fetch('/api/workflows' + pq).then(function(r) { return r.json(); }).then(function(data) {
4657
+ lttFetch('/api/workflows' + pq).then(function(r) { return r.json(); }).then(function(data) {
4638
4658
  renderWorkflows(Array.isArray(data) ? data : []);
4639
4659
  }).catch(function() {});
4640
4660
  }
@@ -4685,7 +4705,7 @@ function renderWorkflows(workflows) {
4685
4705
  }
4686
4706
 
4687
4707
  function dashAdvanceWorkflow(wfId) {
4688
- fetch('/api/workflows' + projectParam(), {
4708
+ lttFetch('/api/workflows' + projectParam(), {
4689
4709
  method: 'POST',
4690
4710
  headers: { 'Content-Type': 'application/json' },
4691
4711
  body: JSON.stringify({ action: 'advance', workflow_id: wfId })
@@ -4698,7 +4718,7 @@ var activeBranch = '';
4698
4718
 
4699
4719
  function fetchBranches() {
4700
4720
  var pq = projectParam();
4701
- fetch('/api/branches' + pq).then(function(r) { return r.json(); }).then(function(data) {
4721
+ lttFetch('/api/branches' + pq).then(function(r) { return r.json(); }).then(function(data) {
4702
4722
  renderBranchTabs(data);
4703
4723
  }).catch(function() {});
4704
4724
  }
@@ -4734,7 +4754,7 @@ function switchBranch(name) {
4734
4754
 
4735
4755
  function fetchPlugins() {
4736
4756
  var pq = projectParam();
4737
- fetch('/api/plugins' + pq).then(function(r) { return r.json(); }).then(function(data) {
4757
+ lttFetch('/api/plugins' + pq).then(function(r) { return r.json(); }).then(function(data) {
4738
4758
  renderPlugins(Array.isArray(data) ? data : []);
4739
4759
  }).catch(function() {});
4740
4760
  }
@@ -4761,7 +4781,7 @@ function renderPlugins(plugins) {
4761
4781
  }
4762
4782
 
4763
4783
  function togglePlugin(name) {
4764
- fetch('/api/plugins' + projectParam(), {
4784
+ lttFetch('/api/plugins' + projectParam(), {
4765
4785
  method: 'POST',
4766
4786
  headers: { 'Content-Type': 'application/json' },
4767
4787
  body: JSON.stringify({ action: 'toggle', name: name })
@@ -4776,9 +4796,9 @@ function poll() {
4776
4796
  var bp = activeBranch && activeBranch !== 'main' ? '&branch=' + encodeURIComponent(activeBranch) : '';
4777
4797
  var pollStart = Date.now();
4778
4798
  Promise.all([
4779
- fetch('/api/history?limit=500' + pp + bp).then(function(r) { return r.json(); }),
4780
- fetch('/api/agents' + pq).then(function(r) { return r.json(); }),
4781
- fetch('/api/status' + pq).then(function(r) { return r.json(); }),
4799
+ lttFetch('/api/history?limit=500' + pp + bp).then(function(r) { return r.json(); }),
4800
+ lttFetch('/api/agents' + pq).then(function(r) { return r.json(); }),
4801
+ lttFetch('/api/status' + pq).then(function(r) { return r.json(); }),
4782
4802
  ]).then(function(results) {
4783
4803
  console.log('[LTT] poll ok — history:' + results[0].length + ' agents:' + Object.keys(results[1]).length + ' project:' + (activeProject || 'default'));
4784
4804
  updateConnectionInfo(Date.now() - pollStart);
@@ -4834,7 +4854,7 @@ function poll() {
4834
4854
 
4835
4855
  function doReset() {
4836
4856
  if (!confirm('Clear all messages, agents, and history?')) return;
4837
- fetch('/api/reset' + projectParam(), { method: 'POST' }).then(function() {
4857
+ lttFetch('/api/reset' + projectParam(), { method: 'POST' }).then(function() {
4838
4858
  lastMessageCount = 0;
4839
4859
  activeThread = null;
4840
4860
  cachedHistory = [];
@@ -4846,7 +4866,7 @@ function doReset() {
4846
4866
  // ==================== PROJECT MANAGEMENT ====================
4847
4867
 
4848
4868
  function loadProjects() {
4849
- return fetch('/api/projects').then(function(r) { return r.json(); }).then(function(projects) {
4869
+ return lttFetch('/api/projects').then(function(r) { return r.json(); }).then(function(projects) {
4850
4870
  console.log('[LTT] loadProjects:', projects.length, 'projects, activeProject:', activeProject);
4851
4871
  var sel = document.getElementById('project-select');
4852
4872
  // Keep the first option (Default)
@@ -4944,7 +4964,7 @@ function addProject() {
4944
4964
  var projectPath = input.value.trim();
4945
4965
  if (!projectPath) return;
4946
4966
 
4947
- fetch('/api/projects', {
4967
+ lttFetch('/api/projects', {
4948
4968
  method: 'POST',
4949
4969
  headers: { 'Content-Type': 'application/json' },
4950
4970
  body: JSON.stringify({ path: projectPath })
@@ -4970,7 +4990,7 @@ function discoverProjects() {
4970
4990
  resultsEl.style.display = 'block';
4971
4991
  resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">Scanning...</div>';
4972
4992
 
4973
- fetch('/api/discover', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(found) {
4993
+ lttFetch('/api/discover', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(found) {
4974
4994
  if (!found.length) {
4975
4995
  resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">No new projects found (all discovered projects already added)</div>';
4976
4996
  setTimeout(function() { resultsEl.style.display = 'none'; }, 3000);
@@ -4996,7 +5016,7 @@ function discoverProjects() {
4996
5016
  }
4997
5017
 
4998
5018
  function addDiscovered(projectPath, name) {
4999
- fetch('/api/projects', {
5019
+ lttFetch('/api/projects', {
5000
5020
  method: 'POST',
5001
5021
  headers: { 'Content-Type': 'application/json' },
5002
5022
  body: JSON.stringify({ path: projectPath, name: name })
@@ -5012,7 +5032,7 @@ function removeProject() {
5012
5032
  if (!activeProject) return;
5013
5033
  if (!confirm('Remove this project from the dashboard?')) return;
5014
5034
 
5015
- fetch('/api/projects', {
5035
+ lttFetch('/api/projects', {
5016
5036
  method: 'DELETE',
5017
5037
  headers: { 'Content-Type': 'application/json' },
5018
5038
  body: JSON.stringify({ path: activeProject })
@@ -5275,7 +5295,7 @@ updateNotifBtn();
5275
5295
  var lanState = { lan_mode: false, lan_ip: null, port: 3000 };
5276
5296
 
5277
5297
  function fetchLanState() {
5278
- return fetch('/api/server-info').then(function(r) { return r.json(); }).then(function(info) {
5298
+ return lttFetch('/api/server-info').then(function(r) { return r.json(); }).then(function(info) {
5279
5299
  lanState = info;
5280
5300
  updateLanUI();
5281
5301
  return info;
@@ -5325,7 +5345,7 @@ function toggleLanMode() {
5325
5345
  var content = document.getElementById('phone-modal-content');
5326
5346
  content.innerHTML = '<div class="phone-off-state"><p>Switching...</p></div>';
5327
5347
 
5328
- fetch('/api/toggle-lan', { method: 'POST' })
5348
+ lttFetch('/api/toggle-lan', { method: 'POST' })
5329
5349
  .then(function(r) { return r.json(); })
5330
5350
  .then(function(info) {
5331
5351
  lanState = info;
@@ -5353,10 +5373,20 @@ function renderPhoneModalContent() {
5353
5373
  }
5354
5374
 
5355
5375
  var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
5356
- // Include active project so the phone shows the same view
5357
- if (activeProject) url += '?project=' + encodeURIComponent(activeProject);
5376
+ // Include auth token and active project so the phone shows the same view
5377
+ var params = [];
5378
+ if (lanState.lan_token) params.push('token=' + encodeURIComponent(lanState.lan_token));
5379
+ if (activeProject) params.push('project=' + encodeURIComponent(activeProject));
5380
+ if (params.length) url += '?' + params.join('&');
5358
5381
  var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&color=58a6ff&bgcolor=0d1117&data=' + encodeURIComponent(url);
5359
5382
 
5383
+ var tokenHtml = lanState.lan_token ?
5384
+ '<div style="margin-top:12px;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:10px">' +
5385
+ '<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px">Auth Token</div>' +
5386
+ '<div style="font-family:monospace;font-size:13px;color:var(--accent);word-break:break-all">' + escapeHtml(lanState.lan_token) + '</div>' +
5387
+ '<div style="font-size:10px;color:var(--text-muted);margin-top:4px">Included in the QR code URL. Only people with this token can access your dashboard.</div>' +
5388
+ '</div>' : '';
5389
+
5360
5390
  el.innerHTML =
5361
5391
  '<div class="phone-url-box">' +
5362
5392
  '<div class="phone-url-text">' + escapeHtml(url) + '</div>' +
@@ -5365,12 +5395,16 @@ function renderPhoneModalContent() {
5365
5395
  '<div class="phone-qr">' +
5366
5396
  '<img src="' + qrUrl + '" alt="QR Code" onerror="this.parentElement.style.display=\'none\'">' +
5367
5397
  '</div>' +
5368
- '<div class="phone-qr-hint">Scan with your phone camera on the same WiFi</div>';
5398
+ '<div class="phone-qr-hint">Scan with your phone camera on the same WiFi</div>' +
5399
+ tokenHtml;
5369
5400
  }
5370
5401
 
5371
5402
  function copyPhoneUrl() {
5372
5403
  var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
5373
- if (activeProject) url += '?project=' + encodeURIComponent(activeProject);
5404
+ var params = [];
5405
+ if (lanState.lan_token) params.push('token=' + encodeURIComponent(lanState.lan_token));
5406
+ if (activeProject) params.push('project=' + encodeURIComponent(activeProject));
5407
+ if (params.length) url += '?' + params.join('&');
5374
5408
  navigator.clipboard.writeText(url).then(function() {
5375
5409
  var btn = document.querySelector('.phone-url-copy');
5376
5410
  btn.textContent = 'Copied!';
@@ -5389,7 +5423,7 @@ var selectedCli = 'claude';
5389
5423
  function renderLaunchPanel() {
5390
5424
  var el = document.getElementById('launch-area');
5391
5425
  // Fetch templates
5392
- fetch('/api/templates').then(function(r) { return r.json(); }).then(function(templates) {
5426
+ lttFetch('/api/templates').then(function(r) { return r.json(); }).then(function(templates) {
5393
5427
  launchTemplates = templates;
5394
5428
  var templateOpts = '<option value="">-- No template --</option>';
5395
5429
  for (var i = 0; i < templates.length; i++) {
@@ -5436,8 +5470,8 @@ function renderLaunchPanel() {
5436
5470
  }
5437
5471
 
5438
5472
  // ==================== v3.4: CONVERSATION TEMPLATES ====================
5439
- function renderConversationTemplates(p){fetch('/api/conversation-templates').then(function(r){return r.json()}).then(function(t){if(!t.length)return;var s=document.createElement('div');s.className='launch-panel';s.style.marginTop='20px';var h='<h3 style="margin-bottom:4px">Conversation Templates</h3><p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">Pre-built multi-agent workflows. Click to see agent prompts.</p><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">';for(var i=0;i<t.length;i++){var c=t[i];var n=c.agents.map(function(a){return a.name}).join(', ');h+='<div style="background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;cursor:pointer;transition:all 0.15s" onclick="showConvTemplate(\''+escapeHtml(c.id)+'\')" onmouseover="this.style.borderColor=\'var(--accent)\'" onmouseout="this.style.borderColor=\'var(--border)\'"><div style="font-weight:600;font-size:13px;margin-bottom:4px">'+escapeHtml(c.name)+'</div><div style="font-size:11px;color:var(--text-dim);margin-bottom:8px">'+escapeHtml(c.description)+'</div><div style="font-size:10px;color:var(--text-muted)">Agents: '+escapeHtml(n)+'</div>'+(c.workflow?'<div style="font-size:10px;color:var(--purple);margin-top:4px">Workflow: '+escapeHtml(c.workflow.steps.join(' \u2192 '))+'</div>':'')+'</div>'}h+='</div><div id="conv-template-detail" style="margin-top:16px"></div>';s.innerHTML=h;p.appendChild(s)}).catch(function(){})}
5440
- function showConvTemplate(tid){var pq=activeProject?'?project='+encodeURIComponent(activeProject):'';fetch('/api/conversation-templates/launch'+pq,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({template_id:tid})}).then(function(r){return r.json()}).then(function(d){if(d.error){alert(d.error);return}var el=document.getElementById('conv-template-detail');var h='<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px"><div style="font-weight:700;font-size:15px;margin-bottom:4px">'+escapeHtml(d.template.name)+'</div><div style="font-size:12px;color:var(--text-dim);margin-bottom:12px">'+escapeHtml(d.template.description)+'</div>';if(d.template.workflow){h+='<div style="display:flex;gap:6px;align-items:center;margin-bottom:14px;flex-wrap:wrap">';var st=d.template.workflow.steps;for(var s=0;s<st.length;s++){if(s>0)h+='<span style="color:var(--text-muted);font-size:12px">\u2192</span>';h+='<span style="background:var(--purple-dim);color:var(--purple);padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600">'+escapeHtml(st[s])+'</span>'}h+='</div>'}h+='<div style="font-weight:600;font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px">Agent Prompts (copy each into a separate terminal)</div>';for(var i=0;i<d.instructions.length;i++){var inst=d.instructions[i];h+='<div style="margin-bottom:10px"><div style="display:flex;align-items:center;gap:8px;margin-bottom:4px"><span style="font-weight:600;font-size:13px">'+escapeHtml(inst.agent_name)+'</span><span class="role-badge">'+escapeHtml(inst.role)+'</span></div><div class="copy-block" onclick="copyText(this)" data-text="'+inst.prompt.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'"><span class="copy-hint">click to copy</span><span style="font-size:11px;color:var(--text-dim)">'+escapeHtml(inst.prompt.substring(0,200))+(inst.prompt.length>200?'...':'')+'</span></div></div>'}h+='</div>';el.innerHTML=h;el.scrollIntoView({behavior:'smooth',block:'nearest'})})}
5473
+ function renderConversationTemplates(p){lttFetch('/api/conversation-templates').then(function(r){return r.json()}).then(function(t){if(!t.length)return;var s=document.createElement('div');s.className='launch-panel';s.style.marginTop='20px';var h='<h3 style="margin-bottom:4px">Conversation Templates</h3><p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">Pre-built multi-agent workflows. Click to see agent prompts.</p><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">';for(var i=0;i<t.length;i++){var c=t[i];var n=c.agents.map(function(a){return a.name}).join(', ');h+='<div style="background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;cursor:pointer;transition:all 0.15s" onclick="showConvTemplate(\''+escapeHtml(c.id)+'\')" onmouseover="this.style.borderColor=\'var(--accent)\'" onmouseout="this.style.borderColor=\'var(--border)\'"><div style="font-weight:600;font-size:13px;margin-bottom:4px">'+escapeHtml(c.name)+'</div><div style="font-size:11px;color:var(--text-dim);margin-bottom:8px">'+escapeHtml(c.description)+'</div><div style="font-size:10px;color:var(--text-muted)">Agents: '+escapeHtml(n)+'</div>'+(c.workflow?'<div style="font-size:10px;color:var(--purple);margin-top:4px">Workflow: '+escapeHtml(c.workflow.steps.join(' \u2192 '))+'</div>':'')+'</div>'}h+='</div><div id="conv-template-detail" style="margin-top:16px"></div>';s.innerHTML=h;p.appendChild(s)}).catch(function(){})}
5474
+ function showConvTemplate(tid){var pq=activeProject?'?project='+encodeURIComponent(activeProject):'';lttFetch('/api/conversation-templates/launch'+pq,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({template_id:tid})}).then(function(r){return r.json()}).then(function(d){if(d.error){alert(d.error);return}var el=document.getElementById('conv-template-detail');var h='<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px"><div style="font-weight:700;font-size:15px;margin-bottom:4px">'+escapeHtml(d.template.name)+'</div><div style="font-size:12px;color:var(--text-dim);margin-bottom:12px">'+escapeHtml(d.template.description)+'</div>';if(d.template.workflow){h+='<div style="display:flex;gap:6px;align-items:center;margin-bottom:14px;flex-wrap:wrap">';var st=d.template.workflow.steps;for(var s=0;s<st.length;s++){if(s>0)h+='<span style="color:var(--text-muted);font-size:12px">\u2192</span>';h+='<span style="background:var(--purple-dim);color:var(--purple);padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600">'+escapeHtml(st[s])+'</span>'}h+='</div>'}h+='<div style="font-weight:600;font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px">Agent Prompts (copy each into a separate terminal)</div>';for(var i=0;i<d.instructions.length;i++){var inst=d.instructions[i];h+='<div style="margin-bottom:10px"><div style="display:flex;align-items:center;gap:8px;margin-bottom:4px"><span style="font-weight:600;font-size:13px">'+escapeHtml(inst.agent_name)+'</span><span class="role-badge">'+escapeHtml(inst.role)+'</span></div><div class="copy-block" onclick="copyText(this)" data-text="'+inst.prompt.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'"><span class="copy-hint">click to copy</span><span style="font-size:11px;color:var(--text-dim)">'+escapeHtml(inst.prompt.substring(0,200))+(inst.prompt.length>200?'...':'')+'</span></div></div>'}h+='</div>';el.innerHTML=h;el.scrollIntoView({behavior:'smooth',block:'nearest'})})}
5441
5475
 
5442
5476
  function selectCli(cli) {
5443
5477
  selectedCli = cli;
@@ -5471,7 +5505,7 @@ function doLaunch() {
5471
5505
  var prompt = document.getElementById('launch-prompt').value.trim();
5472
5506
  var resultEl = document.getElementById('launch-result');
5473
5507
 
5474
- fetch('/api/launch', {
5508
+ lttFetch('/api/launch', {
5475
5509
  method: 'POST',
5476
5510
  headers: { 'Content-Type': 'application/json' },
5477
5511
  body: JSON.stringify({ cli: selectedCli, project_dir: projectDir || undefined, agent_name: agentName, prompt: prompt || undefined })
@@ -5539,7 +5573,8 @@ function setConnStatus(status) {
5539
5573
 
5540
5574
  function initSSE() {
5541
5575
  try {
5542
- var eventSource = new EventSource('/api/events');
5576
+ var sseUrl = '/api/events' + (_lttToken ? '?token=' + encodeURIComponent(_lttToken) : '');
5577
+ var eventSource = new EventSource(sseUrl);
5543
5578
  eventSource.onmessage = function(e) {
5544
5579
  if (e.data === 'update' || e.data === 'connected') {
5545
5580
  poll();
package/dashboard.js CHANGED
@@ -18,6 +18,26 @@ const PORT = parseInt(process.env.AGENT_BRIDGE_PORT || '3000', 10);
18
18
  const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
19
19
  let LAN_MODE = process.env.AGENT_BRIDGE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
20
20
 
21
+ const LAN_TOKEN_FILE = path.join(__dirname, '.lan-token');
22
+ let LAN_TOKEN = null;
23
+
24
+ function generateLanToken() {
25
+ const crypto = require('crypto');
26
+ LAN_TOKEN = crypto.randomBytes(16).toString('hex');
27
+ try { fs.writeFileSync(LAN_TOKEN_FILE, LAN_TOKEN); } catch {}
28
+ return LAN_TOKEN;
29
+ }
30
+
31
+ function loadLanToken() {
32
+ if (fs.existsSync(LAN_TOKEN_FILE)) {
33
+ try { LAN_TOKEN = fs.readFileSync(LAN_TOKEN_FILE, 'utf8').trim(); } catch {}
34
+ }
35
+ if (!LAN_TOKEN) generateLanToken();
36
+ }
37
+
38
+ // Load or generate token on startup
39
+ loadLanToken();
40
+
21
41
  function persistLanMode() {
22
42
  try { fs.writeFileSync(LAN_STATE_FILE, LAN_MODE ? 'true' : 'false'); } catch {}
23
43
  }
@@ -1009,13 +1029,15 @@ const server = http.createServer(async (req, res) => {
1009
1029
 
1010
1030
  const allowedOrigin = `http://localhost:${PORT}`;
1011
1031
  const reqOrigin = req.headers.origin;
1012
- if (LAN_MODE && reqOrigin) {
1013
- res.setHeader('Access-Control-Allow-Origin', '*');
1014
- } else if (reqOrigin === allowedOrigin || reqOrigin === `http://127.0.0.1:${PORT}`) {
1032
+ const lanIP = getLanIP();
1033
+ const lanOrigin = lanIP ? `http://${lanIP}:${PORT}` : null;
1034
+ const trustedOrigins = [allowedOrigin, `http://127.0.0.1:${PORT}`];
1035
+ if (LAN_MODE && lanOrigin) trustedOrigins.push(lanOrigin);
1036
+ if (reqOrigin && trustedOrigins.includes(reqOrigin)) {
1015
1037
  res.setHeader('Access-Control-Allow-Origin', reqOrigin);
1016
1038
  }
1017
1039
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
1018
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1040
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-LTT-Request, X-LTT-Token');
1019
1041
 
1020
1042
  if (req.method === 'OPTIONS') {
1021
1043
  res.writeHead(204);
@@ -1023,7 +1045,23 @@ const server = http.createServer(async (req, res) => {
1023
1045
  return;
1024
1046
  }
1025
1047
 
1026
- // CSRF + DNS rebinding protection: validate Host and Origin on mutating requests
1048
+ // LAN auth token required for non-localhost requests when LAN mode is active
1049
+ if (LAN_MODE) {
1050
+ const host = (req.headers.host || '').replace(/:\d+$/, '');
1051
+ const isLocalhost = host === 'localhost' || host === '127.0.0.1';
1052
+ if (!isLocalhost) {
1053
+ const tokenFromQuery = url.searchParams.get('token');
1054
+ const tokenFromHeader = req.headers['x-ltt-token'];
1055
+ const providedToken = tokenFromHeader || tokenFromQuery;
1056
+ if (!providedToken || providedToken !== LAN_TOKEN) {
1057
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1058
+ res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing LAN token' }));
1059
+ return;
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ // CSRF + DNS rebinding protection: validate Host, Origin, and custom header on mutating requests
1027
1065
  if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
1028
1066
  // Check Host header to block DNS rebinding attacks
1029
1067
  const host = (req.headers.host || '').replace(/:\d+$/, '');
@@ -1034,13 +1072,26 @@ const server = http.createServer(async (req, res) => {
1034
1072
  res.end(JSON.stringify({ error: 'Forbidden: invalid host' }));
1035
1073
  return;
1036
1074
  }
1075
+ // Require custom header — browsers block cross-origin custom headers without preflight,
1076
+ // which our CORS policy won't approve for foreign origins. This closes the no-Origin gap.
1077
+ if (!req.headers['x-ltt-request']) {
1078
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1079
+ res.end(JSON.stringify({ error: 'Forbidden: missing X-LTT-Request header' }));
1080
+ return;
1081
+ }
1037
1082
  // Check Origin header to block cross-site requests
1083
+ // Empty origin is NOT trusted — requires at least the custom header (checked above)
1038
1084
  const origin = req.headers.origin || '';
1039
1085
  const referer = req.headers.referer || '';
1040
1086
  const source = origin || referer;
1041
- const isLocal = !source || source.includes('localhost:' + PORT) || source.includes('127.0.0.1:' + PORT);
1042
- const isLan = LAN_MODE && getLanIP() && source.includes(getLanIP() + ':' + PORT);
1043
- if (!isLocal && !isLan) {
1087
+ if (!source) {
1088
+ // No origin/referer non-browser client (curl, scripts, etc.)
1089
+ // Custom header check above is the only protection layer here — allow through
1090
+ // since local CLI tools (like our own `msg` command) need to work
1091
+ }
1092
+ const isLocal = source && (source.includes('localhost:' + PORT) || source.includes('127.0.0.1:' + PORT));
1093
+ const isLan = LAN_MODE && getLanIP() && source && source.includes(getLanIP() + ':' + PORT);
1094
+ if (source && !isLocal && !isLan) {
1044
1095
  res.writeHead(403, { 'Content-Type': 'application/json' });
1045
1096
  res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
1046
1097
  return;
@@ -1074,6 +1125,7 @@ const server = http.createServer(async (req, res) => {
1074
1125
  const html = fs.readFileSync(HTML_FILE, 'utf8');
1075
1126
  res.writeHead(200, {
1076
1127
  'Content-Type': 'text/html; charset=utf-8',
1128
+ 'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'",
1077
1129
  'Cache-Control': 'no-cache, no-store, must-revalidate',
1078
1130
  'Pragma': 'no-cache',
1079
1131
  'Expires': '0'
@@ -1321,7 +1373,7 @@ const server = http.createServer(async (req, res) => {
1321
1373
  // Server info (LAN mode detection for frontend)
1322
1374
  else if (url.pathname === '/api/server-info' && req.method === 'GET') {
1323
1375
  res.writeHead(200, { 'Content-Type': 'application/json' });
1324
- res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT }));
1376
+ res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT, lan_token: LAN_MODE ? LAN_TOKEN : null }));
1325
1377
  }
1326
1378
  // Toggle LAN mode (re-bind server live)
1327
1379
  else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
@@ -1329,9 +1381,11 @@ const server = http.createServer(async (req, res) => {
1329
1381
  const lanIP = getLanIP();
1330
1382
  LAN_MODE = newMode;
1331
1383
  persistLanMode();
1384
+ // Regenerate token when enabling LAN mode
1385
+ if (newMode) generateLanToken();
1332
1386
  // Send response first
1333
1387
  res.writeHead(200, { 'Content-Type': 'application/json' });
1334
- res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT }));
1388
+ res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT, lan_token: newMode ? LAN_TOKEN : null }));
1335
1389
  // Re-bind by stopping the listener and immediately re-listening
1336
1390
  // Use setImmediate to let the response flush first
1337
1391
  setImmediate(() => {
@@ -1447,7 +1501,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
1447
1501
  const dataDir = resolveDataDir();
1448
1502
  const lanIP = getLanIP();
1449
1503
  console.log('');
1450
- console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.1');
1504
+ console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.2');
1451
1505
  console.log(' ============================================');
1452
1506
  console.log(' Dashboard: http://localhost:' + PORT);
1453
1507
  if (LAN_MODE && lanIP) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "let-them-talk",
3
- "version": "3.4.1",
3
+ "version": "3.4.2",
4
4
  "description": "MCP message broker + web dashboard for inter-agent communication. Let AI CLI agents talk to each other.",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -2097,7 +2097,7 @@ async function main() {
2097
2097
  loadPlugins();
2098
2098
  const transport = new StdioServerTransport();
2099
2099
  await server.connect(transport);
2100
- console.error('Agent Bridge MCP server v3.4.1 running (' + (27 + loadedPlugins.length) + ' tools)');
2100
+ console.error('Agent Bridge MCP server v3.4.2 running (' + (27 + loadedPlugins.length) + ' tools)');
2101
2101
  }
2102
2102
 
2103
2103
  main().catch(console.error);