@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.
- package/CHANGELOG.md +58 -0
- package/Dockerfile +3 -1
- package/README.md +1 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +19 -0
- package/channels/kazee/adapter.js +304 -53
- package/channels/kazee/bot-handoffs.js +230 -0
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/browser-view.js +200 -0
- package/core/config.js +19 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +24 -12
- package/core/lessons.js +11 -5
- package/core/loopback.js +29 -0
- package/core/passive-replies.js +4 -2
- package/core/providers/claude-events.js +9 -0
- package/core/providers/codex-hook.js +5 -1
- package/core/providers/codex.js +48 -0
- package/core/relationship.js +1 -1
- package/core/router.js +6 -1
- package/core/runner.js +264 -8
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +137 -36
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +506 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +17 -1
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +21 -0
- package/test-provider-language.js +20 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +3 -1
- package/test-provider-tool-hooks.js +17 -0
- package/test-thread-lanes.js +18 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
953
|
-
function
|
|
954
|
-
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
976
|
+
// Every server-provided string that goes into innerHTML passes through esc.
|
|
977
|
+
function esc(s) {
|
|
978
|
+
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 =
|
|
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(
|
|
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="' +
|
|
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="' +
|
|
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="' +
|
|
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(
|
|
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(
|
|
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(
|
|
1125
|
-
<button class="danger" onclick="denyAuth(
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
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}`);
|