let-them-talk 3.4.1 → 3.4.3

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.4.3] - 2026-03-15
4
+
5
+ ### Removed — Plugin System
6
+ - Removed the entire plugin system (`vm.runInNewContext` sandbox, plugin CLI commands, dashboard plugin UI)
7
+ - **Why:** Plugins were an unnecessary attack surface. Node.js `vm` is not a security sandbox — plugins could escape and execute arbitrary OS commands. CLI terminals (Claude Code, Gemini, Codex) have their own extension systems, making our plugins redundant.
8
+ - `npx let-them-talk plugin` now shows a deprecation notice
9
+ - MCP tools reduced from 27 + plugins to 27 (all core tools remain)
10
+ - ~200 lines of code removed from server.js, cli.js, dashboard.js, dashboard.html
11
+
12
+ ## [3.4.2] - 2026-03-15
13
+
14
+ ### Security — CSRF Protection
15
+ - Required `X-LTT-Request` custom header on all POST/PUT/DELETE requests
16
+ - `lttFetch` wrapper in dashboard automatically includes the header
17
+ - Malicious cross-origin pages cannot set custom headers without CORS preflight approval
18
+ - Removed wildcard `Access-Control-Allow-Origin: *` in LAN mode — now uses explicit trusted origins only
19
+ - Empty Origin/Referer no longer auto-trusted — requires custom header as minimum protection
20
+
21
+ ### Security — LAN Auth Token
22
+ - Auto-generated 32-char hex token when LAN mode is enabled
23
+ - Token required for all non-localhost requests (via `?token=` query param or `X-LTT-Token` header)
24
+ - Token included in QR code URL — phone scans and it just works
25
+ - Token displayed in phone access modal with explanation
26
+ - New token generated each time LAN mode is toggled on
27
+ - Token persists across server restarts via `.lan-token` file
28
+ - Localhost access never requires a token
29
+
30
+ ### Security — Content Security Policy
31
+ - CSP header added to dashboard HTML response
32
+ - `script-src 'unsafe-inline'` for inline handlers, blocks `eval()` and external scripts
33
+ - `connect-src 'self'` restricts API calls to same origin
34
+ - `font-src`, `style-src`, `img-src` scoped to required sources only
35
+
36
+ ### Fixed
37
+ - CSRF brace imbalance that trapped GET handlers inside POST-only block
38
+ - LAN token not forwarded from phone URL to API calls and SSE
39
+ - Redundant nested origin check collapsed to single condition
40
+
3
41
  ## [3.4.1] - 2026-03-15
4
42
 
5
43
  ### 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.3
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.3
12
12
  MCP message broker for inter-agent communication
13
13
  Supports: Claude Code, Gemini CLI, Codex CLI
14
14
 
@@ -23,11 +23,6 @@ function printUsage() {
23
23
  npx let-them-talk dashboard Launch the web dashboard (http://localhost:3000)
24
24
  npx let-them-talk dashboard --lan Launch dashboard accessible on LAN (phone/tablet)
25
25
  npx let-them-talk reset Clear all conversation data
26
- npx let-them-talk plugin list List installed plugins
27
- npx let-them-talk plugin add <file> Install a plugin from a .js file
28
- npx let-them-talk plugin remove <n> Remove a plugin by name
29
- npx let-them-talk plugin enable <n> Enable a plugin
30
- npx let-them-talk plugin disable <n> Disable a plugin
31
26
  npx let-them-talk msg <agent> <text> Send a message to an agent
32
27
  npx let-them-talk status Show active agents and message count
33
28
  npx let-them-talk help Show this help message
@@ -293,124 +288,6 @@ function showTemplate(templateName) {
293
288
  }
294
289
  }
295
290
 
296
- function pluginCmd() {
297
- const subCmd = process.argv[3];
298
- const dataDir = process.env.AGENT_BRIDGE_DATA_DIR || path.join(process.cwd(), '.agent-bridge');
299
- const pluginsDir = path.join(dataDir, 'plugins');
300
- const pluginsFile = path.join(dataDir, 'plugins.json');
301
-
302
- function getRegistry() {
303
- if (!fs.existsSync(pluginsFile)) return [];
304
- try { return JSON.parse(fs.readFileSync(pluginsFile, 'utf8')); } catch { return []; }
305
- }
306
-
307
- function saveRegistry(reg) {
308
- if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
309
- fs.writeFileSync(pluginsFile, JSON.stringify(reg, null, 2));
310
- }
311
-
312
- switch (subCmd) {
313
- case 'list': {
314
- const plugins = getRegistry();
315
- if (!plugins.length) {
316
- console.log(' No plugins installed.');
317
- console.log(' Install with: npx let-them-talk plugin add <file.js>');
318
- return;
319
- }
320
- console.log('');
321
- console.log(' Installed Plugins');
322
- console.log(' =================');
323
- for (const p of plugins) {
324
- const status = p.enabled !== false ? 'enabled' : 'disabled';
325
- console.log(' ' + p.name.padEnd(20) + ' ' + status.padEnd(10) + ' ' + (p.description || ''));
326
- }
327
- console.log('');
328
- break;
329
- }
330
- case 'add': {
331
- const filePath = process.argv[4];
332
- if (!filePath) { console.error(' Usage: npx let-them-talk plugin add <file.js>'); process.exit(1); }
333
- const absPath = path.resolve(filePath);
334
- if (!fs.existsSync(absPath)) { console.error(' File not found: ' + absPath); process.exit(1); }
335
-
336
- // Validate plugin structure without executing it (no require — prevents RCE on install)
337
- try {
338
- const src = fs.readFileSync(absPath, 'utf8');
339
- if (!src.includes('module.exports') || !src.includes('name') || !src.includes('handler')) {
340
- console.error(' Plugin must export name, description, and handler (module.exports = { name, handler })');
341
- process.exit(1);
342
- }
343
-
344
- // Extract plugin name from source using regex (no eval)
345
- const nameMatch = src.match(/name\s*:\s*['"]([^'"]+)['"]/);
346
- const descMatch = src.match(/description\s*:\s*['"]([^'"]+)['"]/);
347
- const pluginName = nameMatch ? nameMatch[1] : path.basename(absPath, '.js');
348
- const pluginDesc = descMatch ? descMatch[1] : '';
349
-
350
- if (!fs.existsSync(pluginsDir)) fs.mkdirSync(pluginsDir, { recursive: true });
351
- const destFile = path.join(pluginsDir, path.basename(absPath));
352
- fs.copyFileSync(absPath, destFile);
353
-
354
- const reg = getRegistry();
355
- if (!reg.find(p => p.name === pluginName)) {
356
- reg.push({ name: pluginName, description: pluginDesc, file: path.basename(absPath), enabled: true, added_at: new Date().toISOString() });
357
- saveRegistry(reg);
358
- }
359
- console.log(' Plugin "' + pluginName + '" installed successfully.');
360
- console.log(' Restart CLI to load the new tool (runs sandboxed).');
361
- } catch (e) {
362
- console.error(' Failed to install plugin: ' + e.message);
363
- process.exit(1);
364
- }
365
- break;
366
- }
367
- case 'remove': {
368
- const name = process.argv[4];
369
- if (!name) { console.error(' Usage: npx let-them-talk plugin remove <name>'); process.exit(1); }
370
- const reg = getRegistry();
371
- const plugin = reg.find(p => p.name === name);
372
- if (!plugin) { console.error(' Plugin not found: ' + name); process.exit(1); }
373
- const newReg = reg.filter(p => p.name !== name);
374
- saveRegistry(newReg);
375
- if (plugin.file) {
376
- const pluginFile = path.resolve(pluginsDir, plugin.file);
377
- // Prevent path traversal — only delete files inside pluginsDir
378
- if (pluginFile.startsWith(path.resolve(pluginsDir) + path.sep) && fs.existsSync(pluginFile)) {
379
- fs.unlinkSync(pluginFile);
380
- }
381
- }
382
- console.log(' Plugin "' + name + '" removed.');
383
- break;
384
- }
385
- case 'enable': {
386
- const name = process.argv[4];
387
- if (!name) { console.error(' Usage: npx let-them-talk plugin enable <name>'); process.exit(1); }
388
- const reg = getRegistry();
389
- const plugin = reg.find(p => p.name === name);
390
- if (!plugin) { console.error(' Plugin not found: ' + name); process.exit(1); }
391
- plugin.enabled = true;
392
- saveRegistry(reg);
393
- console.log(' Plugin "' + name + '" enabled.');
394
- break;
395
- }
396
- case 'disable': {
397
- const name = process.argv[4];
398
- if (!name) { console.error(' Usage: npx let-them-talk plugin disable <name>'); process.exit(1); }
399
- const reg = getRegistry();
400
- const plugin = reg.find(p => p.name === name);
401
- if (!plugin) { console.error(' Plugin not found: ' + name); process.exit(1); }
402
- plugin.enabled = false;
403
- saveRegistry(reg);
404
- console.log(' Plugin "' + name + '" disabled.');
405
- break;
406
- }
407
- default:
408
- console.error(' Unknown plugin command: ' + (subCmd || ''));
409
- console.error(' Available: list, add, remove, enable, disable');
410
- process.exit(1);
411
- }
412
- }
413
-
414
291
  function dashboard() {
415
292
  if (process.argv.includes('--lan')) {
416
293
  process.env.AGENT_BRIDGE_LAN = 'true';
@@ -532,7 +409,7 @@ switch (command) {
532
409
  break;
533
410
  case 'plugin':
534
411
  case 'plugins':
535
- pluginCmd();
412
+ console.log(' Plugins have been removed in v3.4.3. CLI terminals have their own extension systems.');
536
413
  break;
537
414
  case 'help':
538
415
  case '--help':
package/dashboard.html CHANGED
@@ -2572,65 +2572,7 @@
2572
2572
  }
2573
2573
 
2574
2574
  /* ===== v3.0: PLUGINS SECTION ===== */
2575
- .plugin-card {
2576
- background: var(--surface-2);
2577
- border: 1px solid var(--border);
2578
- border-radius: 6px;
2579
- padding: 8px 10px;
2580
- margin-bottom: 4px;
2581
- display: flex;
2582
- align-items: center;
2583
- justify-content: space-between;
2584
- }
2585
-
2586
- .plugin-info {
2587
- flex: 1;
2588
- min-width: 0;
2589
- }
2590
-
2591
- .plugin-name {
2592
- font-size: 12px;
2593
- font-weight: 600;
2594
- }
2595
-
2596
- .plugin-desc {
2597
- font-size: 10px;
2598
- color: var(--text-muted);
2599
- }
2600
-
2601
- .plugin-toggle {
2602
- background: var(--surface-3);
2603
- border: 1px solid var(--border);
2604
- border-radius: 10px;
2605
- width: 36px;
2606
- height: 20px;
2607
- cursor: pointer;
2608
- position: relative;
2609
- transition: all 0.2s;
2610
- flex-shrink: 0;
2611
- }
2612
-
2613
- .plugin-toggle.on {
2614
- background: var(--green-dim);
2615
- border-color: var(--green);
2616
- }
2617
-
2618
- .plugin-toggle::after {
2619
- content: '';
2620
- position: absolute;
2621
- width: 14px;
2622
- height: 14px;
2623
- border-radius: 50%;
2624
- background: var(--text-dim);
2625
- top: 2px;
2626
- left: 2px;
2627
- transition: all 0.2s;
2628
- }
2629
-
2630
- .plugin-toggle.on::after {
2631
- left: 18px;
2632
- background: var(--green);
2633
- }
2575
+ /* Plugins removed in v3.4.3 */
2634
2576
 
2635
2577
  /* ===== v3.0: AVATAR PICKER ===== */
2636
2578
  .avatar-option {
@@ -2758,12 +2700,6 @@
2758
2700
  <div id="activity-heatmap"></div>
2759
2701
  </div>
2760
2702
 
2761
- <!-- Plugins Section -->
2762
- <div class="sidebar-section">
2763
- <div class="sidebar-title"><span>Plugins</span></div>
2764
- <div id="plugins-list"></div>
2765
- </div>
2766
-
2767
2703
  <!-- Bookmarks Section -->
2768
2704
  <div class="sidebar-section">
2769
2705
  <div class="sidebar-title">
@@ -2842,7 +2778,7 @@
2842
2778
  </div>
2843
2779
  </div>
2844
2780
  <div class="app-footer">
2845
- <span>Let Them Talk v3.4.1</span>
2781
+ <span>Let Them Talk v3.4.3</span>
2846
2782
  </div>
2847
2783
  <div class="profile-popup" id="profile-popup" onclick="event.stopPropagation()">
2848
2784
  <div class="profile-popup-header">
@@ -2897,6 +2833,26 @@ var POLL_INTERVAL = 2000;
2897
2833
  var SLEEP_THRESHOLD = 60; // seconds
2898
2834
  var lastMessageCount = 0;
2899
2835
  var autoScroll = true;
2836
+ // CSRF protection — all mutating requests must include this header
2837
+ // Read LAN token from URL on page load (phone access via QR code)
2838
+ var _lttToken = (function() {
2839
+ 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 ''; }
2840
+ })();
2841
+
2842
+ function lttFetch(url, opts) {
2843
+ opts = opts || {};
2844
+ // Append LAN token to URL if present (needed for phone/LAN access)
2845
+ if (_lttToken) {
2846
+ var sep = url.indexOf('?') >= 0 ? '&' : '?';
2847
+ url = url + sep + 'token=' + encodeURIComponent(_lttToken);
2848
+ }
2849
+ if (opts.method && opts.method !== 'GET') {
2850
+ if (!opts.headers) opts.headers = {};
2851
+ opts.headers['X-LTT-Request'] = '1';
2852
+ }
2853
+ return fetch(url, opts);
2854
+ }
2855
+
2900
2856
  var activeThread = null;
2901
2857
  var activeProject = ''; // empty = default/local
2902
2858
  var cachedHistory = [];
@@ -3227,7 +3183,7 @@ function renderAgents(agents) {
3227
3183
 
3228
3184
  function sendNudge(agentName) {
3229
3185
  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(), {
3186
+ lttFetch('/api/inject' + projectParam(), {
3231
3187
  method: 'POST',
3232
3188
  headers: { 'Content-Type': 'application/json' },
3233
3189
  body: body
@@ -3285,7 +3241,7 @@ function doInject() {
3285
3241
  if (!target || !content) return;
3286
3242
 
3287
3243
  var body = JSON.stringify({ to: target, content: content });
3288
- fetch('/api/inject' + projectParam(), {
3244
+ lttFetch('/api/inject' + projectParam(), {
3289
3245
  method: 'POST',
3290
3246
  headers: { 'Content-Type': 'application/json' },
3291
3247
  body: body
@@ -3612,7 +3568,7 @@ var cachedReadReceipts = {};
3612
3568
 
3613
3569
  function fetchReadReceipts() {
3614
3570
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3615
- fetch('/api/read-receipts' + pq).then(function(r) { return r.json(); }).then(function(data) {
3571
+ lttFetch('/api/read-receipts' + pq).then(function(r) { return r.json(); }).then(function(data) {
3616
3572
  cachedReadReceipts = data || {};
3617
3573
  }).catch(function() {});
3618
3574
  }
@@ -3752,7 +3708,7 @@ function deleteMessage(msgId) {
3752
3708
  var preview = msg.content.substring(0, 60) + (msg.content.length > 60 ? '...' : '');
3753
3709
  if (!confirm('Delete this message from ' + msg.from + '?\n\n"' + preview + '"')) return;
3754
3710
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3755
- fetch('/api/message' + pq, {
3711
+ lttFetch('/api/message' + pq, {
3756
3712
  method: 'DELETE',
3757
3713
  headers: { 'Content-Type': 'application/json' },
3758
3714
  body: JSON.stringify({ id: msgId })
@@ -3802,7 +3758,7 @@ function saveEditMessage() {
3802
3758
  var content = document.getElementById('edit-msg-content').value.trim();
3803
3759
  if (!content) return;
3804
3760
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
3805
- fetch('/api/message' + pq, {
3761
+ lttFetch('/api/message' + pq, {
3806
3762
  method: 'PUT',
3807
3763
  headers: { 'Content-Type': 'application/json' },
3808
3764
  body: JSON.stringify({ id: msgId, content: content })
@@ -3994,7 +3950,7 @@ function renderAgentStats() {
3994
3950
 
3995
3951
  function fetchActivity() {
3996
3952
  var pq = projectParam();
3997
- fetch('/api/timeline' + pq).then(function(r) { return r.json(); }).then(function(data) {
3953
+ lttFetch('/api/timeline' + pq).then(function(r) { return r.json(); }).then(function(data) {
3998
3954
  renderActivityHeatmap(data);
3999
3955
  }).catch(function() {});
4000
3956
  }
@@ -4174,7 +4130,7 @@ var cachedTasks = [];
4174
4130
 
4175
4131
  function fetchTasks() {
4176
4132
  var pq = projectParam();
4177
- fetch('/api/tasks' + pq).then(function(r) { return r.json(); }).then(function(tasks) {
4133
+ lttFetch('/api/tasks' + pq).then(function(r) { return r.json(); }).then(function(tasks) {
4178
4134
  cachedTasks = Array.isArray(tasks) ? tasks : [];
4179
4135
  renderTasks();
4180
4136
  }).catch(function() {
@@ -4265,7 +4221,7 @@ function buildTaskCard(t) {
4265
4221
  }
4266
4222
 
4267
4223
  function updateTaskStatus(taskId, newStatus) {
4268
- fetch('/api/tasks' + projectParam(), {
4224
+ lttFetch('/api/tasks' + projectParam(), {
4269
4225
  method: 'POST',
4270
4226
  headers: { 'Content-Type': 'application/json' },
4271
4227
  body: JSON.stringify({ task_id: taskId, status: newStatus })
@@ -4322,7 +4278,7 @@ var AGENT_COLORS = ['#58a6ff', '#f78166', '#7ee787', '#d2a8ff', '#ffa657', '#ff7
4322
4278
 
4323
4279
  function fetchStats() {
4324
4280
  var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
4325
- fetch('/api/stats' + pq).then(function(r) { return r.json(); }).then(function(data) {
4281
+ lttFetch('/api/stats' + pq).then(function(r) { return r.json(); }).then(function(data) {
4326
4282
  renderStats(data);
4327
4283
  }).catch(function(e) { console.error('Stats fetch failed:', e); });
4328
4284
  }
@@ -4571,7 +4527,7 @@ function saveProfile() {
4571
4527
  };
4572
4528
  if (avatar) body.avatar = avatar;
4573
4529
 
4574
- fetch('/api/profiles' + projectParam(), {
4530
+ lttFetch('/api/profiles' + projectParam(), {
4575
4531
  method: 'POST',
4576
4532
  headers: { 'Content-Type': 'application/json' },
4577
4533
  body: JSON.stringify(body)
@@ -4587,7 +4543,7 @@ function saveProfile() {
4587
4543
 
4588
4544
  function fetchWorkspaces() {
4589
4545
  var pq = projectParam();
4590
- fetch('/api/workspaces' + pq).then(function(r) { return r.json(); }).then(function(data) {
4546
+ lttFetch('/api/workspaces' + pq).then(function(r) { return r.json(); }).then(function(data) {
4591
4547
  renderWorkspaces(data);
4592
4548
  }).catch(function() {});
4593
4549
  }
@@ -4634,7 +4590,7 @@ function renderWorkspaces(data) {
4634
4590
 
4635
4591
  function fetchWorkflows() {
4636
4592
  var pq = projectParam();
4637
- fetch('/api/workflows' + pq).then(function(r) { return r.json(); }).then(function(data) {
4593
+ lttFetch('/api/workflows' + pq).then(function(r) { return r.json(); }).then(function(data) {
4638
4594
  renderWorkflows(Array.isArray(data) ? data : []);
4639
4595
  }).catch(function() {});
4640
4596
  }
@@ -4685,7 +4641,7 @@ function renderWorkflows(workflows) {
4685
4641
  }
4686
4642
 
4687
4643
  function dashAdvanceWorkflow(wfId) {
4688
- fetch('/api/workflows' + projectParam(), {
4644
+ lttFetch('/api/workflows' + projectParam(), {
4689
4645
  method: 'POST',
4690
4646
  headers: { 'Content-Type': 'application/json' },
4691
4647
  body: JSON.stringify({ action: 'advance', workflow_id: wfId })
@@ -4698,7 +4654,7 @@ var activeBranch = '';
4698
4654
 
4699
4655
  function fetchBranches() {
4700
4656
  var pq = projectParam();
4701
- fetch('/api/branches' + pq).then(function(r) { return r.json(); }).then(function(data) {
4657
+ lttFetch('/api/branches' + pq).then(function(r) { return r.json(); }).then(function(data) {
4702
4658
  renderBranchTabs(data);
4703
4659
  }).catch(function() {});
4704
4660
  }
@@ -4730,44 +4686,6 @@ function switchBranch(name) {
4730
4686
  poll();
4731
4687
  }
4732
4688
 
4733
- // ==================== v3.0: PLUGINS ====================
4734
-
4735
- function fetchPlugins() {
4736
- var pq = projectParam();
4737
- fetch('/api/plugins' + pq).then(function(r) { return r.json(); }).then(function(data) {
4738
- renderPlugins(Array.isArray(data) ? data : []);
4739
- }).catch(function() {});
4740
- }
4741
-
4742
- function renderPlugins(plugins) {
4743
- var el = document.getElementById('plugins-list');
4744
- if (!plugins.length) {
4745
- el.innerHTML = '<div style="color:var(--text-muted);font-size:12px;padding:4px;">No plugins installed</div>';
4746
- return;
4747
- }
4748
- var html = '';
4749
- for (var i = 0; i < plugins.length; i++) {
4750
- var p = plugins[i];
4751
- var onClass = p.enabled !== false ? ' on' : '';
4752
- html += '<div class="plugin-card">' +
4753
- '<div class="plugin-info">' +
4754
- '<div class="plugin-name">' + escapeHtml(p.name) + '</div>' +
4755
- '<div class="plugin-desc">' + escapeHtml(p.description || '') + '</div>' +
4756
- '</div>' +
4757
- '<div class="plugin-toggle' + onClass + '" onclick="togglePlugin(\'' + escapeHtml(p.name) + '\')"></div>' +
4758
- '</div>';
4759
- }
4760
- el.innerHTML = html;
4761
- }
4762
-
4763
- function togglePlugin(name) {
4764
- fetch('/api/plugins' + projectParam(), {
4765
- method: 'POST',
4766
- headers: { 'Content-Type': 'application/json' },
4767
- body: JSON.stringify({ action: 'toggle', name: name })
4768
- }).then(function() { fetchPlugins(); }).catch(function() {});
4769
- }
4770
-
4771
4689
  // ==================== POLLING ====================
4772
4690
 
4773
4691
  function poll() {
@@ -4776,9 +4694,9 @@ function poll() {
4776
4694
  var bp = activeBranch && activeBranch !== 'main' ? '&branch=' + encodeURIComponent(activeBranch) : '';
4777
4695
  var pollStart = Date.now();
4778
4696
  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(); }),
4697
+ lttFetch('/api/history?limit=500' + pp + bp).then(function(r) { return r.json(); }),
4698
+ lttFetch('/api/agents' + pq).then(function(r) { return r.json(); }),
4699
+ lttFetch('/api/status' + pq).then(function(r) { return r.json(); }),
4782
4700
  ]).then(function(results) {
4783
4701
  console.log('[LTT] poll ok — history:' + results[0].length + ' agents:' + Object.keys(results[1]).length + ' project:' + (activeProject || 'default'));
4784
4702
  updateConnectionInfo(Date.now() - pollStart);
@@ -4819,7 +4737,6 @@ function poll() {
4819
4737
  renderBookmarksSidebar();
4820
4738
  fetchActivity();
4821
4739
  fetchBranches();
4822
- fetchPlugins();
4823
4740
  updateTypingIndicator(cachedAgents);
4824
4741
  if (activeView === 'tasks') fetchTasks();
4825
4742
  if (activeView === 'workspaces') fetchWorkspaces();
@@ -4834,7 +4751,7 @@ function poll() {
4834
4751
 
4835
4752
  function doReset() {
4836
4753
  if (!confirm('Clear all messages, agents, and history?')) return;
4837
- fetch('/api/reset' + projectParam(), { method: 'POST' }).then(function() {
4754
+ lttFetch('/api/reset' + projectParam(), { method: 'POST' }).then(function() {
4838
4755
  lastMessageCount = 0;
4839
4756
  activeThread = null;
4840
4757
  cachedHistory = [];
@@ -4846,7 +4763,7 @@ function doReset() {
4846
4763
  // ==================== PROJECT MANAGEMENT ====================
4847
4764
 
4848
4765
  function loadProjects() {
4849
- return fetch('/api/projects').then(function(r) { return r.json(); }).then(function(projects) {
4766
+ return lttFetch('/api/projects').then(function(r) { return r.json(); }).then(function(projects) {
4850
4767
  console.log('[LTT] loadProjects:', projects.length, 'projects, activeProject:', activeProject);
4851
4768
  var sel = document.getElementById('project-select');
4852
4769
  // Keep the first option (Default)
@@ -4944,7 +4861,7 @@ function addProject() {
4944
4861
  var projectPath = input.value.trim();
4945
4862
  if (!projectPath) return;
4946
4863
 
4947
- fetch('/api/projects', {
4864
+ lttFetch('/api/projects', {
4948
4865
  method: 'POST',
4949
4866
  headers: { 'Content-Type': 'application/json' },
4950
4867
  body: JSON.stringify({ path: projectPath })
@@ -4970,7 +4887,7 @@ function discoverProjects() {
4970
4887
  resultsEl.style.display = 'block';
4971
4888
  resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">Scanning...</div>';
4972
4889
 
4973
- fetch('/api/discover', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(found) {
4890
+ lttFetch('/api/discover', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(found) {
4974
4891
  if (!found.length) {
4975
4892
  resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">No new projects found (all discovered projects already added)</div>';
4976
4893
  setTimeout(function() { resultsEl.style.display = 'none'; }, 3000);
@@ -4996,7 +4913,7 @@ function discoverProjects() {
4996
4913
  }
4997
4914
 
4998
4915
  function addDiscovered(projectPath, name) {
4999
- fetch('/api/projects', {
4916
+ lttFetch('/api/projects', {
5000
4917
  method: 'POST',
5001
4918
  headers: { 'Content-Type': 'application/json' },
5002
4919
  body: JSON.stringify({ path: projectPath, name: name })
@@ -5012,7 +4929,7 @@ function removeProject() {
5012
4929
  if (!activeProject) return;
5013
4930
  if (!confirm('Remove this project from the dashboard?')) return;
5014
4931
 
5015
- fetch('/api/projects', {
4932
+ lttFetch('/api/projects', {
5016
4933
  method: 'DELETE',
5017
4934
  headers: { 'Content-Type': 'application/json' },
5018
4935
  body: JSON.stringify({ path: activeProject })
@@ -5275,7 +5192,7 @@ updateNotifBtn();
5275
5192
  var lanState = { lan_mode: false, lan_ip: null, port: 3000 };
5276
5193
 
5277
5194
  function fetchLanState() {
5278
- return fetch('/api/server-info').then(function(r) { return r.json(); }).then(function(info) {
5195
+ return lttFetch('/api/server-info').then(function(r) { return r.json(); }).then(function(info) {
5279
5196
  lanState = info;
5280
5197
  updateLanUI();
5281
5198
  return info;
@@ -5325,7 +5242,7 @@ function toggleLanMode() {
5325
5242
  var content = document.getElementById('phone-modal-content');
5326
5243
  content.innerHTML = '<div class="phone-off-state"><p>Switching...</p></div>';
5327
5244
 
5328
- fetch('/api/toggle-lan', { method: 'POST' })
5245
+ lttFetch('/api/toggle-lan', { method: 'POST' })
5329
5246
  .then(function(r) { return r.json(); })
5330
5247
  .then(function(info) {
5331
5248
  lanState = info;
@@ -5353,10 +5270,20 @@ function renderPhoneModalContent() {
5353
5270
  }
5354
5271
 
5355
5272
  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);
5273
+ // Include auth token and active project so the phone shows the same view
5274
+ var params = [];
5275
+ if (lanState.lan_token) params.push('token=' + encodeURIComponent(lanState.lan_token));
5276
+ if (activeProject) params.push('project=' + encodeURIComponent(activeProject));
5277
+ if (params.length) url += '?' + params.join('&');
5358
5278
  var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&color=58a6ff&bgcolor=0d1117&data=' + encodeURIComponent(url);
5359
5279
 
5280
+ var tokenHtml = lanState.lan_token ?
5281
+ '<div style="margin-top:12px;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:10px">' +
5282
+ '<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px">Auth Token</div>' +
5283
+ '<div style="font-family:monospace;font-size:13px;color:var(--accent);word-break:break-all">' + escapeHtml(lanState.lan_token) + '</div>' +
5284
+ '<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>' +
5285
+ '</div>' : '';
5286
+
5360
5287
  el.innerHTML =
5361
5288
  '<div class="phone-url-box">' +
5362
5289
  '<div class="phone-url-text">' + escapeHtml(url) + '</div>' +
@@ -5365,12 +5292,16 @@ function renderPhoneModalContent() {
5365
5292
  '<div class="phone-qr">' +
5366
5293
  '<img src="' + qrUrl + '" alt="QR Code" onerror="this.parentElement.style.display=\'none\'">' +
5367
5294
  '</div>' +
5368
- '<div class="phone-qr-hint">Scan with your phone camera on the same WiFi</div>';
5295
+ '<div class="phone-qr-hint">Scan with your phone camera on the same WiFi</div>' +
5296
+ tokenHtml;
5369
5297
  }
5370
5298
 
5371
5299
  function copyPhoneUrl() {
5372
5300
  var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
5373
- if (activeProject) url += '?project=' + encodeURIComponent(activeProject);
5301
+ var params = [];
5302
+ if (lanState.lan_token) params.push('token=' + encodeURIComponent(lanState.lan_token));
5303
+ if (activeProject) params.push('project=' + encodeURIComponent(activeProject));
5304
+ if (params.length) url += '?' + params.join('&');
5374
5305
  navigator.clipboard.writeText(url).then(function() {
5375
5306
  var btn = document.querySelector('.phone-url-copy');
5376
5307
  btn.textContent = 'Copied!';
@@ -5389,7 +5320,7 @@ var selectedCli = 'claude';
5389
5320
  function renderLaunchPanel() {
5390
5321
  var el = document.getElementById('launch-area');
5391
5322
  // Fetch templates
5392
- fetch('/api/templates').then(function(r) { return r.json(); }).then(function(templates) {
5323
+ lttFetch('/api/templates').then(function(r) { return r.json(); }).then(function(templates) {
5393
5324
  launchTemplates = templates;
5394
5325
  var templateOpts = '<option value="">-- No template --</option>';
5395
5326
  for (var i = 0; i < templates.length; i++) {
@@ -5436,8 +5367,8 @@ function renderLaunchPanel() {
5436
5367
  }
5437
5368
 
5438
5369
  // ==================== 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'})})}
5370
+ 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(){})}
5371
+ 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
5372
 
5442
5373
  function selectCli(cli) {
5443
5374
  selectedCli = cli;
@@ -5471,7 +5402,7 @@ function doLaunch() {
5471
5402
  var prompt = document.getElementById('launch-prompt').value.trim();
5472
5403
  var resultEl = document.getElementById('launch-result');
5473
5404
 
5474
- fetch('/api/launch', {
5405
+ lttFetch('/api/launch', {
5475
5406
  method: 'POST',
5476
5407
  headers: { 'Content-Type': 'application/json' },
5477
5408
  body: JSON.stringify({ cli: selectedCli, project_dir: projectDir || undefined, agent_name: agentName, prompt: prompt || undefined })
@@ -5539,7 +5470,8 @@ function setConnStatus(status) {
5539
5470
 
5540
5471
  function initSSE() {
5541
5472
  try {
5542
- var eventSource = new EventSource('/api/events');
5473
+ var sseUrl = '/api/events' + (_lttToken ? '?token=' + encodeURIComponent(_lttToken) : '');
5474
+ var eventSource = new EventSource(sseUrl);
5543
5475
  eventSource.onmessage = function(e) {
5544
5476
  if (e.data === 'update' || e.data === 'connected') {
5545
5477
  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
  }
@@ -308,7 +328,7 @@ function apiStats(query) {
308
328
  function apiReset(query) {
309
329
  const projectPath = query.get('project') || null;
310
330
  const dataDir = resolveDataDir(projectPath);
311
- 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'];
331
+ const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'read_receipts.json', 'permissions.json'];
312
332
  for (const f of fixedFiles) {
313
333
  const p = path.join(dataDir, f);
314
334
  if (fs.existsSync(p)) fs.unlinkSync(p);
@@ -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'
@@ -1248,27 +1300,6 @@ const server = http.createServer(async (req, res) => {
1248
1300
  res.writeHead(200, { 'Content-Type': 'application/json' });
1249
1301
  res.end(JSON.stringify(branches));
1250
1302
  }
1251
- else if (url.pathname === '/api/plugins' && req.method === 'GET') {
1252
- const projectPath = url.searchParams.get('project') || null;
1253
- const pluginsFile = filePath('plugins.json', projectPath);
1254
- res.writeHead(200, { 'Content-Type': 'application/json' });
1255
- res.end(JSON.stringify(fs.existsSync(pluginsFile) ? JSON.parse(fs.readFileSync(pluginsFile, 'utf8')) : []));
1256
- }
1257
- else if (url.pathname === '/api/plugins' && req.method === 'POST') {
1258
- const body = await parseBody(req);
1259
- const projectPath = url.searchParams.get('project') || null;
1260
- const pluginsFile = filePath('plugins.json', projectPath);
1261
- let plugins = [];
1262
- if (fs.existsSync(pluginsFile)) try { plugins = JSON.parse(fs.readFileSync(pluginsFile, 'utf8')); } catch {}
1263
- if (body.action === 'toggle' && body.name) {
1264
- const p = plugins.find(x => x.name === body.name);
1265
- if (!p) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Plugin not found' })); return; }
1266
- p.enabled = !p.enabled;
1267
- fs.writeFileSync(pluginsFile, JSON.stringify(plugins, null, 2));
1268
- }
1269
- res.writeHead(200, { 'Content-Type': 'application/json' });
1270
- res.end(JSON.stringify({ success: true }));
1271
- }
1272
1303
  else if (url.pathname === '/api/projects' && req.method === 'DELETE') {
1273
1304
  const body = await parseBody(req);
1274
1305
  const result = apiRemoveProject(body);
@@ -1321,7 +1352,7 @@ const server = http.createServer(async (req, res) => {
1321
1352
  // Server info (LAN mode detection for frontend)
1322
1353
  else if (url.pathname === '/api/server-info' && req.method === 'GET') {
1323
1354
  res.writeHead(200, { 'Content-Type': 'application/json' });
1324
- res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT }));
1355
+ res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT, lan_token: LAN_MODE ? LAN_TOKEN : null }));
1325
1356
  }
1326
1357
  // Toggle LAN mode (re-bind server live)
1327
1358
  else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
@@ -1329,9 +1360,11 @@ const server = http.createServer(async (req, res) => {
1329
1360
  const lanIP = getLanIP();
1330
1361
  LAN_MODE = newMode;
1331
1362
  persistLanMode();
1363
+ // Regenerate token when enabling LAN mode
1364
+ if (newMode) generateLanToken();
1332
1365
  // Send response first
1333
1366
  res.writeHead(200, { 'Content-Type': 'application/json' });
1334
- res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT }));
1367
+ res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT, lan_token: newMode ? LAN_TOKEN : null }));
1335
1368
  // Re-bind by stopping the listener and immediately re-listening
1336
1369
  // Use setImmediate to let the response flush first
1337
1370
  setImmediate(() => {
@@ -1447,7 +1480,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
1447
1480
  const dataDir = resolveDataDir();
1448
1481
  const lanIP = getLanIP();
1449
1482
  console.log('');
1450
- console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.1');
1483
+ console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.3');
1451
1484
  console.log(' ============================================');
1452
1485
  console.log(' Dashboard: http://localhost:' + PORT);
1453
1486
  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.3",
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
@@ -18,8 +18,7 @@ const PROFILES_FILE = path.join(DATA_DIR, 'profiles.json');
18
18
  const WORKFLOWS_FILE = path.join(DATA_DIR, 'workflows.json');
19
19
  const WORKSPACES_DIR = path.join(DATA_DIR, 'workspaces');
20
20
  const BRANCHES_FILE = path.join(DATA_DIR, 'branches.json');
21
- const PLUGINS_FILE = path.join(DATA_DIR, 'plugins.json');
22
- const PLUGINS_DIR = path.join(DATA_DIR, 'plugins');
21
+ // Plugins removed in v3.4.3 — unnecessary attack surface, CLIs have their own extension systems
23
22
 
24
23
  // In-memory state for this process
25
24
  let registeredName = null;
@@ -418,17 +417,6 @@ function getHistoryFile(branch) {
418
417
  return path.join(DATA_DIR, `branch-${sanitizeName(branch)}-history.jsonl`);
419
418
  }
420
419
 
421
- // --- Plugin helpers ---
422
-
423
- function getPluginRegistry() {
424
- if (!fs.existsSync(PLUGINS_FILE)) return [];
425
- try { return JSON.parse(fs.readFileSync(PLUGINS_FILE, 'utf8')); } catch { return []; }
426
- }
427
-
428
- function savePluginRegistry(plugins) {
429
- fs.writeFileSync(PLUGINS_FILE, JSON.stringify(plugins, null, 2));
430
- }
431
-
432
420
  // --- Tool implementations ---
433
421
 
434
422
  function toolRegister(name, provider = null) {
@@ -1181,8 +1169,8 @@ function toolReset() {
1181
1169
  }
1182
1170
  }
1183
1171
  }
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]) {
1172
+ // Remove profiles, workflows, branches, permissions, read receipts
1173
+ for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE]) {
1186
1174
  if (fs.existsSync(f)) fs.unlinkSync(f);
1187
1175
  }
1188
1176
  // Remove workspaces dir
@@ -1497,11 +1485,6 @@ const server = new Server(
1497
1485
  );
1498
1486
 
1499
1487
  server.setRequestHandler(ListToolsRequestSchema, async () => {
1500
- const pluginTools = loadedPlugins.map(p => ({
1501
- name: 'plugin_' + p.name,
1502
- description: '[Plugin] ' + p.description,
1503
- inputSchema: p.inputSchema,
1504
- }));
1505
1488
  return {
1506
1489
  tools: [
1507
1490
  {
@@ -1875,7 +1858,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1875
1858
  properties: {},
1876
1859
  },
1877
1860
  },
1878
- ...pluginTools,
1879
1861
  ],
1880
1862
  };
1881
1863
  });
@@ -1969,12 +1951,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1969
1951
  result = toolListBranches();
1970
1952
  break;
1971
1953
  default:
1972
- // Check if it's a plugin tool
1973
- if (name.startsWith('plugin_')) {
1974
- const pluginName = name.substring(7);
1975
- result = await executePlugin(pluginName, args);
1976
- break;
1977
- }
1978
1954
  return {
1979
1955
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
1980
1956
  isError: true,
@@ -2014,90 +1990,11 @@ process.on('exit', () => {
2014
1990
  process.on('SIGTERM', () => process.exit(0));
2015
1991
  process.on('SIGINT', () => process.exit(0));
2016
1992
 
2017
- // --- Phase 5: Plugin system ---
2018
-
2019
- let loadedPlugins = []; // { name, description, inputSchema, handler }
2020
-
2021
- function loadPlugins() {
2022
- loadedPlugins = [];
2023
- if (!fs.existsSync(PLUGINS_DIR)) return;
2024
- const registry = getPluginRegistry();
2025
- const enabledNames = new Set(registry.filter(p => p.enabled !== false).map(p => p.name));
2026
-
2027
- try {
2028
- const vm = require('vm');
2029
- const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.js'));
2030
- for (const file of files) {
2031
- try {
2032
- const pluginPath = path.join(PLUGINS_DIR, file);
2033
- const code = fs.readFileSync(pluginPath, 'utf8');
2034
- // Run plugin in a sandboxed VM context — no require, no process, no child_process
2035
- const sandbox = { module: { exports: {} }, exports: {}, console: { log: () => {}, error: () => {}, warn: () => {} } };
2036
- vm.runInNewContext(code, sandbox, { filename: file, timeout: 5000 });
2037
- const plugin = sandbox.module.exports;
2038
- if (!plugin.name || !plugin.description || !plugin.handler) {
2039
- console.error(`Plugin ${file}: missing name, description, or handler`);
2040
- continue;
2041
- }
2042
- if (!enabledNames.has(plugin.name) && enabledNames.size > 0) continue;
2043
- loadedPlugins.push({
2044
- name: plugin.name,
2045
- description: plugin.description,
2046
- inputSchema: plugin.inputSchema || { type: 'object', properties: {} },
2047
- handler: plugin.handler,
2048
- });
2049
- console.error(`Plugin loaded: ${plugin.name} (sandboxed)`);
2050
- } catch (e) {
2051
- console.error(`Plugin ${file} failed to load: ${e.message}`);
2052
- }
2053
- }
2054
- } catch {}
2055
- }
2056
-
2057
- function executePlugin(pluginName, args) {
2058
- const plugin = loadedPlugins.find(p => p.name === pluginName);
2059
- if (!plugin) return { error: `Plugin "${pluginName}" not found` };
2060
-
2061
- const context = {
2062
- registeredName,
2063
- sendMessage: (to, content) => toolSendMessage(content, to),
2064
- getAgents: () => toolListAgents().agents,
2065
- getHistory: (limit) => toolGetHistory(limit),
2066
- readFile: (filePath) => {
2067
- const resolved = path.resolve(filePath);
2068
- const allowedRoot = path.resolve(process.cwd());
2069
- let realPath;
2070
- try { realPath = fs.realpathSync(resolved); } catch { throw new Error('File not found'); }
2071
- if (!realPath.startsWith(allowedRoot + path.sep) && realPath !== allowedRoot) {
2072
- throw new Error('File path must be within the project directory');
2073
- }
2074
- return fs.readFileSync(realPath, 'utf8');
2075
- },
2076
- };
2077
-
2078
- return new Promise((resolve) => {
2079
- const timeout = setTimeout(() => resolve({ error: 'Plugin execution timed out (30s)' }), 30000);
2080
- try {
2081
- const result = plugin.handler(args, context);
2082
- if (result && typeof result.then === 'function') {
2083
- result.then(r => { clearTimeout(timeout); resolve(r); }).catch(e => { clearTimeout(timeout); resolve({ error: e.message }); });
2084
- } else {
2085
- clearTimeout(timeout);
2086
- resolve(result);
2087
- }
2088
- } catch (e) {
2089
- clearTimeout(timeout);
2090
- resolve({ error: e.message });
2091
- }
2092
- });
2093
- }
2094
-
2095
1993
  async function main() {
2096
1994
  ensureDataDir();
2097
- loadPlugins();
2098
1995
  const transport = new StdioServerTransport();
2099
1996
  await server.connect(transport);
2100
- console.error('Agent Bridge MCP server v3.4.1 running (' + (27 + loadedPlugins.length) + ' tools)');
1997
+ console.error('Agent Bridge MCP server v3.4.3 running (27 tools)');
2101
1998
  }
2102
1999
 
2103
2000
  main().catch(console.error);