@inetafrica/open-claudia 3.5.14 → 3.5.17

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/Dockerfile +3 -1
  3. package/README.md +1 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +19 -0
  7. package/channels/kazee/adapter.js +304 -53
  8. package/channels/kazee/bot-handoffs.js +230 -0
  9. package/channels/telegram/adapter.js +4 -2
  10. package/core/actions.js +8 -0
  11. package/core/browser-view.js +200 -0
  12. package/core/config.js +19 -1
  13. package/core/cost-report.js +3 -0
  14. package/core/cron-schedule.js +59 -0
  15. package/core/dream.js +141 -34
  16. package/core/guardrail.js +5 -0
  17. package/core/handlers.js +24 -12
  18. package/core/lessons.js +11 -5
  19. package/core/loopback.js +29 -0
  20. package/core/passive-replies.js +4 -2
  21. package/core/providers/claude-events.js +9 -0
  22. package/core/providers/codex-hook.js +5 -1
  23. package/core/providers/codex.js +48 -0
  24. package/core/relationship.js +1 -1
  25. package/core/router.js +6 -1
  26. package/core/runner.js +264 -8
  27. package/core/safe-filename.js +47 -0
  28. package/core/scheduler.js +137 -36
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +506 -0
  32. package/core/utility-agent.js +17 -1
  33. package/package.json +4 -4
  34. package/test-fixtures/fake-agent-cli.js +17 -1
  35. package/test-guardrail.js +19 -0
  36. package/test-kazee-context-media.js +1 -1
  37. package/test-provider-dream.js +3 -1
  38. package/test-provider-events.js +21 -0
  39. package/test-provider-language.js +20 -1
  40. package/test-provider-pack-review.js +3 -1
  41. package/test-provider-scheduler.js +3 -1
  42. package/test-provider-tool-hooks.js +17 -0
  43. package/test-thread-lanes.js +18 -4
  44. package/web.js +148 -50
package/web.js CHANGED
@@ -373,6 +373,8 @@ async function liveChannels() {
373
373
 
374
374
  // ── API routes ─────────────────────────────────────────────────────
375
375
 
376
+ const USER_ACCESS_ROUTES = new Set(["/api/auth/approve", "/api/auth/deny", "/api/auth/remove"]);
377
+
376
378
  async function handleAPI(req, res, body, dependencies = {}) {
377
379
  const url = req.url;
378
380
 
@@ -406,7 +408,9 @@ async function handleAPI(req, res, body, dependencies = {}) {
406
408
  }
407
409
 
408
410
  // ── Authentication (Claude / Codex) ──
409
- if (url.startsWith("/api/auth/")) {
411
+ // /api/auth/approve|deny|remove are user access (the Users tab), handled further down; they
412
+ // must not fall into this provider sign-in block, whose catch-all answers 404.
413
+ if (url.startsWith("/api/auth/") && !USER_ACCESS_ROUTES.has(url)) {
410
414
  const webAuth = require("./core/web-auth");
411
415
  const json = (obj, c = 200) => { res.writeHead(c, { "Content-Type": "application/json" }); return res.end(JSON.stringify(obj)); };
412
416
  const post = req.method === "POST";
@@ -639,9 +643,21 @@ async function handleAPI(req, res, body, dependencies = {}) {
639
643
  return res.end(JSON.stringify({ content: loadLogs() }));
640
644
  }
641
645
 
646
+ // Live view of the bot's own browser, watch-only (core/browser-view.js): the running
647
+ // agent-browser sessions, and one session's screen as server-sent events.
648
+ if (url.split("?")[0] === "/api/browser/sessions" && req.method === "GET") {
649
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
650
+ return res.end(JSON.stringify({ sessions: require("./core/browser-view").describeSessions() }));
651
+ }
652
+ if (url.split("?")[0] === "/api/browser/stream" && req.method === "GET") {
653
+ const query = new URL(url, "http://localhost").searchParams;
654
+ return require("./core/browser-view").serveStream(res, query.get("session") || "", { fps: query.get("fps") });
655
+ }
656
+
642
657
  // Token usage history (for the Usage dashboard)
643
658
  if (url === "/api/usage") {
644
- const records = loadUsageHistory();
659
+ // Turn records only: compaction records (kind "compaction") carry no turn.
660
+ const records = loadUsageHistory().filter((r) => r.kind !== "compaction");
645
661
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
646
662
  const env = loadEnv();
647
663
  const alertPolicy = usageAlertPolicy(env);
@@ -799,8 +815,14 @@ function botDisplayName() {
799
815
  return "Open Claudia";
800
816
  }
801
817
 
818
+ // BOT_NAME lands inside the page's HTML and inside JS template literals in
819
+ // the page script, so it is escaped for both (entities decode back to text).
820
+ function pageEsc(s) {
821
+ return String(s).replace(/[&<>"'`$\\]/g, (c) => "&#" + c.charCodeAt(0) + ";");
822
+ }
823
+
802
824
  function getHTML() {
803
- const botName = botDisplayName();
825
+ const botName = pageEsc(botDisplayName());
804
826
  return `<!DOCTYPE html>
805
827
  <html lang="en">
806
828
  <head>
@@ -911,13 +933,14 @@ async function doLogin() {
911
933
  const pw = $("#pw").value;
912
934
  const r = await api("/api/login", { method: "POST", body: { password: pw } });
913
935
  if (r?.ok) init();
914
- else $("#login-msg").innerHTML = '<div class="msg err">' + (r?.error || "Wrong password") + '</div>';
936
+ else $("#login-msg").innerHTML = '<div class="msg err">' + esc(r?.error || "Wrong password") + '</div>';
915
937
  }
916
938
 
917
939
  function render() {
918
940
  const tabs = [
919
941
  { id: "dashboard", label: "Dashboard" },
920
942
  { id: "chat", label: "Chat" },
943
+ { id: "browser", label: "Browser" },
921
944
  { id: "usage", label: "Usage" },
922
945
  { id: "auth", label: "Users" },
923
946
  { id: "credentials", label: "Credentials" },
@@ -934,7 +957,7 @@ function render() {
934
957
  $("#app").innerHTML = \`
935
958
  <div class="container">
936
959
  <h1>${botName}</h1>
937
- <p class="subtitle">v\${status.version}</p>
960
+ <p class="subtitle">v\${esc(status.version)}</p>
938
961
  <div class="tabs">
939
962
  \${tabs.map(t => \`<div class="tab \${currentTab===t.id?'active':''}" onclick="switchTab('\${t.id}')">\${t.label}</div>\`).join("")}
940
963
  </div>
@@ -945,31 +968,39 @@ function render() {
945
968
 
946
969
  function switchTab(tab) {
947
970
  if (tab !== "chat" && window.chatES) { try { window.chatES.close(); } catch (e) {} window.chatES = null; }
971
+ if (tab !== "browser" && window.browserES) { try { window.browserES.close(); } catch (e) {} window.browserES = null; }
948
972
  currentTab = tab;
949
973
  render();
950
974
  }
951
975
 
952
- // ── Chat tab ──
953
- function chatEsc(s) {
954
- return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
976
+ // Every server-provided string that goes into innerHTML passes through esc.
977
+ function esc(s) {
978
+ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
955
979
  }
980
+ // A value passed as an argument in an inline onclick="...": a JS string
981
+ // literal, then escaped for the attribute.
982
+ function jsArg(v) { return esc(JSON.stringify(String(v == null ? "" : v))); }
983
+ // Only http(s) links are clickable.
984
+ function safeUrl(u) { return /^https?:\\/\\//i.test(String(u || "")) ? esc(u) : "#"; }
985
+
986
+ // ── Chat tab ──
956
987
 
957
988
  function chatFrameHTML(f) {
958
989
  if (f.kind === "message") {
959
990
  const cls = f.role === "user" ? "user" : "bot";
960
- let inner = chatEsc(f.text);
991
+ let inner = esc(f.text);
961
992
  if (f.buttons && f.buttons.length) {
962
993
  inner += '<div class="chat-btns">' + f.buttons.map(function (b) {
963
- return '<button onclick="chatAction(\\'' + chatEsc(b.id) + '\\',\\'' + chatEsc(b.payload || b.id) + '\\',\\'' + chatEsc(f.messageId) + '\\')">' + chatEsc(b.label) + '</button>';
994
+ return '<button onclick="chatAction(' + jsArg(b.id) + ',' + jsArg(b.payload || b.id) + ',' + jsArg(f.messageId) + ')">' + esc(b.label) + '</button>';
964
995
  }).join("") + '</div>';
965
996
  }
966
- return '<div class="chat-bubble ' + cls + '" data-mid="' + chatEsc(f.messageId) + '">' + inner + '</div>';
997
+ return '<div class="chat-bubble ' + cls + '" data-mid="' + esc(f.messageId) + '">' + inner + '</div>';
967
998
  }
968
999
  if (f.kind === "file") {
969
- return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '">📎 <a href="' + chatEsc(f.url) + '" target="_blank" rel="noopener">' + chatEsc(f.fileName || "file") + '</a>' + (f.caption ? '<br>' + chatEsc(f.caption) : '') + '</div>';
1000
+ return '<div class="chat-bubble bot" data-mid="' + esc(f.messageId) + '">📎 <a href="' + safeUrl(f.url) + '" target="_blank" rel="noopener">' + esc(f.fileName || "file") + '</a>' + (f.caption ? '<br>' + esc(f.caption) : '') + '</div>';
970
1001
  }
971
1002
  if (f.kind === "voice") {
972
- return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '"><audio controls src="' + chatEsc(f.url) + '"></audio></div>';
1003
+ return '<div class="chat-bubble bot" data-mid="' + esc(f.messageId) + '"><audio controls src="' + esc(f.url) + '"></audio></div>';
973
1004
  }
974
1005
  return "";
975
1006
  }
@@ -1058,13 +1089,13 @@ async function loadTab() {
1058
1089
  <div class="card">
1059
1090
  <div class="status-bar">
1060
1091
  <span><span class="status-dot green"></span> Bot configured</span>
1061
- <span>v\${status.version}</span>
1092
+ <span>v\${esc(status.version)}</span>
1062
1093
  </div>
1063
- <div class="list-item"><span>Workspace</span><span>\${status.workspace}</span></div>
1064
- <div class="list-item"><span>Bot Token</span><span>\${status.botToken}</span></div>
1065
- <div class="list-item"><span>Chat ID</span><span>\${status.chatId}</span></div>
1066
- <div class="list-item"><span>Default provider</span><span>\${status.defaultProvider?.label || "None (model turns unavailable)"}</span></div>
1067
- \${(status.providers || []).map(p => '<div class="list-item"><span>' + p.label + '</span><span>' + p.availability.state + ' · ' + p.compatibility.state + ' · auth ' + p.auth.state + '</span></div>').join("")}
1094
+ <div class="list-item"><span>Workspace</span><span>\${esc(status.workspace)}</span></div>
1095
+ <div class="list-item"><span>Bot Token</span><span>\${esc(status.botToken)}</span></div>
1096
+ <div class="list-item"><span>Chat ID</span><span>\${esc(status.chatId)}</span></div>
1097
+ <div class="list-item"><span>Default provider</span><span>\${esc(status.defaultProvider?.label || "None (model turns unavailable)")}</span></div>
1098
+ \${(status.providers || []).map(providerRowHTML).join("")}
1068
1099
  </div>\`;
1069
1100
  }
1070
1101
  else if (currentTab === "usage") {
@@ -1081,7 +1112,7 @@ async function loadTab() {
1081
1112
  el.innerHTML =
1082
1113
  '<div class="card">' +
1083
1114
  '<h2>Token Rate</h2>' +
1084
- '<p style="color:#888;font-size:13px;">Current: v' + u.currentVersion + ' · ' + u.totalRecords + ' turns logged</p>' +
1115
+ '<p style="color:#888;font-size:13px;">Current: v' + esc(u.currentVersion) + ' · ' + esc(u.totalRecords) + ' turns logged</p>' +
1085
1116
  '<div class="metric-grid">' +
1086
1117
  '<div class="metric"><div class="label">Latest context</div><div class="value">' + fmtK(latestCtx) + '</div></div>' +
1087
1118
  '<div class="metric"><div class="label">Recent baseline</div><div class="value">' + fmtK(baselineCtx) + '</div></div>' +
@@ -1110,8 +1141,8 @@ async function loadTab() {
1110
1141
  <h2>Authorized Users</h2>
1111
1142
  \${auth.authorized.map(a => \`
1112
1143
  <div class="list-item">
1113
- <span>\${a.name || a.chatId} \${a.username ? "@"+a.username : ""} \${a.isOwner ? "(owner)" : ""}</span>
1114
- \${!a.isOwner ? \`<button class="danger" onclick="removeAuth('\${a.chatId}')">Remove</button>\` : ""}
1144
+ <span>\${esc(a.name || a.chatId)} \${a.username ? "@"+esc(a.username) : ""} \${a.isOwner ? "(owner)" : ""}</span>
1145
+ \${!a.isOwner ? \`<button class="danger" onclick="removeAuth(\${jsArg(a.chatId)})">Remove</button>\` : ""}
1115
1146
  </div>\`).join("") || "<p>None</p>"}
1116
1147
  </div>
1117
1148
  \${auth.pending.length > 0 ? \`
@@ -1119,10 +1150,10 @@ async function loadTab() {
1119
1150
  <h2>Pending Requests</h2>
1120
1151
  \${auth.pending.map(p => \`
1121
1152
  <div class="list-item">
1122
- <span>\${p.name} \${p.username ? "@"+p.username : ""}</span>
1153
+ <span>\${esc(p.name)} \${p.username ? "@"+esc(p.username) : ""}</span>
1123
1154
  <span>
1124
- <button onclick="approveAuth('\${p.chatId}')">Approve</button>
1125
- <button class="danger" onclick="denyAuth('\${p.chatId}')">Deny</button>
1155
+ <button onclick="approveAuth(\${jsArg(p.chatId)})">Approve</button>
1156
+ <button class="danger" onclick="denyAuth(\${jsArg(p.chatId)})">Deny</button>
1126
1157
  </span>
1127
1158
  </div>\`).join("")}
1128
1159
  </div>\` : ""}\`;
@@ -1162,7 +1193,7 @@ async function loadTab() {
1162
1193
  <div class="card">
1163
1194
  <div class="form-group">
1164
1195
  <label>Assistant Identity & Personality</label>
1165
- <textarea id="soul-content" rows="15">\${soul.content}</textarea>
1196
+ <textarea id="soul-content" rows="15">\${esc(soul.content)}</textarea>
1166
1197
  </div>
1167
1198
  <button onclick="saveSoul()">Save</button>
1168
1199
  <span id="soul-msg" style="margin-left:12px;font-size:13px;color:#888;"></span>
@@ -1176,7 +1207,7 @@ async function loadTab() {
1176
1207
  <h2>Scheduled Tasks</h2>
1177
1208
  \${crons.map((c, i) => \`
1178
1209
  <div class="list-item">
1179
- <span>\${c.label || c.prompt.slice(0,40)} — \${c.schedule} — \${c.project}</span>
1210
+ <span>\${esc(c.label || String(c.prompt || "").slice(0,40))} — \${esc(c.schedule)} — \${esc(c.project)}</span>
1180
1211
  </div>\`).join("") || "<p>No cron jobs. Use /cron in Telegram to add them.</p>"}
1181
1212
  </div>\`;
1182
1213
  }
@@ -1188,11 +1219,43 @@ async function loadTab() {
1188
1219
  <h2>Conversation History</h2>
1189
1220
  \${sessions.slice().reverse().slice(0,25).map(s => \`
1190
1221
  <div class="list-item">
1191
- <span>\${s.title || s.id} · \${s.provider || "legacy"} · \${s.project}</span>
1222
+ <span>\${esc(s.title || s.id)} · \${esc(s.provider || "legacy")} · \${esc(s.project)}</span>
1192
1223
  <span style="color:#666;font-size:12px;">\${s.lastUsed ? new Date(s.lastUsed).toLocaleDateString() : "unknown"}</span>
1193
1224
  </div>\`).join("") || "<p>No sessions yet.</p>"}
1194
1225
  </div>\`;
1195
1226
  }
1227
+ else if (currentTab === "browser") {
1228
+ // Watch-only live view of the bot's own browser (GET /api/browser/sessions + /api/browser/stream).
1229
+ const r = await api("/api/browser/sessions");
1230
+ if (!r) return;
1231
+ const list = r.sessions || [];
1232
+ if (window.browserES) { try { window.browserES.close(); } catch (e) {} window.browserES = null; }
1233
+ if (!list.length) {
1234
+ el.innerHTML = '<div class="card"><h2>Browser</h2><p style="color:#888">The browser is not running. It shows here while the bot is using it.</p><button class="secondary" onclick="loadTab()">Check again</button></div>';
1235
+ return;
1236
+ }
1237
+ const pick = list.some(x => x.name === window.browserPick) ? window.browserPick : list[0].name;
1238
+ el.innerHTML = '<div class="card">' +
1239
+ '<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;">' +
1240
+ '<h2 style="margin:0">Browser</h2>' +
1241
+ (list.length > 1 ? '<select onchange="window.browserPick=this.value;loadTab()">' + list.map(x => '<option value="' + esc(x.name) + '"' + (x.name === pick ? ' selected' : '') + '>' + esc(x.name) + '</option>').join('') + '</select>' : '') +
1242
+ '</div>' +
1243
+ '<div id="bv-url" style="color:#888;font-size:12px;margin-bottom:8px;word-break:break-all"></div>' +
1244
+ '<img id="bv-img" alt="" style="width:100%;border-radius:8px;background:#000;min-height:120px">' +
1245
+ '<div id="bv-state" style="color:#888;font-size:12px;margin-top:6px">Connecting...</div></div>';
1246
+ const es = new EventSource('/api/browser/stream?session=' + encodeURIComponent(pick) + '&fps=5');
1247
+ window.browserES = es;
1248
+ es.addEventListener('frame', (ev) => {
1249
+ const m = JSON.parse(ev.data);
1250
+ const img = $('#bv-img'); if (img) img.src = 'data:image/jpeg;base64,' + m.data;
1251
+ const st = $('#bv-state'); if (st) st.textContent = 'Live - watch only';
1252
+ });
1253
+ es.addEventListener('url', (ev) => { const u = $('#bv-url'); if (u) u.textContent = JSON.parse(ev.data).url || ''; });
1254
+ es.addEventListener('end', () => {
1255
+ es.close(); window.browserES = null;
1256
+ const st = $('#bv-state'); if (st) st.textContent = 'The browser closed.';
1257
+ });
1258
+ }
1196
1259
  else if (currentTab === "logs") {
1197
1260
  const logs = await api("/api/logs");
1198
1261
  if (!logs) return;
@@ -1202,7 +1265,7 @@ async function loadTab() {
1202
1265
  <h2 style="margin:0">Logs</h2>
1203
1266
  <button class="secondary" onclick="loadTab()">Refresh</button>
1204
1267
  </div>
1205
- <pre>\${logs.content}</pre>
1268
+ <pre>\${esc(logs.content)}</pre>
1206
1269
  </div>\`;
1207
1270
  }
1208
1271
  else if (currentTab === "settings") {
@@ -1262,7 +1325,7 @@ async function loadTab() {
1262
1325
  <div class="form-group">
1263
1326
  <label>\${f.label}</label>
1264
1327
  <div style="color:#777;font-size:11px;margin:2px 0 6px;">\${f.desc}</div>
1265
- <input id="cfg-\${f.k}" value="\${config[f.k] || ""}" placeholder="\${f.ph || ""}">
1328
+ <input id="cfg-\${f.k}" value="\${esc(config[f.k] || "")}" placeholder="\${f.ph || ""}">
1266
1329
  </div>\`).join("")}
1267
1330
  </div>\`).join("");
1268
1331
  }
@@ -1278,12 +1341,12 @@ function usagePolicyHTML(policy, trend) {
1278
1341
  const parts = [];
1279
1342
  if (policy.absoluteContextTokens > 0) parts.push('absolute ceiling ' + fmtK(policy.absoluteContextTokens));
1280
1343
  if (policy.rateMultiplier > 0) {
1281
- parts.push('rate ceiling ' + policy.rateMultiplier + 'x after ' + policy.minBaselineTurns + ' baseline turns');
1344
+ parts.push('rate ceiling ' + esc(policy.rateMultiplier) + 'x after ' + esc(policy.minBaselineTurns) + ' baseline turns');
1282
1345
  }
1283
1346
  const baseline = trend && trend.baselineTurns ? trend.baselineTurns : 0;
1284
1347
  return '<div class="policy">Alert policy: ' + parts.join(' · ') +
1285
1348
  ' · cooldown ' + Math.round((policy.cooldownMs || 0) / 60000) + ' min' +
1286
- ' · baseline sample ' + baseline + '/' + policy.baselineTurns + ' turns</div>';
1349
+ ' · baseline sample ' + esc(baseline) + '/' + esc(policy.baselineTurns) + ' turns</div>';
1287
1350
  }
1288
1351
 
1289
1352
  function usageTableHTML(rows, keyName) {
@@ -1292,9 +1355,9 @@ function usageTableHTML(rows, keyName) {
1292
1355
  '</th><th>turns</th><th>avg ctx/turn</th><th>avg in</th><th>avg out</th><th>cost</th></tr></thead><tbody>';
1293
1356
  const body = rows.map(function (r) {
1294
1357
  const name = r[keyName] || "—";
1295
- return '<tr><td>' + name + '</td><td>' + r.turns + '</td><td>' + fmtK(r.avgContextTokens) +
1358
+ return '<tr><td>' + esc(name) + '</td><td>' + esc(r.turns) + '</td><td>' + fmtK(r.avgContextTokens) +
1296
1359
  '</td><td>' + fmtK(r.avgInputTokens) + '</td><td>' + fmtK(r.avgOutputTokens) +
1297
- '</td><td>$' + (r.totalCostUsd || 0).toFixed(3) + '</td></tr>';
1360
+ '</td><td>$' + Number(r.totalCostUsd || 0).toFixed(3) + '</td></tr>';
1298
1361
  }).join("");
1299
1362
  return head + body + "</tbody></table>";
1300
1363
  }
@@ -1365,7 +1428,7 @@ async function pollClaudeAuth() {
1365
1428
  }
1366
1429
  if (st.url) {
1367
1430
  box.innerHTML = \`<p style="font-size:13px;">Open this URL, sign in, then paste the code Claude gives you:</p>
1368
- <p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
1431
+ <p style="word-break:break-all;"><a href="\${safeUrl(st.url)}" target="_blank" rel="noopener">\${esc(st.url)}</a></p>
1369
1432
  <input id="claude-code" placeholder="Paste code here">
1370
1433
  <button onclick="submitClaudeCodeWeb()" style="margin-top:8px;">Submit code</button>\`;
1371
1434
  } else {
@@ -1388,7 +1451,7 @@ async function saveClaudeTokenWeb() {
1388
1451
  async function startCodexAuth() {
1389
1452
  $("#codex-flow").innerHTML = "<p class='subtitle'>Starting…</p>";
1390
1453
  const r = await api("/api/auth/codex/start", { method: "POST" });
1391
- if (r && r.error) { $("#codex-flow").innerHTML = "<div class='msg err'>" + r.error + "</div>"; return; }
1454
+ if (r && r.error) { $("#codex-flow").innerHTML = "<div class='msg err'>" + esc(r.error) + "</div>"; return; }
1392
1455
  pollCodexAuth();
1393
1456
  }
1394
1457
  async function pollCodexAuth() {
@@ -1404,8 +1467,8 @@ async function pollCodexAuth() {
1404
1467
  }
1405
1468
  if (st.url) {
1406
1469
  box.innerHTML = \`<p style="font-size:13px;">Open this URL and enter the code:</p>
1407
- <p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
1408
- \${st.code ? "<p>Device code: <b>" + st.code + "</b></p>" : ""}
1470
+ <p style="word-break:break-all;"><a href="\${safeUrl(st.url)}" target="_blank" rel="noopener">\${esc(st.url)}</a></p>
1471
+ \${st.code ? "<p>Device code: <b>" + esc(st.code) + "</b></p>" : ""}
1409
1472
  <p class='subtitle'>Waiting for you to finish in the browser…</p>\`;
1410
1473
  } else {
1411
1474
  box.innerHTML = "<p class='subtitle'>Starting…</p>";
@@ -1441,10 +1504,14 @@ async function saveConfig() {
1441
1504
  setTimeout(() => $("#config-msg").textContent = "", 3000);
1442
1505
  }
1443
1506
 
1507
+ function providerRowHTML(p) {
1508
+ return '<div class="list-item"><span>' + esc(p.label) + '</span><span>' + esc(p.availability.state) + ' · ' + esc(p.compatibility.state) + ' · auth ' + esc(p.auth.state) + '</span></div>';
1509
+ }
1510
+
1444
1511
  function renderSetup() {
1445
1512
  const providerRows = (status.providers || []).map(p =>
1446
- '<div class="list-item"><span>' + p.label + '</span><span>' + p.availability.state + ' · ' + p.compatibility.state + ' · auth ' + p.auth.state + '</span></div>' +
1447
- (p.recovery ? '<div style="color:#888;font-size:11px;margin:-8px 0 10px;">' + p.recovery.split("\\n")[0] + '</div>' : "")
1513
+ providerRowHTML(p) +
1514
+ (p.recovery ? '<div style="color:#888;font-size:11px;margin:-8px 0 10px;">' + esc(String(p.recovery).split("\\n")[0]) + '</div>' : "")
1448
1515
  ).join("");
1449
1516
  $("#app").innerHTML = \`
1450
1517
  <div class="container">
@@ -1484,7 +1551,7 @@ async function doSetup() {
1484
1551
  currentTab = "dashboard";
1485
1552
  await init();
1486
1553
  } else {
1487
- $("#setup-msg").innerHTML = \`<div class="msg err">\${r?.error || "Setup failed"}</div>\`;
1554
+ $("#setup-msg").innerHTML = \`<div class="msg err">\${esc(r?.error || "Setup failed")}</div>\`;
1488
1555
  }
1489
1556
  }
1490
1557
 
@@ -1496,23 +1563,55 @@ init();
1496
1563
 
1497
1564
  // ── Server ─────────────────────────────────────────────────────────
1498
1565
 
1566
+ // A failing handler answers 500 and is logged here. It must never become an
1567
+ // unhandled rejection: bot.js reports those to the owner, and some routes
1568
+ // (/otp/) need no sign-in, so anyone could make the bot message its owner.
1569
+ function answerFailure(res, err) {
1570
+ console.error("web: request failed:", err?.message || err);
1571
+ if (res.headersSent) { res.destroy(); return; }
1572
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
1573
+ res.end("Server error\n");
1574
+ }
1575
+
1499
1576
  let webServer = null;
1500
1577
 
1501
1578
  function startWebServer(options = {}) {
1502
1579
  if (webServer) return webServer;
1503
1580
  ensureConfigDir(CONFIG_DIR);
1504
1581
 
1505
- const server = http.createServer(async (req, res) => {
1582
+ const server = http.createServer((req, res) => {
1583
+ handleRequest(req, res).catch((e) => answerFailure(res, e));
1584
+ });
1585
+ async function handleRequest(req, res) {
1506
1586
  // CORS for local dev
1507
1587
  res.setHeader("Access-Control-Allow-Origin", "*");
1508
1588
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1509
1589
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1510
1590
  if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
1511
1591
 
1512
- // One-time magic-link login: /otp/<token> from the bot's /dashboard
1513
- // command. Burns the token, sets the session cookie, redirects to /.
1514
- if (req.method === "GET" && req.url.startsWith("/otp/")) {
1515
- const token = decodeURIComponent(req.url.slice(5).split("?")[0].split("/")[0]);
1592
+ // One-time magic-link login: /otp/<token> from the bot's /dashboard command. Opening the
1593
+ // link (GET) only shows a page that posts it back; the POST burns the token, sets the
1594
+ // session cookie and redirects to /. Link previews and scanners only fetch (Kazee Chat
1595
+ // fetches every link in a message, the bot's own replies included: HEAD, then GET), so they
1596
+ // can no longer use the link up before the person opens it. A malformed link answers 400.
1597
+ if (req.url.startsWith("/otp/") && ["GET", "HEAD", "POST"].includes(req.method)) {
1598
+ let token;
1599
+ try { token = decodeURIComponent(req.url.slice(5).split("?")[0].split("/")[0]); }
1600
+ catch (e) {
1601
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1602
+ return res.end("Bad link. Send /dashboard in the bot for a fresh one.\n");
1603
+ }
1604
+ const page = (status, inner, extra = "") => {
1605
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "same-origin", "X-Robots-Tag": "noindex" });
1606
+ if (req.method === "HEAD") return res.end();
1607
+ return res.end(`<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,sans-serif;background:#0f0f0f;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;padding:20px}button{font-size:16px;padding:10px 18px;border-radius:8px;border:0;background:#6366f1;color:#fff}</style></head><body><div>${inner}</div>${extra}</body></html>`);
1608
+ };
1609
+ const expired = () => page(401, '<h2>Link expired or already used</h2><p style="color:#888">Send /dashboard in the bot for a fresh link.</p>');
1610
+ if (!/^[0-9a-f]{64}$/.test(token)) return expired();
1611
+ if (req.method !== "POST") {
1612
+ return page(200, `<form method="post" action="/otp/${token}"><h2>Opening your dashboard…</h2><button type="submit">Open dashboard</button></form>`, "<script>document.forms[0].submit()</script>");
1613
+ }
1614
+ req.resume();
1516
1615
  const { consume } = require("./core/web-otp");
1517
1616
  if (consume(token)) {
1518
1617
  res.writeHead(302, {
@@ -1521,21 +1620,20 @@ function startWebServer(options = {}) {
1521
1620
  });
1522
1621
  return res.end();
1523
1622
  }
1524
- res.writeHead(401, { "Content-Type": "text/html" });
1525
- return res.end('<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,sans-serif;background:#0f0f0f;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;padding:20px}</style></head><body><div><h2>Link expired or already used</h2><p style="color:#888">Send /dashboard in the bot for a fresh link.</p></div></body></html>');
1623
+ return expired();
1526
1624
  }
1527
1625
 
1528
1626
  if (req.url.startsWith("/api/")) {
1529
1627
  let body = "";
1530
1628
  req.on("data", (d) => { body += d; });
1531
- req.on("end", () => handleAPI(req, res, body, options));
1629
+ req.on("end", () => handleAPI(req, res, body, options).catch((e) => answerFailure(res, e)));
1532
1630
  return;
1533
1631
  }
1534
1632
 
1535
1633
  // Serve HTML
1536
1634
  res.writeHead(200, { "Content-Type": "text/html" });
1537
1635
  res.end(getHTML());
1538
- });
1636
+ }
1539
1637
 
1540
1638
  server.listen(PORT, () => {
1541
1639
  console.log(`Web UI running on http://localhost:${PORT}`);