@songsid/agend 2.1.6-beta.6 → 2.1.6-beta.8
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/dist/backend/claude-code.d.ts +25 -0
- package/dist/backend/claude-code.js +103 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +27 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +14 -0
- package/dist/channel/adapters/discord.js +59 -1
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +36 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/tool-router.js +20 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +18 -0
- package/dist/config-validator.js +3 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/connection-secrets.d.ts +96 -0
- package/dist/connection-secrets.js +25 -0
- package/dist/connection-secrets.js.map +1 -0
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +246 -80
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +171 -0
- package/dist/fleet-manager.js +954 -5
- package/dist/fleet-manager.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +3 -0
- package/dist/provider-probe.js +12 -2
- package/dist/provider-probe.js.map +1 -1
- package/dist/provider-secret-registry.d.ts +98 -0
- package/dist/provider-secret-registry.js +334 -0
- package/dist/provider-secret-registry.js.map +1 -0
- package/dist/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +14 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/secret-store.d.ts +25 -0
- package/dist/secret-store.js +165 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/settings-api.d.ts +101 -0
- package/dist/settings-api.js +404 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/settings.html +171 -6
- package/dist/usage/providers.d.ts +8 -6
- package/dist/usage/providers.js +83 -9
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +12 -0
- package/dist/usage/usage-api.js +55 -0
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/settings.html
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
button.danger:hover { background: #fef2f2; }
|
|
50
50
|
button.sm { padding: 4px 9px; font-size: 12px; }
|
|
51
51
|
label { display: block; font-size: 12px; font-weight: 500; color: var(--muted); margin: 10px 0 4px; }
|
|
52
|
-
input[type=text], input[type=number], select { width: 100%; font-family: inherit; font-size: 13px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; color: var(--fg); }
|
|
52
|
+
input[type=text], input[type=password], input[type=number], select { width: 100%; font-family: inherit; font-size: 13px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; color: var(--fg); }
|
|
53
53
|
input:focus, select:focus { outline: 2px solid #bfdbfe; border-color: var(--accent); }
|
|
54
54
|
.edit { padding: 14px 16px; background: #fff; border-top: 1px solid var(--border); }
|
|
55
55
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
<div class="sec-head"><h2>🔌 Connections & Bots</h2><div class="grow"></div><button class="sm" id="newBotBtn" data-i18n="add">+ Add</button></div>
|
|
194
194
|
<div id="newBot"></div>
|
|
195
195
|
<div class="card" id="bots"></div>
|
|
196
|
+
<div class="card" id="providerSecrets" style="margin-top:12px"></div>
|
|
196
197
|
</section>
|
|
197
198
|
|
|
198
199
|
<section>
|
|
@@ -417,7 +418,7 @@
|
|
|
417
418
|
}
|
|
418
419
|
|
|
419
420
|
// ── State ──
|
|
420
|
-
const state = { schema: { impacts: {}, order: ["now", "instance", "fleet"] }, fleet: {}, classic: {}, classicStatus: {}, classicLive: {}, live: {}, fleetUp: false, advFmt: "yaml", advEdit: false, collapsedGroups: new Set(), version: "", newAgentOpen: false, newBotOpen: false, pending: new Map() };
|
|
421
|
+
const state = { schema: { impacts: {}, order: ["now", "instance", "fleet"] }, fleet: {}, classic: {}, classicStatus: {}, classicLive: {}, live: {}, connections: [], providerSecrets: [], fleetUp: false, advFmt: "yaml", advEdit: false, collapsedGroups: new Set(), version: "", newAgentOpen: false, newBotOpen: false, pending: new Map() };
|
|
421
422
|
const channels = () => state.fleet.channels || (state.fleet.channel ? [state.fleet.channel] : []);
|
|
422
423
|
const channelIds = () => channels().map(c => c.id || c.type).filter(Boolean);
|
|
423
424
|
const chLabel = (i) => i === 0 ? "primary" : "persona";
|
|
@@ -707,8 +708,10 @@
|
|
|
707
708
|
catch (error) { result = { ok: false, body: { error: error.message } }; }
|
|
708
709
|
if (!result?.ok) {
|
|
709
710
|
showBanner(`${change.label}: ${result?.body?.error || t("applyFailed")}`, true);
|
|
711
|
+
change.cleanup?.();
|
|
710
712
|
button.disabled = false; return;
|
|
711
713
|
}
|
|
714
|
+
change.cleanup?.();
|
|
712
715
|
state.pending.delete(key); updatePendingBar();
|
|
713
716
|
}
|
|
714
717
|
// The key is generated here, before the first attempt, and reused by every
|
|
@@ -1156,6 +1159,7 @@
|
|
|
1156
1159
|
? (ch.access.allowed_users).slice(0, 3).join(", ") + ((ch.access.allowed_users).length > 3 ? ` +${(ch.access.allowed_users).length - 3}` : "")
|
|
1157
1160
|
: t("noAllowedUsers")),
|
|
1158
1161
|
el("span", { class: "spacer" }),
|
|
1162
|
+
el("span", { class: "tag", title: "secret status" }, state.connections.find(c => (c.id || c.type) === (ch.id || ch.type))?.token_present ? "Token configured" : "Token missing"),
|
|
1159
1163
|
el("span", { style: `color:${state.fleetUp ? "var(--green)" : "var(--red)"};font-size:13px` }, state.fleetUp ? "Connected" : "Problem"),
|
|
1160
1164
|
el("button", { class: "sm ghost", onclick: () => {
|
|
1161
1165
|
const form = botEditForm(ch, i);
|
|
@@ -1166,6 +1170,125 @@
|
|
|
1166
1170
|
});
|
|
1167
1171
|
}
|
|
1168
1172
|
|
|
1173
|
+
// Provider API keys use a separate registry endpoint. Unsupported providers
|
|
1174
|
+
// are intentionally rendered without an input: a key that cannot be
|
|
1175
|
+
// positively verified must never be accepted as an "unverified" write.
|
|
1176
|
+
function providerSecretInputAllowed(spec) { return spec?.verifier === "available"; }
|
|
1177
|
+
function renderProviderSecrets() {
|
|
1178
|
+
const host = $("providerSecrets"); if (!host) return;
|
|
1179
|
+
host.innerHTML = "";
|
|
1180
|
+
const rows = Array.isArray(state.providerSecrets) ? state.providerSecrets : [];
|
|
1181
|
+
if (!rows.length) { host.className = "card empty"; host.textContent = "No provider API-key verifiers available."; return; }
|
|
1182
|
+
host.className = "card";
|
|
1183
|
+
rows.forEach(spec => {
|
|
1184
|
+
const row = el("div", { class: "item" });
|
|
1185
|
+
const feedback = el("div", { class: "feedback" });
|
|
1186
|
+
const present = spec.token_present ? "Key configured" : "Not configured";
|
|
1187
|
+
if (!providerSecretInputAllowed(spec)) {
|
|
1188
|
+
row.append(el("div", { class: "item-row" }, el("span", { class: "name" }, spec.display_name), el("span", { class: "tag" }, "Unsupported verifier")), el("div", { class: "hint" }, "This provider cannot be safely verified here; no key input is offered."));
|
|
1189
|
+
host.append(row); return;
|
|
1190
|
+
}
|
|
1191
|
+
const input = el("input", { type: "password", autocomplete: "new-password", placeholder: present });
|
|
1192
|
+
const button = el("button", { class: "sm primary" }, "Verify & apply");
|
|
1193
|
+
button.onclick = async () => {
|
|
1194
|
+
const secret = input.value;
|
|
1195
|
+
if (!secret) { feedback.className = "feedback error"; feedback.textContent = "Enter a key first."; return; }
|
|
1196
|
+
button.disabled = true; feedback.className = "feedback"; feedback.textContent = "Verifying…";
|
|
1197
|
+
const key = crypto.randomUUID ? crypto.randomUUID() : `provider-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1198
|
+
try {
|
|
1199
|
+
const verified = await api(`/api/settings/secrets/${encodeURIComponent(spec.id)}/verify`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ secret, idempotency_key: key }) });
|
|
1200
|
+
if (!verified.ok) { feedback.className = "feedback error"; feedback.textContent = `✗ ${verified.body?.error || "verification failed"}`; return; }
|
|
1201
|
+
feedback.textContent = "Applying…";
|
|
1202
|
+
let result = await api(`/api/settings/secrets/${encodeURIComponent(spec.id)}/apply`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ verification_id: verified.body?.verification_id, idempotency_key: key }) });
|
|
1203
|
+
let body = result.body || {};
|
|
1204
|
+
for (let n = 0; result.ok && body.job_id && body.result === "applying" && n < 60; n++) {
|
|
1205
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1206
|
+
const status = await api(`/api/settings/secrets/${encodeURIComponent(spec.id)}/apply/${encodeURIComponent(body.job_id)}`);
|
|
1207
|
+
if (status.ok) body = status.body || body;
|
|
1208
|
+
}
|
|
1209
|
+
const labels = { applied_next_use: "✓ Saved — new processes will use it", reloaded: "✓ Applied", rolled_back: "↩ Rolled back", rollback_failed: "✗ Rollback failed" };
|
|
1210
|
+
const ok = body.result === "applied_next_use" || body.result === "reloaded";
|
|
1211
|
+
feedback.className = `feedback ${ok ? "" : "error"}`;
|
|
1212
|
+
feedback.textContent = labels[body.result] || `✗ ${body.error || "Apply failed"}`;
|
|
1213
|
+
if (ok) { input.value = ""; await reload(); }
|
|
1214
|
+
} finally { input.value = ""; button.disabled = false; }
|
|
1215
|
+
};
|
|
1216
|
+
row.append(el("div", { class: "item-row" }, el("span", { class: "name" }, spec.display_name), el("span", { class: "tag" }, present), el("span", { class: "spacer" }, input), button), feedback);
|
|
1217
|
+
host.append(row);
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Secret rotation is staged through the same Apply bar as every other
|
|
1222
|
+
// Settings mutation. The token is held only in this short-lived closure and
|
|
1223
|
+
// is cleared by the caller after the request settles; it never enters
|
|
1224
|
+
// localStorage, the URL, a label, or the staged change's display text.
|
|
1225
|
+
async function applyConnectionSecret(connectionId, secret, feedback) {
|
|
1226
|
+
const key = crypto.randomUUID ? crypto.randomUUID() : `secret-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1227
|
+
feedback.className = "feedback"; feedback.textContent = "Verifying…";
|
|
1228
|
+
const verified = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/secret/verify`, {
|
|
1229
|
+
method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ secret, idempotency_key: key }),
|
|
1230
|
+
});
|
|
1231
|
+
if (!verified.ok) { feedback.className = "feedback error"; feedback.textContent = `✗ ${verified.body?.error || "verification failed"}`; return verified; }
|
|
1232
|
+
feedback.textContent = "Applying…";
|
|
1233
|
+
const applied = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/secret/apply`, {
|
|
1234
|
+
method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ verification_id: verified.body?.verification_id, idempotency_key: key }),
|
|
1235
|
+
});
|
|
1236
|
+
if (!applied.ok && applied.status !== 409) { feedback.className = "feedback error"; feedback.textContent = `✗ ${applied.body?.error || "apply failed"}`; return applied; }
|
|
1237
|
+
let result = applied.body || {};
|
|
1238
|
+
if (result.job_id && result.result === "applying") {
|
|
1239
|
+
for (let n = 0; n < 60; n++) {
|
|
1240
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1241
|
+
const status = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/secret/apply/${encodeURIComponent(result.job_id)}`);
|
|
1242
|
+
if (status.ok) { result = status.body || result; if (result.result !== "applying") break; }
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
const labels = { applying: "…Still applying — check status", applied: "✓ Applied", restart_required: "↻ Saved — restart required", rolled_back: "↩ Rolled back", rollback_failed: "✗ Rollback failed" };
|
|
1246
|
+
feedback.className = `feedback ${result.result === "applied" || result.result === "restart_required" ? "" : "error"}`;
|
|
1247
|
+
feedback.textContent = labels[result.result] || "✗ Unknown apply result";
|
|
1248
|
+
return { ok: result.result === "applied" || result.result === "restart_required", status: result.result === "applied" ? 200 : 409, body: result };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Binding changes use a separate verified challenge. They never go through
|
|
1252
|
+
// PUT /fleet/channels: the provider probe proves the target first, and the
|
|
1253
|
+
// apply job updates only group/general coordinates after the replacement
|
|
1254
|
+
// adapter is ready.
|
|
1255
|
+
async function verifyAndApplyConnectionBinding(connectionId, groupId, generalChannelId, feedback) {
|
|
1256
|
+
const key = crypto.randomUUID ? crypto.randomUUID() : `binding-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1257
|
+
const binding = { group_id: String(groupId).trim(), general_channel_id: String(generalChannelId || "").trim() || null };
|
|
1258
|
+
feedback.className = "feedback"; feedback.textContent = "Verifying target…";
|
|
1259
|
+
const verified = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/binding/verify`, {
|
|
1260
|
+
method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ ...binding, idempotency_key: key }),
|
|
1261
|
+
});
|
|
1262
|
+
if (!verified.ok) { feedback.className = "feedback error"; feedback.textContent = `✗ ${verified.body?.error || "binding verification failed"}`; return verified; }
|
|
1263
|
+
const probe = verified.body?.probe || {};
|
|
1264
|
+
const target = [probe.group_name || binding.group_id, probe.channel_name || probe.channel_id].filter(Boolean).join(" / ");
|
|
1265
|
+
const perms = [probe.can_view ? "view" : null, probe.can_send ? "send" : null, probe.can_manage_topics ? "topics" : null].filter(Boolean).join(", ");
|
|
1266
|
+
feedback.textContent = `✓ Verified ${target}${perms ? ` (${perms})` : ""}`;
|
|
1267
|
+
if (!window.confirm(`Rebind this connection to ${target || binding.group_id}?`)) {
|
|
1268
|
+
feedback.textContent = "Verified; not applied.";
|
|
1269
|
+
return { ok: false, status: 409, body: { result: "verified" } };
|
|
1270
|
+
}
|
|
1271
|
+
feedback.textContent = "Applying binding…";
|
|
1272
|
+
const applied = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/binding/apply`, {
|
|
1273
|
+
method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ verification_id: verified.body?.verification_id, idempotency_key: key }),
|
|
1274
|
+
});
|
|
1275
|
+
if (!applied.ok && applied.status !== 409) { feedback.className = "feedback error"; feedback.textContent = `✗ ${applied.body?.error || "binding apply failed"}`; return applied; }
|
|
1276
|
+
let result = applied.body || {};
|
|
1277
|
+
if (result.job_id && result.result === "applying") {
|
|
1278
|
+
for (let n = 0; n < 60; n++) {
|
|
1279
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1280
|
+
const status = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/binding/apply/${encodeURIComponent(result.job_id)}`);
|
|
1281
|
+
if (status.ok) { result = status.body || result; if (result.result !== "applying") break; }
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
const labels = { applied: "✓ Binding applied", rolled_back: "↩ Binding rolled back", rollback_failed: "✗ Binding rollback failed", applying: "…Still applying — check status" };
|
|
1285
|
+
const ok = result.result === "applied";
|
|
1286
|
+
feedback.className = `feedback ${ok ? "" : "error"}`;
|
|
1287
|
+
feedback.textContent = labels[result.result] || "✗ Unknown binding result";
|
|
1288
|
+
if (ok) await reload();
|
|
1289
|
+
return { ok, status: ok ? 200 : 409, body: result };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1169
1292
|
function botEditForm(ch, i) {
|
|
1170
1293
|
ch.access = ch.access || { mode: "locked", allowed_users: [] };
|
|
1171
1294
|
ch.access.allowed_users = ch.access.allowed_users || [];
|
|
@@ -1175,18 +1298,54 @@
|
|
|
1175
1298
|
const fMode = select(ch.access.mode || "locked", ["open", "locked", "pairing"]);
|
|
1176
1299
|
const users = [...ch.access.allowed_users];
|
|
1177
1300
|
const accessFeedback = el("div", { class: "feedback" });
|
|
1301
|
+
const tokenInput = el("input", { type: "password", autocomplete: "new-password", placeholder: "Paste a new bot token to rotate it" });
|
|
1302
|
+
const secretFeedback = el("div", { class: "feedback" });
|
|
1303
|
+
const bindingFeedback = el("div", { class: "feedback" });
|
|
1304
|
+
const fGroup = el("input", { type: "text", value: ch.group_id != null ? String(ch.group_id) : "", autocomplete: "off" });
|
|
1305
|
+
const fGeneral = el("input", { type: "text", value: ch.options?.general_channel_id != null ? String(ch.options.general_channel_id) : "", autocomplete: "off", placeholder: type === "discord" ? "General channel id (optional)" : "Forum topic id (optional)" });
|
|
1306
|
+
const bindingVerify = el("button", { class: "sm primary", onclick: async () => {
|
|
1307
|
+
if (!fGroup.value.trim()) { bindingFeedback.className = "feedback error"; bindingFeedback.textContent = "✗ group/guild id required"; return; }
|
|
1308
|
+
await verifyAndApplyConnectionBinding(connectionId, fGroup.value, fGeneral.value, bindingFeedback);
|
|
1309
|
+
} }, "Verify new binding → Confirm rebind");
|
|
1310
|
+
const connectionId = ch.id || ch.type || chLabel(i);
|
|
1311
|
+
let pendingSecret = "";
|
|
1312
|
+
const stageSecret = () => {
|
|
1313
|
+
const secret = tokenInput.value;
|
|
1314
|
+
if (!secret) { secretFeedback.className = "feedback error"; secretFeedback.textContent = "✗ token required"; return; }
|
|
1315
|
+
pendingSecret = secret;
|
|
1316
|
+
tokenInput.value = "";
|
|
1317
|
+
stageChange(`secret:${connectionId}`, {
|
|
1318
|
+
// Secret rotation is an immediate adapter operation. Use the server's
|
|
1319
|
+
// published impact ordering rather than duplicating an impact literal
|
|
1320
|
+
// in this page (hot/cold parity guards reject such copies).
|
|
1321
|
+
label: `Rotate ${type} token`, impact: state.schema.order[0],
|
|
1322
|
+
apply: async () => {
|
|
1323
|
+
const value = pendingSecret; pendingSecret = "";
|
|
1324
|
+
try { return await applyConnectionSecret(connectionId, value, secretFeedback); }
|
|
1325
|
+
finally { pendingSecret = ""; }
|
|
1326
|
+
},
|
|
1327
|
+
cleanup: () => { pendingSecret = ""; },
|
|
1328
|
+
});
|
|
1329
|
+
secretFeedback.className = "feedback"; secretFeedback.textContent = "Queued — click Apply changes.";
|
|
1330
|
+
};
|
|
1178
1331
|
const validateAccess = () => setValidation(fMode, accessFeedback, "", fMode.value === "locked" && users.length === 0 ? t("accessLockedEmpty") : "");
|
|
1179
1332
|
fMode.addEventListener("change", validateAccess);
|
|
1180
1333
|
box.append(
|
|
1181
1334
|
el("div", { class: "grid2" },
|
|
1182
1335
|
el("div", {}, el("label", {}, t("accessMode"), impact("fleet.channel.access.mode")), fMode, accessFeedback),
|
|
1183
1336
|
el("div", {}, el("label", {}, t("allowedUsers"), impact("fleet.channel.access.allowed_users")), chipList(users, validateAccess, "user id"))),
|
|
1184
|
-
// Only the field that platform actually has, the way quickstart asks.
|
|
1185
1337
|
el("div", { class: "field" },
|
|
1186
1338
|
el("label", {}, type === "discord" ? t("guildIdField") : t("groupIdField"), impact("fleet.channels")),
|
|
1187
|
-
|
|
1339
|
+
fGroup,
|
|
1340
|
+
el("label", {}, type === "discord" ? "General channel id" : "Forum topic id"), fGeneral,
|
|
1341
|
+
el("div", { class: "hint" }, "The target is checked with the existing bot token before any binding is changed."),
|
|
1342
|
+
el("div", { class: "actions" }, bindingVerify, bindingFeedback)),
|
|
1188
1343
|
drawer(t("advancedSection"),
|
|
1189
1344
|
el("div", { class: "field" }, el("label", {}, t("tokenEnv")), el("div", { class: "sub" }, ch.bot_token_env || "(no token env)")),
|
|
1345
|
+
el("div", { class: "field" },
|
|
1346
|
+
el("label", {}, "Bot token (never stored in the browser)"), tokenInput,
|
|
1347
|
+
el("div", { class: "hint" }, "The token is verified by AgEnD, then applied by replacing the running adapter. It is never echoed."),
|
|
1348
|
+
el("div", { class: "actions" }, el("button", { class: "sm primary", onclick: stageSecret }, "Verify & apply token"), secretFeedback)),
|
|
1190
1349
|
el("div", { class: "actions" },
|
|
1191
1350
|
el("button", { class: "sm danger", onclick: () => { closeModal(); delBot(i); } }, t("deleteBot")))),
|
|
1192
1351
|
);
|
|
@@ -1639,7 +1798,7 @@
|
|
|
1639
1798
|
}
|
|
1640
1799
|
|
|
1641
1800
|
// ── Load / render ──
|
|
1642
|
-
function renderAll() { renderAgents(); renderClassic(); renderBots(); renderGeneral(); renderAdvanced(); applyStaticI18n(); }
|
|
1801
|
+
function renderAll() { renderAgents(); renderClassic(); renderBots(); renderProviderSecrets(); renderGeneral(); renderAdvanced(); applyStaticI18n(); }
|
|
1643
1802
|
async function reloadLive() {
|
|
1644
1803
|
const f = await api("/api/fleet");
|
|
1645
1804
|
state.fleetUp = f.ok;
|
|
@@ -1671,6 +1830,12 @@
|
|
|
1671
1830
|
if (!f.ok) { $("banner").style.display = "block"; $("banner").style.background = "#fef2f2"; $("banner").style.color = "var(--red)"; $("banner").textContent = t("configLoadFailed"); return; }
|
|
1672
1831
|
state.fleet = f.body || {};
|
|
1673
1832
|
const c = await api("/api/settings/classic"); state.classic = c.body || {};
|
|
1833
|
+
const connections = await api("/api/settings/connections");
|
|
1834
|
+
state.connections = connections.ok && Array.isArray(connections.body) ? connections.body : [];
|
|
1835
|
+
const providerSecrets = await api("/api/settings/provider-secrets");
|
|
1836
|
+
const providerHost = $("providerSecrets");
|
|
1837
|
+
if (providerHost) providerHost.style.display = providerSecrets.ok ? "" : "none";
|
|
1838
|
+
state.providerSecrets = providerSecrets.ok && Array.isArray(providerSecrets.body) ? providerSecrets.body : [];
|
|
1674
1839
|
await reloadLive();
|
|
1675
1840
|
renderAll();
|
|
1676
1841
|
}
|
|
@@ -1692,7 +1857,7 @@
|
|
|
1692
1857
|
document.addEventListener("keydown", e => { if (e.key === "Escape" && modalState) closeModal(); });
|
|
1693
1858
|
$("wizardBtn").onclick = () => openWizard();
|
|
1694
1859
|
$("applyChanges").onclick = applyPendingChanges;
|
|
1695
|
-
$("discardChanges").onclick = async () => { state.pending.clear(); updatePendingBar(); await reload(); };
|
|
1860
|
+
$("discardChanges").onclick = async () => { for (const change of state.pending.values()) change.cleanup?.(); state.pending.clear(); updatePendingBar(); await reload(); };
|
|
1696
1861
|
|
|
1697
1862
|
reload();
|
|
1698
1863
|
})();
|
|
@@ -140,16 +140,18 @@ type UsageProvider = {
|
|
|
140
140
|
name: string;
|
|
141
141
|
fetch: (storeHome?: string) => Promise<Omit<ProviderUsage, "id" | "name">>;
|
|
142
142
|
};
|
|
143
|
+
type UsageFleetConfig = Parameters<typeof listConfiguredProfiles>[0];
|
|
143
144
|
/**
|
|
144
145
|
* One row per subscription, not one row per backend.
|
|
145
146
|
*
|
|
146
|
-
* A fleet running two kiro logins has two quotas, and a single row could
|
|
147
|
-
* ever show one of them — or worse, add them together and show a number
|
|
148
|
-
* true of neither. Each
|
|
149
|
-
* gets its own row; a
|
|
150
|
-
*
|
|
147
|
+
* A fleet running two kiro/codex logins has two quotas, and a single row could
|
|
148
|
+
* only ever show one of them — or worse, add them together and show a number
|
|
149
|
+
* that is true of neither. Each effective credential profile reads its own
|
|
150
|
+
* store and gets its own row; a mixed fleet also keeps one row for effective
|
|
151
|
+
* legacy shared bindings. Instance-private homes are never scanned: their
|
|
152
|
+
* auth files point at these same logical sources.
|
|
151
153
|
*/
|
|
152
|
-
export declare function providersForConfig(config:
|
|
154
|
+
export declare function providersForConfig(config: UsageFleetConfig, base?: UsageProvider[]): UsageProvider[];
|
|
153
155
|
/** Test seam: a provider that hangs cannot be simulated with real vendors. */
|
|
154
156
|
export declare function setUsageProvidersForTests(providers: UsageProvider[] | null): void;
|
|
155
157
|
/**
|
package/dist/usage/providers.js
CHANGED
|
@@ -23,7 +23,7 @@ import { readFileSync } from "node:fs";
|
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { getAgendHome } from "../paths.js";
|
|
25
25
|
import { loadFleetConfig } from "../config.js";
|
|
26
|
-
import { credentialHomeSpec, credentialProfileStoreHome,
|
|
26
|
+
import { credentialHomeSpec, credentialProfileStoreHome, instanceCredentialProfile, } from "../backend/credential-profile.js";
|
|
27
27
|
import { readKiroAuthTokens } from "../backend/kiro-auth-store.js";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { getProviderRateLimit } from "./provider-alerts.js";
|
|
@@ -1099,27 +1099,101 @@ const DEFAULT_PROVIDERS = [
|
|
|
1099
1099
|
/**
|
|
1100
1100
|
* One row per subscription, not one row per backend.
|
|
1101
1101
|
*
|
|
1102
|
-
* A fleet running two kiro logins has two quotas, and a single row could
|
|
1103
|
-
* ever show one of them — or worse, add them together and show a number
|
|
1104
|
-
* true of neither. Each
|
|
1105
|
-
* gets its own row; a
|
|
1106
|
-
*
|
|
1102
|
+
* A fleet running two kiro/codex logins has two quotas, and a single row could
|
|
1103
|
+
* only ever show one of them — or worse, add them together and show a number
|
|
1104
|
+
* that is true of neither. Each effective credential profile reads its own
|
|
1105
|
+
* store and gets its own row; a mixed fleet also keeps one row for effective
|
|
1106
|
+
* legacy shared bindings. Instance-private homes are never scanned: their
|
|
1107
|
+
* auth files point at these same logical sources.
|
|
1107
1108
|
*/
|
|
1108
1109
|
export function providersForConfig(config, base = DEFAULT_PROVIDERS) {
|
|
1109
1110
|
return base.flatMap(provider => {
|
|
1110
1111
|
const backendName = usageBackendForProviderId(provider.id);
|
|
1111
1112
|
if (!backendName || !credentialHomeSpec(backendName))
|
|
1112
1113
|
return [provider];
|
|
1113
|
-
const profiles =
|
|
1114
|
+
const profiles = effectiveCredentialProfiles(config, backendName);
|
|
1114
1115
|
if (profiles.length === 0)
|
|
1115
1116
|
return [provider];
|
|
1116
|
-
|
|
1117
|
+
// A fleet can mix profiled and legacy instances. The old expansion
|
|
1118
|
+
// returned only profile rows whenever *any* profile existed, which hid the
|
|
1119
|
+
// shared subscription from a running legacy instance. Include the shared
|
|
1120
|
+
// source only when an effective binding actually uses it; active-provider
|
|
1121
|
+
// filtering then removes sources that are stopped or otherwise unused.
|
|
1122
|
+
const rows = [];
|
|
1123
|
+
// Classic channels are stored separately from fleet.yaml and always use
|
|
1124
|
+
// the shared Codex login today. Keep the shared Codex row available so
|
|
1125
|
+
// active-provider filtering can retain that binding; unlike filesystem
|
|
1126
|
+
// scanning this row is harmless when no classic/shared instance is live.
|
|
1127
|
+
if (provider.id === "codex" || hasEffectiveSharedBinding(config, backendName)) {
|
|
1128
|
+
rows.push({
|
|
1129
|
+
id: provider.id,
|
|
1130
|
+
name: provider.id === "codex" ? `${provider.name} (default)` : provider.name,
|
|
1131
|
+
fetch: () => provider.fetch(),
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
rows.push(...profiles.map(profile => ({
|
|
1117
1135
|
id: `${provider.id}:${profile}`,
|
|
1118
1136
|
name: `${provider.name} (${profile})`,
|
|
1119
1137
|
fetch: () => provider.fetch(credentialProfileStoreHome(getAgendHome(), backendName, profile)),
|
|
1120
|
-
}));
|
|
1138
|
+
})));
|
|
1139
|
+
return rows;
|
|
1121
1140
|
});
|
|
1122
1141
|
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Whether at least one effective fleet binding uses the legacy shared login.
|
|
1144
|
+
*
|
|
1145
|
+
* This deliberately follows effective backend/profile inheritance instead of
|
|
1146
|
+
* looking at directories on disk. Instance-private Codex homes are config
|
|
1147
|
+
* homes whose auth file points at this same shared/profile source; scanning
|
|
1148
|
+
* them would duplicate one quota and make usage depend on stale instance
|
|
1149
|
+
* directories. A missing/unknown config keeps the legacy shared row for
|
|
1150
|
+
* backwards compatibility.
|
|
1151
|
+
*/
|
|
1152
|
+
function hasEffectiveSharedBinding(config, backendName) {
|
|
1153
|
+
if (!config)
|
|
1154
|
+
return true;
|
|
1155
|
+
const defaults = config.defaults;
|
|
1156
|
+
const instances = config.instances ?? {};
|
|
1157
|
+
let sawEffectiveBinding = false;
|
|
1158
|
+
for (const instance of Object.values(instances)) {
|
|
1159
|
+
const backend = instance.backend ?? defaults?.backend;
|
|
1160
|
+
if (backend !== backendName)
|
|
1161
|
+
continue;
|
|
1162
|
+
sawEffectiveBinding = true;
|
|
1163
|
+
if (!instanceCredentialProfile(instance, defaults, backendName))
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
// A defaults-only fleet has one effective shared binding when the default
|
|
1167
|
+
// backend is this provider and no profile was configured. With no known
|
|
1168
|
+
// binding at all, preserve the pre-profile shared-provider behaviour.
|
|
1169
|
+
if (!sawEffectiveBinding) {
|
|
1170
|
+
if (defaults?.backend === backendName && !instanceCredentialProfile(undefined, defaults, backendName))
|
|
1171
|
+
return true;
|
|
1172
|
+
return Object.keys(instances).length === 0;
|
|
1173
|
+
}
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
/** Profiles on effective bindings only (not dormant backend_options on another backend). */
|
|
1177
|
+
function effectiveCredentialProfiles(config, backendName) {
|
|
1178
|
+
if (!config)
|
|
1179
|
+
return [];
|
|
1180
|
+
const found = new Set();
|
|
1181
|
+
const defaults = config.defaults;
|
|
1182
|
+
for (const instance of Object.values(config.instances ?? {})) {
|
|
1183
|
+
const backend = instance.backend ?? defaults?.backend;
|
|
1184
|
+
if (backend !== backendName)
|
|
1185
|
+
continue;
|
|
1186
|
+
const profile = instanceCredentialProfile(instance, defaults, backendName);
|
|
1187
|
+
if (profile)
|
|
1188
|
+
found.add(profile);
|
|
1189
|
+
}
|
|
1190
|
+
if (Object.keys(config.instances ?? {}).length === 0 && defaults?.backend === backendName) {
|
|
1191
|
+
const profile = instanceCredentialProfile(undefined, defaults, backendName);
|
|
1192
|
+
if (profile)
|
|
1193
|
+
found.add(profile);
|
|
1194
|
+
}
|
|
1195
|
+
return [...found].sort();
|
|
1196
|
+
}
|
|
1123
1197
|
/**
|
|
1124
1198
|
* The fleet configuration, for working out which subscriptions exist.
|
|
1125
1199
|
*
|