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