let-them-talk 3.4.0 → 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,54 @@
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
+
32
+ ## [3.4.1] - 2026-03-15
33
+
34
+ ### Added
35
+ - **File-level mutex** — in-memory promise queue per file for serializing edit/delete operations
36
+ - **Agent permissions enforcement** — `canSendTo()` checks in `send_message` and `broadcast`, `can_read` filtering in `get_history` and message delivery
37
+ - **Read receipts** — auto-recorded when agents consume messages, visible as agent-initial dots under messages in dashboard
38
+
39
+ ### Security
40
+ - HTTP 500 responses now return generic error instead of raw `err.message` (prevents filesystem path leaks)
41
+ - `/api/discover` changed from GET to POST (now under CSRF protection)
42
+ - `workspace_read`/`workspace_list` validate agent name parameter with regex
43
+ - `get_history` filters results by agent's `can_read` permissions
44
+ - `read_receipts.json` and `permissions.json` added to both MCP and dashboard reset cleanup
45
+ - Dashboard workspace API regex aligned with server (`[a-zA-Z0-9_-]`)
46
+
47
+ ### Fixed
48
+ - `toolWaitForReply` missing `markAsRead` calls (read receipts not recorded)
49
+ - `toolBroadcast` bypassing permission checks entirely
50
+ - `toolReset` not cleaning up `permissions.json` and `read_receipts.json`
51
+
3
52
  ## [3.4.0] - 2026-03-15
4
53
 
5
54
  ### Added — Dashboard Features
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.0
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.0
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
@@ -850,6 +850,27 @@
850
850
 
851
851
  .msg-content a:hover { text-decoration: underline; }
852
852
 
853
+ /* ===== READ RECEIPTS ===== */
854
+ .read-receipts {
855
+ display: flex;
856
+ gap: 2px;
857
+ margin-top: 4px;
858
+ align-items: center;
859
+ }
860
+ .read-receipt-dot {
861
+ width: 14px;
862
+ height: 14px;
863
+ border-radius: 50%;
864
+ background: var(--surface-3);
865
+ display: flex;
866
+ align-items: center;
867
+ justify-content: center;
868
+ font-size: 8px;
869
+ font-weight: 700;
870
+ color: var(--text-dim);
871
+ cursor: default;
872
+ }
873
+
853
874
  /* ===== MESSAGE INPUT ===== */
854
875
  .msg-input-bar {
855
876
  border-top: 1px solid var(--border);
@@ -2821,7 +2842,7 @@
2821
2842
  </div>
2822
2843
  </div>
2823
2844
  <div class="app-footer">
2824
- <span>Let Them Talk v3.4.0</span>
2845
+ <span>Let Them Talk v3.4.2</span>
2825
2846
  </div>
2826
2847
  <div class="profile-popup" id="profile-popup" onclick="event.stopPropagation()">
2827
2848
  <div class="profile-popup-header">
@@ -2876,6 +2897,26 @@ var POLL_INTERVAL = 2000;
2876
2897
  var SLEEP_THRESHOLD = 60; // seconds
2877
2898
  var lastMessageCount = 0;
2878
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
+
2879
2920
  var activeThread = null;
2880
2921
  var activeProject = ''; // empty = default/local
2881
2922
  var cachedHistory = [];
@@ -3206,7 +3247,7 @@ function renderAgents(agents) {
3206
3247
 
3207
3248
  function sendNudge(agentName) {
3208
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.' });
3209
- fetch('/api/inject' + projectParam(), {
3250
+ lttFetch('/api/inject' + projectParam(), {
3210
3251
  method: 'POST',
3211
3252
  headers: { 'Content-Type': 'application/json' },
3212
3253
  body: body
@@ -3264,7 +3305,7 @@ function doInject() {
3264
3305
  if (!target || !content) return;
3265
3306
 
3266
3307
  var body = JSON.stringify({ to: target, content: content });
3267
- fetch('/api/inject' + projectParam(), {
3308
+ lttFetch('/api/inject' + projectParam(), {
3268
3309
  method: 'POST',
3269
3310
  headers: { 'Content-Type': 'application/json' },
3270
3311
  body: body
@@ -3448,6 +3489,7 @@ function renderMessages(messages) {
3448
3489
  '</div>' +
3449
3490
  '<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
3450
3491
  buildReactionsHtml(m.id) +
3492
+ buildReadReceipts(m.id) +
3451
3493
  '</div></div>';
3452
3494
  lastFrom = m.from;
3453
3495
  lastTo = m.to;
@@ -3469,6 +3511,7 @@ function renderMessages(messages) {
3469
3511
  '<div class="file-meta"><span class="file-icon">&#x1f4c4;</span>' + escapeHtml(fileName) + '<span class="file-size">' + fileSize + '</span></div>' +
3470
3512
  '<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
3471
3513
  buildReactionsHtml(m.id) +
3514
+ buildReadReceipts(m.id) +
3472
3515
  '</div></div>';
3473
3516
  lastFrom = m.from;
3474
3517
  lastTo = m.to;
@@ -3486,6 +3529,7 @@ function renderMessages(messages) {
3486
3529
  '</div>' +
3487
3530
  '<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
3488
3531
  buildReactionsHtml(m.id) +
3532
+ buildReadReceipts(m.id) +
3489
3533
  '</div></div>';
3490
3534
  lastFrom = m.from;
3491
3535
  lastTo = m.to;
@@ -3582,6 +3626,33 @@ function onSearch() {
3582
3626
  renderMessages(cachedHistory);
3583
3627
  }
3584
3628
 
3629
+ // ==================== READ RECEIPTS ====================
3630
+
3631
+ var cachedReadReceipts = {};
3632
+
3633
+ function fetchReadReceipts() {
3634
+ var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3635
+ lttFetch('/api/read-receipts' + pq).then(function(r) { return r.json(); }).then(function(data) {
3636
+ cachedReadReceipts = data || {};
3637
+ }).catch(function() {});
3638
+ }
3639
+
3640
+ function buildReadReceipts(msgId) {
3641
+ var receipts = cachedReadReceipts[msgId];
3642
+ if (!receipts) return '';
3643
+ var agents = Object.keys(receipts);
3644
+ if (!agents.length) return '';
3645
+ var html = '<div class="read-receipts">';
3646
+ for (var i = 0; i < agents.length; i++) {
3647
+ var agent = agents[i];
3648
+ var time = receipts[agent] ? new Date(receipts[agent]).toLocaleTimeString() : '';
3649
+ var color = getColor(agent);
3650
+ html += '<div class="read-receipt-dot" style="background:' + color + '" title="Read by ' + escapeHtml(agent) + (time ? ' at ' + time : '') + '">' + initial(agent) + '</div>';
3651
+ }
3652
+ html += '</div>';
3653
+ return html;
3654
+ }
3655
+
3585
3656
  // ==================== REACTIONS ====================
3586
3657
 
3587
3658
  var REACTION_EMOJIS = ['\ud83d\udc4d', '\u2705', '\u2764\ufe0f', '\ud83e\udd14', '\ud83d\udd25'];
@@ -3701,7 +3772,7 @@ function deleteMessage(msgId) {
3701
3772
  var preview = msg.content.substring(0, 60) + (msg.content.length > 60 ? '...' : '');
3702
3773
  if (!confirm('Delete this message from ' + msg.from + '?\n\n"' + preview + '"')) return;
3703
3774
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3704
- fetch('/api/message' + pq, {
3775
+ lttFetch('/api/message' + pq, {
3705
3776
  method: 'DELETE',
3706
3777
  headers: { 'Content-Type': 'application/json' },
3707
3778
  body: JSON.stringify({ id: msgId })
@@ -3751,7 +3822,7 @@ function saveEditMessage() {
3751
3822
  var content = document.getElementById('edit-msg-content').value.trim();
3752
3823
  if (!content) return;
3753
3824
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3754
- fetch('/api/message' + pq, {
3825
+ lttFetch('/api/message' + pq, {
3755
3826
  method: 'PUT',
3756
3827
  headers: { 'Content-Type': 'application/json' },
3757
3828
  body: JSON.stringify({ id: msgId, content: content })
@@ -3943,7 +4014,7 @@ function renderAgentStats() {
3943
4014
 
3944
4015
  function fetchActivity() {
3945
4016
  var pq = projectParam();
3946
- 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) {
3947
4018
  renderActivityHeatmap(data);
3948
4019
  }).catch(function() {});
3949
4020
  }
@@ -4123,7 +4194,7 @@ var cachedTasks = [];
4123
4194
 
4124
4195
  function fetchTasks() {
4125
4196
  var pq = projectParam();
4126
- 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) {
4127
4198
  cachedTasks = Array.isArray(tasks) ? tasks : [];
4128
4199
  renderTasks();
4129
4200
  }).catch(function() {
@@ -4214,7 +4285,7 @@ function buildTaskCard(t) {
4214
4285
  }
4215
4286
 
4216
4287
  function updateTaskStatus(taskId, newStatus) {
4217
- fetch('/api/tasks' + projectParam(), {
4288
+ lttFetch('/api/tasks' + projectParam(), {
4218
4289
  method: 'POST',
4219
4290
  headers: { 'Content-Type': 'application/json' },
4220
4291
  body: JSON.stringify({ task_id: taskId, status: newStatus })
@@ -4271,7 +4342,7 @@ var AGENT_COLORS = ['#58a6ff', '#f78166', '#7ee787', '#d2a8ff', '#ffa657', '#ff7
4271
4342
 
4272
4343
  function fetchStats() {
4273
4344
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
4274
- 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) {
4275
4346
  renderStats(data);
4276
4347
  }).catch(function(e) { console.error('Stats fetch failed:', e); });
4277
4348
  }
@@ -4520,7 +4591,7 @@ function saveProfile() {
4520
4591
  };
4521
4592
  if (avatar) body.avatar = avatar;
4522
4593
 
4523
- fetch('/api/profiles' + projectParam(), {
4594
+ lttFetch('/api/profiles' + projectParam(), {
4524
4595
  method: 'POST',
4525
4596
  headers: { 'Content-Type': 'application/json' },
4526
4597
  body: JSON.stringify(body)
@@ -4536,7 +4607,7 @@ function saveProfile() {
4536
4607
 
4537
4608
  function fetchWorkspaces() {
4538
4609
  var pq = projectParam();
4539
- 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) {
4540
4611
  renderWorkspaces(data);
4541
4612
  }).catch(function() {});
4542
4613
  }
@@ -4583,7 +4654,7 @@ function renderWorkspaces(data) {
4583
4654
 
4584
4655
  function fetchWorkflows() {
4585
4656
  var pq = projectParam();
4586
- 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) {
4587
4658
  renderWorkflows(Array.isArray(data) ? data : []);
4588
4659
  }).catch(function() {});
4589
4660
  }
@@ -4634,7 +4705,7 @@ function renderWorkflows(workflows) {
4634
4705
  }
4635
4706
 
4636
4707
  function dashAdvanceWorkflow(wfId) {
4637
- fetch('/api/workflows' + projectParam(), {
4708
+ lttFetch('/api/workflows' + projectParam(), {
4638
4709
  method: 'POST',
4639
4710
  headers: { 'Content-Type': 'application/json' },
4640
4711
  body: JSON.stringify({ action: 'advance', workflow_id: wfId })
@@ -4647,7 +4718,7 @@ var activeBranch = '';
4647
4718
 
4648
4719
  function fetchBranches() {
4649
4720
  var pq = projectParam();
4650
- 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) {
4651
4722
  renderBranchTabs(data);
4652
4723
  }).catch(function() {});
4653
4724
  }
@@ -4683,7 +4754,7 @@ function switchBranch(name) {
4683
4754
 
4684
4755
  function fetchPlugins() {
4685
4756
  var pq = projectParam();
4686
- 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) {
4687
4758
  renderPlugins(Array.isArray(data) ? data : []);
4688
4759
  }).catch(function() {});
4689
4760
  }
@@ -4710,7 +4781,7 @@ function renderPlugins(plugins) {
4710
4781
  }
4711
4782
 
4712
4783
  function togglePlugin(name) {
4713
- fetch('/api/plugins' + projectParam(), {
4784
+ lttFetch('/api/plugins' + projectParam(), {
4714
4785
  method: 'POST',
4715
4786
  headers: { 'Content-Type': 'application/json' },
4716
4787
  body: JSON.stringify({ action: 'toggle', name: name })
@@ -4725,9 +4796,9 @@ function poll() {
4725
4796
  var bp = activeBranch && activeBranch !== 'main' ? '&branch=' + encodeURIComponent(activeBranch) : '';
4726
4797
  var pollStart = Date.now();
4727
4798
  Promise.all([
4728
- fetch('/api/history?limit=500' + pp + bp).then(function(r) { return r.json(); }),
4729
- fetch('/api/agents' + pq).then(function(r) { return r.json(); }),
4730
- 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(); }),
4731
4802
  ]).then(function(results) {
4732
4803
  console.log('[LTT] poll ok — history:' + results[0].length + ' agents:' + Object.keys(results[1]).length + ' project:' + (activeProject || 'default'));
4733
4804
  updateConnectionInfo(Date.now() - pollStart);
@@ -4774,6 +4845,7 @@ function poll() {
4774
4845
  if (activeView === 'workspaces') fetchWorkspaces();
4775
4846
  if (activeView === 'workflows') fetchWorkflows();
4776
4847
  if (activeView === 'stats') fetchStats();
4848
+ fetchReadReceipts();
4777
4849
  }).catch(function(e) {
4778
4850
  console.error('Poll failed:', e);
4779
4851
  document.getElementById('conn-detail').textContent = ' ERR: ' + e.message;
@@ -4782,7 +4854,7 @@ function poll() {
4782
4854
 
4783
4855
  function doReset() {
4784
4856
  if (!confirm('Clear all messages, agents, and history?')) return;
4785
- fetch('/api/reset' + projectParam(), { method: 'POST' }).then(function() {
4857
+ lttFetch('/api/reset' + projectParam(), { method: 'POST' }).then(function() {
4786
4858
  lastMessageCount = 0;
4787
4859
  activeThread = null;
4788
4860
  cachedHistory = [];
@@ -4794,7 +4866,7 @@ function doReset() {
4794
4866
  // ==================== PROJECT MANAGEMENT ====================
4795
4867
 
4796
4868
  function loadProjects() {
4797
- 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) {
4798
4870
  console.log('[LTT] loadProjects:', projects.length, 'projects, activeProject:', activeProject);
4799
4871
  var sel = document.getElementById('project-select');
4800
4872
  // Keep the first option (Default)
@@ -4892,7 +4964,7 @@ function addProject() {
4892
4964
  var projectPath = input.value.trim();
4893
4965
  if (!projectPath) return;
4894
4966
 
4895
- fetch('/api/projects', {
4967
+ lttFetch('/api/projects', {
4896
4968
  method: 'POST',
4897
4969
  headers: { 'Content-Type': 'application/json' },
4898
4970
  body: JSON.stringify({ path: projectPath })
@@ -4918,7 +4990,7 @@ function discoverProjects() {
4918
4990
  resultsEl.style.display = 'block';
4919
4991
  resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">Scanning...</div>';
4920
4992
 
4921
- fetch('/api/discover').then(function(r) { return r.json(); }).then(function(found) {
4993
+ lttFetch('/api/discover', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(found) {
4922
4994
  if (!found.length) {
4923
4995
  resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">No new projects found (all discovered projects already added)</div>';
4924
4996
  setTimeout(function() { resultsEl.style.display = 'none'; }, 3000);
@@ -4944,7 +5016,7 @@ function discoverProjects() {
4944
5016
  }
4945
5017
 
4946
5018
  function addDiscovered(projectPath, name) {
4947
- fetch('/api/projects', {
5019
+ lttFetch('/api/projects', {
4948
5020
  method: 'POST',
4949
5021
  headers: { 'Content-Type': 'application/json' },
4950
5022
  body: JSON.stringify({ path: projectPath, name: name })
@@ -4960,7 +5032,7 @@ function removeProject() {
4960
5032
  if (!activeProject) return;
4961
5033
  if (!confirm('Remove this project from the dashboard?')) return;
4962
5034
 
4963
- fetch('/api/projects', {
5035
+ lttFetch('/api/projects', {
4964
5036
  method: 'DELETE',
4965
5037
  headers: { 'Content-Type': 'application/json' },
4966
5038
  body: JSON.stringify({ path: activeProject })
@@ -5223,7 +5295,7 @@ updateNotifBtn();
5223
5295
  var lanState = { lan_mode: false, lan_ip: null, port: 3000 };
5224
5296
 
5225
5297
  function fetchLanState() {
5226
- 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) {
5227
5299
  lanState = info;
5228
5300
  updateLanUI();
5229
5301
  return info;
@@ -5273,7 +5345,7 @@ function toggleLanMode() {
5273
5345
  var content = document.getElementById('phone-modal-content');
5274
5346
  content.innerHTML = '<div class="phone-off-state"><p>Switching...</p></div>';
5275
5347
 
5276
- fetch('/api/toggle-lan', { method: 'POST' })
5348
+ lttFetch('/api/toggle-lan', { method: 'POST' })
5277
5349
  .then(function(r) { return r.json(); })
5278
5350
  .then(function(info) {
5279
5351
  lanState = info;
@@ -5301,10 +5373,20 @@ function renderPhoneModalContent() {
5301
5373
  }
5302
5374
 
5303
5375
  var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
5304
- // Include active project so the phone shows the same view
5305
- 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('&');
5306
5381
  var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&color=58a6ff&bgcolor=0d1117&data=' + encodeURIComponent(url);
5307
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
+
5308
5390
  el.innerHTML =
5309
5391
  '<div class="phone-url-box">' +
5310
5392
  '<div class="phone-url-text">' + escapeHtml(url) + '</div>' +
@@ -5313,12 +5395,16 @@ function renderPhoneModalContent() {
5313
5395
  '<div class="phone-qr">' +
5314
5396
  '<img src="' + qrUrl + '" alt="QR Code" onerror="this.parentElement.style.display=\'none\'">' +
5315
5397
  '</div>' +
5316
- '<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;
5317
5400
  }
5318
5401
 
5319
5402
  function copyPhoneUrl() {
5320
5403
  var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
5321
- 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('&');
5322
5408
  navigator.clipboard.writeText(url).then(function() {
5323
5409
  var btn = document.querySelector('.phone-url-copy');
5324
5410
  btn.textContent = 'Copied!';
@@ -5337,7 +5423,7 @@ var selectedCli = 'claude';
5337
5423
  function renderLaunchPanel() {
5338
5424
  var el = document.getElementById('launch-area');
5339
5425
  // Fetch templates
5340
- 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) {
5341
5427
  launchTemplates = templates;
5342
5428
  var templateOpts = '<option value="">-- No template --</option>';
5343
5429
  for (var i = 0; i < templates.length; i++) {
@@ -5384,8 +5470,8 @@ function renderLaunchPanel() {
5384
5470
  }
5385
5471
 
5386
5472
  // ==================== v3.4: CONVERSATION TEMPLATES ====================
5387
- 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(){})}
5388
- 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'})})}
5389
5475
 
5390
5476
  function selectCli(cli) {
5391
5477
  selectedCli = cli;
@@ -5419,7 +5505,7 @@ function doLaunch() {
5419
5505
  var prompt = document.getElementById('launch-prompt').value.trim();
5420
5506
  var resultEl = document.getElementById('launch-result');
5421
5507
 
5422
- fetch('/api/launch', {
5508
+ lttFetch('/api/launch', {
5423
5509
  method: 'POST',
5424
5510
  headers: { 'Content-Type': 'application/json' },
5425
5511
  body: JSON.stringify({ cli: selectedCli, project_dir: projectDir || undefined, agent_name: agentName, prompt: prompt || undefined })
@@ -5487,7 +5573,8 @@ function setConnStatus(status) {
5487
5573
 
5488
5574
  function initSSE() {
5489
5575
  try {
5490
- var eventSource = new EventSource('/api/events');
5576
+ var sseUrl = '/api/events' + (_lttToken ? '?token=' + encodeURIComponent(_lttToken) : '');
5577
+ var eventSource = new EventSource(sseUrl);
5491
5578
  eventSource.onmessage = function(e) {
5492
5579
  if (e.data === 'update' || e.data === 'connected') {
5493
5580
  poll();
package/dashboard.js CHANGED
@@ -5,10 +5,39 @@ const path = require('path');
5
5
  const os = require('os');
6
6
  const { spawn } = require('child_process');
7
7
 
8
+ // --- File-level mutex for serializing read-then-write operations ---
9
+ const lockMap = new Map();
10
+ function withFileLock(filePath, fn) {
11
+ const prev = lockMap.get(filePath) || Promise.resolve();
12
+ const next = prev.then(fn, fn);
13
+ lockMap.set(filePath, next.then(() => {}, () => {}));
14
+ return next;
15
+ }
16
+
8
17
  const PORT = parseInt(process.env.AGENT_BRIDGE_PORT || '3000', 10);
9
18
  const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
10
19
  let LAN_MODE = process.env.AGENT_BRIDGE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
11
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
+
12
41
  function persistLanMode() {
13
42
  try { fs.writeFileSync(LAN_STATE_FILE, LAN_MODE ? 'true' : 'false'); } catch {}
14
43
  }
@@ -299,7 +328,7 @@ function apiStats(query) {
299
328
  function apiReset(query) {
300
329
  const projectPath = query.get('project') || null;
301
330
  const dataDir = resolveDataDir(projectPath);
302
- const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'plugins.json'];
331
+ const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'plugins.json', 'read_receipts.json', 'permissions.json'];
303
332
  for (const f of fixedFiles) {
304
333
  const p = path.join(dataDir, f);
305
334
  if (fs.existsSync(p)) fs.unlinkSync(p);
@@ -734,7 +763,7 @@ function apiLaunchAgent(body) {
734
763
  }
735
764
 
736
765
  // --- v3.4: Message Edit ---
737
- function apiEditMessage(body, query) {
766
+ async function apiEditMessage(body, query) {
738
767
  const projectPath = query.get('project') || null;
739
768
  const { id, content } = body;
740
769
  if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
@@ -747,36 +776,17 @@ function apiEditMessage(body, query) {
747
776
  let found = false;
748
777
  const now = new Date().toISOString();
749
778
 
750
- // Update in history.jsonl
751
- if (fs.existsSync(historyFile)) {
752
- const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n').filter(Boolean);
753
- const updated = lines.map(line => {
754
- try {
755
- const msg = JSON.parse(line);
756
- if (msg.id === id) {
757
- found = true;
758
- if (!msg.edit_history) msg.edit_history = [];
759
- msg.edit_history.push({ content: msg.content, edited_at: now });
760
- msg.content = content;
761
- msg.edited = true;
762
- msg.edited_at = now;
763
- return JSON.stringify(msg);
764
- }
765
- return line;
766
- } catch { return line; }
767
- });
768
- if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
769
- }
770
-
771
- // Also update in messages.jsonl (for agents that haven't consumed yet)
772
- if (found && fs.existsSync(messagesFile)) {
773
- const raw = fs.readFileSync(messagesFile, 'utf8').trim();
774
- if (raw) {
775
- const lines = raw.split('\n');
779
+ // Update in history.jsonl (locked)
780
+ await withFileLock(historyFile, () => {
781
+ if (fs.existsSync(historyFile)) {
782
+ const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n').filter(Boolean);
776
783
  const updated = lines.map(line => {
777
784
  try {
778
785
  const msg = JSON.parse(line);
779
786
  if (msg.id === id) {
787
+ found = true;
788
+ if (!msg.edit_history) msg.edit_history = [];
789
+ msg.edit_history.push({ content: msg.content, edited_at: now });
780
790
  msg.content = content;
781
791
  msg.edited = true;
782
792
  msg.edited_at = now;
@@ -785,8 +795,33 @@ function apiEditMessage(body, query) {
785
795
  return line;
786
796
  } catch { return line; }
787
797
  });
788
- fs.writeFileSync(messagesFile, updated.join('\n') + '\n');
798
+ if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
789
799
  }
800
+ });
801
+
802
+ // Also update in messages.jsonl (locked independently)
803
+ if (found) {
804
+ await withFileLock(messagesFile, () => {
805
+ if (fs.existsSync(messagesFile)) {
806
+ const raw = fs.readFileSync(messagesFile, 'utf8').trim();
807
+ if (raw) {
808
+ const lines = raw.split('\n');
809
+ const updated = lines.map(line => {
810
+ try {
811
+ const msg = JSON.parse(line);
812
+ if (msg.id === id) {
813
+ msg.content = content;
814
+ msg.edited = true;
815
+ msg.edited_at = now;
816
+ return JSON.stringify(msg);
817
+ }
818
+ return line;
819
+ } catch { return line; }
820
+ });
821
+ fs.writeFileSync(messagesFile, updated.join('\n') + '\n');
822
+ }
823
+ }
824
+ });
790
825
  }
791
826
 
792
827
  if (!found) return { error: 'Message not found' };
@@ -794,7 +829,7 @@ function apiEditMessage(body, query) {
794
829
  }
795
830
 
796
831
  // --- v3.4: Message Delete ---
797
- function apiDeleteMessage(body, query) {
832
+ async function apiDeleteMessage(body, query) {
798
833
  const projectPath = query.get('project') || null;
799
834
  const { id } = body;
800
835
  if (!id) return { error: 'Missing "id" field' };
@@ -806,16 +841,28 @@ function apiDeleteMessage(body, query) {
806
841
  let found = false;
807
842
  let msgFrom = null;
808
843
 
809
- // Find the message first to check permissions
810
- if (fs.existsSync(historyFile)) {
811
- const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
812
- for (const line of lines) {
813
- try {
814
- const msg = JSON.parse(line);
815
- if (msg.id === id) { found = true; msgFrom = msg.from; break; }
816
- } catch {}
844
+ // Find the message and remove from history.jsonl (locked)
845
+ await withFileLock(historyFile, () => {
846
+ if (fs.existsSync(historyFile)) {
847
+ const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
848
+ for (const line of lines) {
849
+ try {
850
+ const msg = JSON.parse(line);
851
+ if (msg.id === id) { found = true; msgFrom = msg.from; break; }
852
+ } catch {}
853
+ }
854
+
855
+ if (found) {
856
+ const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
857
+ if (allowed.includes(msgFrom)) {
858
+ const filtered = lines.filter(line => {
859
+ try { return JSON.parse(line).id !== id; } catch { return true; }
860
+ });
861
+ fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
862
+ }
863
+ }
817
864
  }
818
- }
865
+ });
819
866
 
820
867
  if (!found) return { error: 'Message not found' };
821
868
 
@@ -825,23 +872,16 @@ function apiDeleteMessage(body, query) {
825
872
  return { error: 'Can only delete messages sent from Dashboard or system' };
826
873
  }
827
874
 
828
- // Remove from history.jsonl
829
- if (fs.existsSync(historyFile)) {
830
- const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
831
- const filtered = lines.filter(line => {
832
- try { return JSON.parse(line).id !== id; } catch { return true; }
833
- });
834
- fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
835
- }
836
-
837
- // Remove from messages.jsonl
838
- if (fs.existsSync(messagesFile)) {
839
- const lines = fs.readFileSync(messagesFile, 'utf8').trim().split('\n');
840
- const filtered = lines.filter(line => {
841
- try { return JSON.parse(line).id !== id; } catch { return true; }
842
- });
843
- fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
844
- }
875
+ // Remove from messages.jsonl (locked independently)
876
+ await withFileLock(messagesFile, () => {
877
+ if (fs.existsSync(messagesFile)) {
878
+ const lines = fs.readFileSync(messagesFile, 'utf8').trim().split('\n');
879
+ const filtered = lines.filter(line => {
880
+ try { return JSON.parse(line).id !== id; } catch { return true; }
881
+ });
882
+ fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
883
+ }
884
+ });
845
885
 
846
886
  return { success: true, id };
847
887
  }
@@ -989,13 +1029,15 @@ const server = http.createServer(async (req, res) => {
989
1029
 
990
1030
  const allowedOrigin = `http://localhost:${PORT}`;
991
1031
  const reqOrigin = req.headers.origin;
992
- if (LAN_MODE && reqOrigin) {
993
- res.setHeader('Access-Control-Allow-Origin', '*');
994
- } 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)) {
995
1037
  res.setHeader('Access-Control-Allow-Origin', reqOrigin);
996
1038
  }
997
1039
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
998
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1040
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-LTT-Request, X-LTT-Token');
999
1041
 
1000
1042
  if (req.method === 'OPTIONS') {
1001
1043
  res.writeHead(204);
@@ -1003,7 +1045,23 @@ const server = http.createServer(async (req, res) => {
1003
1045
  return;
1004
1046
  }
1005
1047
 
1006
- // 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
1007
1065
  if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
1008
1066
  // Check Host header to block DNS rebinding attacks
1009
1067
  const host = (req.headers.host || '').replace(/:\d+$/, '');
@@ -1014,13 +1072,26 @@ const server = http.createServer(async (req, res) => {
1014
1072
  res.end(JSON.stringify({ error: 'Forbidden: invalid host' }));
1015
1073
  return;
1016
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
+ }
1017
1082
  // Check Origin header to block cross-site requests
1083
+ // Empty origin is NOT trusted — requires at least the custom header (checked above)
1018
1084
  const origin = req.headers.origin || '';
1019
1085
  const referer = req.headers.referer || '';
1020
1086
  const source = origin || referer;
1021
- const isLocal = !source || source.includes('localhost:' + PORT) || source.includes('127.0.0.1:' + PORT);
1022
- const isLan = LAN_MODE && getLanIP() && source.includes(getLanIP() + ':' + PORT);
1023
- 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) {
1024
1095
  res.writeHead(403, { 'Content-Type': 'application/json' });
1025
1096
  res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
1026
1097
  return;
@@ -1054,6 +1125,7 @@ const server = http.createServer(async (req, res) => {
1054
1125
  const html = fs.readFileSync(HTML_FILE, 'utf8');
1055
1126
  res.writeHead(200, {
1056
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'",
1057
1129
  'Cache-Control': 'no-cache, no-store, must-revalidate',
1058
1130
  'Pragma': 'no-cache',
1059
1131
  'Expires': '0'
@@ -1121,7 +1193,7 @@ const server = http.createServer(async (req, res) => {
1121
1193
  });
1122
1194
  res.end(html);
1123
1195
  }
1124
- else if (url.pathname === '/api/discover' && req.method === 'GET') {
1196
+ else if (url.pathname === '/api/discover' && req.method === 'POST') {
1125
1197
  res.writeHead(200, { 'Content-Type': 'application/json' });
1126
1198
  res.end(JSON.stringify(apiDiscover()));
1127
1199
  }
@@ -1153,7 +1225,7 @@ const server = http.createServer(async (req, res) => {
1153
1225
  else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
1154
1226
  const projectPath = url.searchParams.get('project') || null;
1155
1227
  const agentParam = url.searchParams.get('agent');
1156
- if (agentParam && !/^[a-zA-Z0-9]{1,20}$/.test(agentParam)) {
1228
+ if (agentParam && !/^[a-zA-Z0-9_-]{1,20}$/.test(agentParam)) {
1157
1229
  res.writeHead(400, { 'Content-Type': 'application/json' });
1158
1230
  res.end(JSON.stringify({ error: 'Invalid agent name' }));
1159
1231
  return;
@@ -1258,14 +1330,14 @@ const server = http.createServer(async (req, res) => {
1258
1330
  // --- v3.4: Message Edit ---
1259
1331
  else if (url.pathname === '/api/message' && req.method === 'PUT') {
1260
1332
  const body = await parseBody(req);
1261
- const result = apiEditMessage(body, url.searchParams);
1333
+ const result = await apiEditMessage(body, url.searchParams);
1262
1334
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1263
1335
  res.end(JSON.stringify(result));
1264
1336
  }
1265
1337
  // --- v3.4: Message Delete ---
1266
1338
  else if (url.pathname === '/api/message' && req.method === 'DELETE') {
1267
1339
  const body = await parseBody(req);
1268
- const result = apiDeleteMessage(body, url.searchParams);
1340
+ const result = await apiDeleteMessage(body, url.searchParams);
1269
1341
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1270
1342
  res.end(JSON.stringify(result));
1271
1343
  }
@@ -1292,10 +1364,16 @@ const server = http.createServer(async (req, res) => {
1292
1364
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1293
1365
  res.end(JSON.stringify(result));
1294
1366
  }
1367
+ // --- v3.4: Read Receipts ---
1368
+ else if (url.pathname === '/api/read-receipts' && req.method === 'GET') {
1369
+ const projectPath = url.searchParams.get('project') || null;
1370
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1371
+ res.end(JSON.stringify(readJson(filePath('read_receipts.json', projectPath))));
1372
+ }
1295
1373
  // Server info (LAN mode detection for frontend)
1296
1374
  else if (url.pathname === '/api/server-info' && req.method === 'GET') {
1297
1375
  res.writeHead(200, { 'Content-Type': 'application/json' });
1298
- 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 }));
1299
1377
  }
1300
1378
  // Toggle LAN mode (re-bind server live)
1301
1379
  else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
@@ -1303,9 +1381,11 @@ const server = http.createServer(async (req, res) => {
1303
1381
  const lanIP = getLanIP();
1304
1382
  LAN_MODE = newMode;
1305
1383
  persistLanMode();
1384
+ // Regenerate token when enabling LAN mode
1385
+ if (newMode) generateLanToken();
1306
1386
  // Send response first
1307
1387
  res.writeHead(200, { 'Content-Type': 'application/json' });
1308
- 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 }));
1309
1389
  // Re-bind by stopping the listener and immediately re-listening
1310
1390
  // Use setImmediate to let the response flush first
1311
1391
  setImmediate(() => {
@@ -1367,8 +1447,9 @@ const server = http.createServer(async (req, res) => {
1367
1447
  res.end(JSON.stringify({ error: 'Not found' }));
1368
1448
  }
1369
1449
  } catch (err) {
1450
+ console.error('Server error:', err.message);
1370
1451
  res.writeHead(500, { 'Content-Type': 'application/json' });
1371
- res.end(JSON.stringify({ error: err.message }));
1452
+ res.end(JSON.stringify({ error: 'Internal server error' }));
1372
1453
  }
1373
1454
  });
1374
1455
 
@@ -1420,7 +1501,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
1420
1501
  const dataDir = resolveDataDir();
1421
1502
  const lanIP = getLanIP();
1422
1503
  console.log('');
1423
- console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.0');
1504
+ console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.2');
1424
1505
  console.log(' ============================================');
1425
1506
  console.log(' Dashboard: http://localhost:' + PORT);
1426
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.0",
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
@@ -273,14 +273,61 @@ function autoCompact() {
273
273
  } catch {}
274
274
  }
275
275
 
276
+ // --- Permissions helpers ---
277
+ const PERMISSIONS_FILE = path.join(DATA_DIR, 'permissions.json');
278
+
279
+ function getPermissions() {
280
+ if (!fs.existsSync(PERMISSIONS_FILE)) return {};
281
+ try { return JSON.parse(fs.readFileSync(PERMISSIONS_FILE, 'utf8')); } catch { return {}; }
282
+ }
283
+
284
+ function canSendTo(sender, recipient) {
285
+ const perms = getPermissions();
286
+ // If no permissions set, allow everything (backward compatible)
287
+ if (!perms[sender] && !perms[recipient]) return true;
288
+ // Check sender's write permissions
289
+ if (perms[sender] && perms[sender].can_write_to) {
290
+ const allowed = perms[sender].can_write_to;
291
+ if (allowed !== '*' && Array.isArray(allowed) && !allowed.includes(recipient)) return false;
292
+ }
293
+ // Check recipient's read permissions
294
+ if (perms[recipient] && perms[recipient].can_read) {
295
+ const allowed = perms[recipient].can_read;
296
+ if (allowed !== '*' && Array.isArray(allowed) && !allowed.includes(sender)) return false;
297
+ }
298
+ return true;
299
+ }
300
+
301
+ // --- Read receipts helpers ---
302
+ const READ_RECEIPTS_FILE = path.join(DATA_DIR, 'read_receipts.json');
303
+
304
+ function getReadReceipts() {
305
+ if (!fs.existsSync(READ_RECEIPTS_FILE)) return {};
306
+ try { return JSON.parse(fs.readFileSync(READ_RECEIPTS_FILE, 'utf8')); } catch { return {}; }
307
+ }
308
+
309
+ function markAsRead(agentName, messageId) {
310
+ ensureDataDir();
311
+ const receipts = getReadReceipts();
312
+ if (!receipts[messageId]) receipts[messageId] = {};
313
+ receipts[messageId][agentName] = new Date().toISOString();
314
+ fs.writeFileSync(READ_RECEIPTS_FILE, JSON.stringify(receipts, null, 2));
315
+ }
316
+
276
317
  // Get unconsumed messages for an agent (full scan — used by check_messages and initial load)
277
318
  function getUnconsumedMessages(agentName, fromFilter = null) {
278
319
  const messages = readJsonl(getMessagesFile(currentBranch));
279
320
  const consumed = getConsumedIds(agentName);
321
+ const perms = getPermissions();
280
322
  return messages.filter(m => {
281
323
  if (m.to !== agentName) return false;
282
324
  if (consumed.has(m.id)) return false;
283
325
  if (fromFilter && m.from !== fromFilter && !m.system) return false;
326
+ // Permission check: skip messages from senders this agent can't read
327
+ if (perms[agentName] && perms[agentName].can_read) {
328
+ const allowed = perms[agentName].can_read;
329
+ if (allowed !== '*' && Array.isArray(allowed) && !allowed.includes(m.from) && !m.system) return false;
330
+ }
284
331
  return true;
285
332
  });
286
333
  }
@@ -520,6 +567,11 @@ function toolSendMessage(content, to = null, reply_to = null) {
520
567
  return { error: 'Cannot send a message to yourself' };
521
568
  }
522
569
 
570
+ // Permission check
571
+ if (!canSendTo(registeredName, to)) {
572
+ return { error: `Permission denied: you are not allowed to send messages to "${to}"` };
573
+ }
574
+
523
575
  const sizeErr = validateContentSize(content);
524
576
  if (sizeErr) return sizeErr;
525
577
 
@@ -583,7 +635,9 @@ function toolBroadcast(content) {
583
635
 
584
636
  ensureDataDir();
585
637
  const ids = [];
638
+ const skipped = [];
586
639
  for (const to of otherAgents) {
640
+ if (!canSendTo(registeredName, to)) { skipped.push(to); continue; }
587
641
  messageSeq++;
588
642
  const msg = {
589
643
  id: generateId(),
@@ -600,7 +654,9 @@ function toolBroadcast(content) {
600
654
  }
601
655
  touchActivity();
602
656
 
603
- return { success: true, sent_to: ids, recipient_count: otherAgents.length };
657
+ const result = { success: true, sent_to: ids, recipient_count: ids.length };
658
+ if (skipped.length > 0) result.skipped = skipped;
659
+ return result;
604
660
  }
605
661
 
606
662
  async function toolWaitForReply(timeoutSeconds = 300, from = null) {
@@ -618,6 +674,7 @@ async function toolWaitForReply(timeoutSeconds = 300, from = null) {
618
674
  const consumed = getConsumedIds(registeredName);
619
675
  consumed.add(msg.id);
620
676
  saveConsumedIds(registeredName, consumed);
677
+ markAsRead(registeredName, msg.id);
621
678
  const _mf1 = getMessagesFile(currentBranch);
622
679
  if (fs.existsSync(_mf1)) {
623
680
  lastReadOffset = fs.statSync(_mf1).size;
@@ -647,6 +704,7 @@ async function toolWaitForReply(timeoutSeconds = 300, from = null) {
647
704
 
648
705
  consumed.add(msg.id);
649
706
  saveConsumedIds(registeredName, consumed);
707
+ markAsRead(registeredName, msg.id);
650
708
  touchActivity();
651
709
  setListening(false);
652
710
  return buildMessageResponse(msg, consumed);
@@ -718,6 +776,7 @@ async function toolListen(from = null) {
718
776
  const consumed = getConsumedIds(registeredName);
719
777
  consumed.add(msg.id);
720
778
  saveConsumedIds(registeredName, consumed);
779
+ markAsRead(registeredName, msg.id);
721
780
  const _mfL1 = getMessagesFile(currentBranch);
722
781
  if (fs.existsSync(_mfL1)) {
723
782
  lastReadOffset = fs.statSync(_mfL1).size;
@@ -750,6 +809,7 @@ async function toolListen(from = null) {
750
809
 
751
810
  consumed.add(msg.id);
752
811
  saveConsumedIds(registeredName, consumed);
812
+ markAsRead(registeredName, msg.id);
753
813
  touchActivity();
754
814
  setListening(false);
755
815
  return buildMessageResponse(msg, consumed);
@@ -776,6 +836,7 @@ async function toolListenCodex(from = null) {
776
836
  const consumed = getConsumedIds(registeredName);
777
837
  consumed.add(msg.id);
778
838
  saveConsumedIds(registeredName, consumed);
839
+ markAsRead(registeredName, msg.id);
779
840
  const _mfC1 = getMessagesFile(currentBranch);
780
841
  if (fs.existsSync(_mfC1)) {
781
842
  lastReadOffset = fs.statSync(_mfC1).size;
@@ -804,6 +865,7 @@ async function toolListenCodex(from = null) {
804
865
 
805
866
  consumed.add(msg.id);
806
867
  saveConsumedIds(registeredName, consumed);
868
+ markAsRead(registeredName, msg.id);
807
869
  touchActivity();
808
870
  setListening(false);
809
871
  return buildMessageResponse(msg, consumed);
@@ -824,6 +886,16 @@ function toolGetHistory(limit = 50, thread_id = null) {
824
886
  if (thread_id) {
825
887
  history = history.filter(m => m.thread_id === thread_id || m.id === thread_id);
826
888
  }
889
+ // Filter by permissions — only show messages involving this agent or permitted senders
890
+ if (registeredName) {
891
+ const perms = getPermissions();
892
+ if (perms[registeredName] && perms[registeredName].can_read) {
893
+ const allowed = perms[registeredName].can_read;
894
+ if (allowed !== '*' && Array.isArray(allowed)) {
895
+ history = history.filter(m => m.from === registeredName || m.to === registeredName || allowed.includes(m.from));
896
+ }
897
+ }
898
+ }
827
899
  const recent = history.slice(-limit);
828
900
  const acks = getAcks();
829
901
 
@@ -1109,8 +1181,8 @@ function toolReset() {
1109
1181
  }
1110
1182
  }
1111
1183
  }
1112
- // Remove profiles, workflows, branches, plugins
1113
- for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PLUGINS_FILE]) {
1184
+ // Remove profiles, workflows, branches, plugins, permissions, read receipts
1185
+ for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PLUGINS_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE]) {
1114
1186
  if (fs.existsSync(f)) fs.unlinkSync(f);
1115
1187
  }
1116
1188
  // Remove workspaces dir
@@ -1186,6 +1258,9 @@ function toolWorkspaceWrite(key, content) {
1186
1258
  function toolWorkspaceRead(key, agent) {
1187
1259
  if (!registeredName) return { error: 'You must call register() first' };
1188
1260
  const targetAgent = agent || registeredName;
1261
+ if (targetAgent !== registeredName && !/^[a-zA-Z0-9_-]{1,20}$/.test(targetAgent)) {
1262
+ return { error: 'Invalid agent name' };
1263
+ }
1189
1264
 
1190
1265
  const ws = getWorkspace(targetAgent);
1191
1266
  if (key) {
@@ -1203,6 +1278,7 @@ function toolWorkspaceRead(key, agent) {
1203
1278
  function toolWorkspaceList(agent) {
1204
1279
  const agents = getAgents();
1205
1280
  if (agent) {
1281
+ if (!/^[a-zA-Z0-9_-]{1,20}$/.test(agent)) return { error: 'Invalid agent name' };
1206
1282
  const ws = getWorkspace(agent);
1207
1283
  return { agent, keys: Object.keys(ws).map(k => ({ key: k, size: ws[k].content.length, updated_at: ws[k].updated_at })) };
1208
1284
  }
@@ -2021,7 +2097,7 @@ async function main() {
2021
2097
  loadPlugins();
2022
2098
  const transport = new StdioServerTransport();
2023
2099
  await server.connect(transport);
2024
- console.error('Agent Bridge MCP server v3.4.0 running (' + (27 + loadedPlugins.length) + ' tools)');
2100
+ console.error('Agent Bridge MCP server v3.4.2 running (' + (27 + loadedPlugins.length) + ' tools)');
2025
2101
  }
2026
2102
 
2027
2103
  main().catch(console.error);