@mrrlin-dev/external-agents 0.3.2 → 0.3.4

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 (3) hide show
  1. package/lib/dispatch.js +53 -0
  2. package/package.json +1 -1
  3. package/ui.js +79 -48
package/lib/dispatch.js CHANGED
@@ -180,6 +180,59 @@ export function resolveEscalation(registry, sourceAgentId) {
180
180
  * model: "gemini-3.5-flash" # OPTIONAL — falls back to agentEntry.model
181
181
  * output_filename: "generated.md" # OPTIONAL — default "generated.md"
182
182
  */
183
+ // Lightweight credential verifier — a real API round-trip that proves the key
184
+ // works, without the workdir/JSONL overhead of runGenerate. Used by the UI's
185
+ // paste-and-save flow to give the operator immediate feedback ("✓ verified"
186
+ // vs "invalid key — 401") instead of just "env var is set".
187
+ //
188
+ // Returns { ok: true, latencyMs } on 2xx, or { ok: false, status, hint } on
189
+ // anything else. Times out at 10s (verification is a UX detail, not a job).
190
+ export async function verifyCredential(agentEntry) {
191
+ const g = agentEntry.transports?.generate_new;
192
+ if (!g || !g.url) return { ok: false, hint: "no generate_new transport to verify against" };
193
+ const envName = g.env;
194
+ if (envName && envName !== "OLLAMA_UNUSED_KEY") {
195
+ const apiKey = process.env[envName];
196
+ if (!apiKey) return { ok: false, hint: `env var ${envName} not set` };
197
+ }
198
+ const apiKey = envName && envName !== "OLLAMA_UNUSED_KEY" ? process.env[envName] : null;
199
+ const model = g.model || agentEntry.model;
200
+
201
+ const ctrl = new AbortController();
202
+ const timer = setTimeout(() => ctrl.abort(), 10000);
203
+ const start = Date.now();
204
+ try {
205
+ const headers = { "Content-Type": "application/json" };
206
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
207
+ const resp = await fetch(g.url, {
208
+ method: "POST",
209
+ headers,
210
+ body: JSON.stringify({
211
+ model,
212
+ messages: [{ role: "user", content: "ping" }],
213
+ max_tokens: 1,
214
+ stream: false,
215
+ }),
216
+ signal: ctrl.signal,
217
+ });
218
+ const latencyMs = Date.now() - start;
219
+ if (resp.ok) return { ok: true, latencyMs };
220
+ const text = await resp.text().catch(() => "");
221
+ return {
222
+ ok: false,
223
+ status: resp.status,
224
+ hint: resp.status === 401 || resp.status === 403
225
+ ? "invalid API key (server returned " + resp.status + ")"
226
+ : `HTTP ${resp.status}` + (text ? ": " + text.slice(0, 200) : ""),
227
+ latencyMs,
228
+ };
229
+ } catch (e) {
230
+ return { ok: false, hint: e.name === "AbortError" ? "timeout after 10s" : e.message };
231
+ } finally {
232
+ clearTimeout(timer);
233
+ }
234
+ }
235
+
183
236
  export async function runGenerate(agentEntry, prompt, options = {}) {
184
237
  const g = agentEntry.transports?.generate_new;
185
238
  if (!g || typeof g !== "object") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrlin-dev/external-agents",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "One MCP server for every LLM you talk to \u2014 direct-API dispatcher across Gemini, DeepSeek, Groq, OpenRouter, Cerebras, and more. Part of mrrlin.com.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/ui.js CHANGED
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { loadRegistry } from "./lib/registry.js";
8
8
  import { readState, writeState, probeInstalled } from "./lib/state.js";
9
+ import { verifyCredential } from "./lib/dispatch.js";
9
10
 
10
11
  // UI-set persisted keys — MUST use the same file that server.js reads at
11
12
  // startup, otherwise the operator's saved key is invisible on next boot.
@@ -177,7 +178,7 @@ const PAGE = `<!doctype html>
177
178
 
178
179
  <div style="margin-top: 32px; padding: 16px; background: #fff; border: 1px dashed #ccc; border-radius: 4px;">
179
180
  <h3 style="margin: 0 0 4px 0; font-size: 15px;">Missing your model?</h3>
180
- <p style="margin: 0 0 12px 0; color: #666; font-size: 13px;">Suggest a new model or provider &mdash; opens a pre-filled issue on the public tracker at <a href="https://github.com/mrrlin-dev/external-agents/issues" target="_blank" rel="noopener noreferrer">mrrlin-dev/external-agents</a> (also logged locally).</p>
181
+ <p style="margin: 0 0 12px 0; color: #666; font-size: 13px;">Suggest a new model or provider &mdash; opens a pre-filled issue on the public tracker at <a href="https://github.com/mrrlin-dev/external-agents/issues" target="_blank" rel="noopener noreferrer">mrrlin-dev/external-agents</a>.</p>
181
182
  <div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
182
183
  <input id="suggest-name" placeholder="Model or provider name (e.g. anthropic/haiku-4-5)" style="flex: 1; min-width: 260px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font: inherit;">
183
184
  <input id="suggest-url" placeholder="Docs / setup URL (optional)" style="flex: 1; min-width: 260px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font: inherit;">
@@ -224,18 +225,10 @@ async function submitSuggest() {
224
225
  out.textContent = "opening GitHub issue…";
225
226
  out.style.color = "#666";
226
227
 
227
- // 1) Fire-and-forget local JSONL record so 'external-agents ui' still has an
228
- // audit trail even if the user cancels the GitHub tab.
229
- fetch("/api/suggest", {
230
- method: "POST",
231
- headers: { "Content-Type": "application/json" },
232
- body: JSON.stringify({ name, url }),
233
- }).catch(() => { /* non-blocking; the public issue below is the real submit */ });
234
-
235
- // 2) Public path — open a pre-filled New Issue on the external-agents repo.
236
- // The registry maintainer picks it up from the public tracker; anyone else
237
- // watching the repo can see it too, so proposals are discoverable, not stuck
238
- // in a private JSONL.
228
+ // Open a pre-filled New Issue on the public tracker. The registry
229
+ // maintainer picks it up from GitHub; anyone else watching the repo can see
230
+ // it too, so proposals are discoverable and trackable without a local
231
+ // JSONL sidecar (which nobody ever reads).
239
232
  const body = [
240
233
  "**Model / provider:** " + name,
241
234
  "",
@@ -252,8 +245,7 @@ async function submitSuggest() {
252
245
  window.open(issueUrl, "_blank", "noopener,noreferrer");
253
246
 
254
247
  out.innerHTML = "opened a pre-filled GitHub issue in a new tab — " +
255
- "just click <b>Submit new issue</b> there. " +
256
- '(also logged locally as backup)';
248
+ "just click <b>Submit new issue</b> there.";
257
249
  out.style.color = "#4a8";
258
250
  document.getElementById("suggest-name").value = "";
259
251
  document.getElementById("suggest-url").value = "";
@@ -358,9 +350,21 @@ async function saveKey(envName) {
358
350
  });
359
351
  const j = await r.json();
360
352
  if (r.ok) {
361
- stat.textContent = "✓ persisted to " + j.persisted_to + ". Restart your MCP client to pick it up.";
362
- stat.className = "status";
353
+ const v = (j.verified || [])[0] || {};
354
+ const nProbed = (j.reprobed || []).length;
355
+ if (v.ok) {
356
+ const ms = v.latencyMs ? " (" + v.latencyMs + "ms)" : "";
357
+ stat.innerHTML = "<span style=\\"color:#1a6b31;\\">✓ verified" + ms + "</span> — " + nProbed + " model" + (nProbed === 1 ? "" : "s") + " unlocked";
358
+ } else if (v.hint) {
359
+ stat.innerHTML = "<span style=\\"color:#9a2b1c;\\">✗ " + v.hint + "</span> — the key was saved but the provider rejected it";
360
+ } else {
361
+ stat.innerHTML = "<span style=\\"color:#1a6b31;\\">✓ persisted</span> — " + nProbed + " model" + (nProbed === 1 ? "" : "s") + " unlocked";
362
+ }
363
363
  inp.value = "";
364
+ // Immediately refresh the table + banner so freshly-unlocked entries
365
+ // drop from the banner without a manual page reload. Verified failures
366
+ // remain in needs_auth so the banner keeps them visible.
367
+ await refresh();
364
368
  } else {
365
369
  stat.textContent = "error: " + (j.error || r.statusText);
366
370
  stat.className = "status err";
@@ -408,7 +412,7 @@ const server = http.createServer(async (req, res) => {
408
412
  if (req.method === "POST" && p === "/api/set_credential") {
409
413
  let body = "";
410
414
  req.on("data", (c) => { body += c.toString(); });
411
- req.on("end", () => {
415
+ req.on("end", async () => {
412
416
  try {
413
417
  const { env_name, value } = JSON.parse(body || "{}");
414
418
  if (!env_name || typeof env_name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(env_name)) {
@@ -423,11 +427,64 @@ const server = http.createServer(async (req, res) => {
423
427
  // restart to see it. We tell them so in the response.
424
428
  process.env[env_name] = value;
425
429
  console.error(`external-agents ui: credential persisted for ${env_name} (${value.length} chars)`);
430
+ // Re-probe every registry entry that references this env var — either
431
+ // via its `auth: "env:XYZ"` field or its `transports.generate_new.env`.
432
+ // Without this, state.json keeps its stale `needs_auth` marker until
433
+ // the operator restarts the process, and the banner keeps counting
434
+ // just-unlocked entries as still-locked. State + banner reconcile now.
435
+ const affected = REGISTRY.agents.filter((a) => {
436
+ const authVar = typeof a.auth === "string" && a.auth.startsWith("env:")
437
+ ? a.auth.slice("env:".length).split(/\s+/)[0]
438
+ : null;
439
+ const genVar = a.transports?.generate_new?.env || null;
440
+ return authVar === env_name || genVar === env_name;
441
+ });
442
+ // Two-stage state update:
443
+ // 1. probeInstalled — cheap sanity check (env var set, binary present)
444
+ // 2. verifyCredential — REAL API round-trip (~<10s, max_tokens=1)
445
+ // against ONE representative entry per (provider × env_name) so we
446
+ // confirm the pasted key actually works. Verifying every entry that
447
+ // shares the same env var is pointless — they auth the same way.
448
+ const patch = {};
449
+ for (const a of affected) {
450
+ const r = probeInstalled(a);
451
+ patch[a.id] = { ...r, checked: Math.floor(Date.now() / 1000) };
452
+ }
453
+ // Pick one entry per provider to verify (they all share the same key).
454
+ const seenProviders = new Set();
455
+ const toVerify = affected.filter((a) => {
456
+ if (seenProviders.has(a.provider)) return false;
457
+ seenProviders.add(a.provider);
458
+ return a.transports?.generate_new?.url;
459
+ });
460
+ const verifyResults = await Promise.all(toVerify.map(async (a) => {
461
+ const v = await verifyCredential(a);
462
+ return { agent_id: a.id, provider: a.provider, ...v };
463
+ }));
464
+ // Propagate a failed verify to every entry in that provider — mark
465
+ // needs_auth with the failure hint so banner keeps it visible.
466
+ for (const vr of verifyResults) {
467
+ if (!vr.ok) {
468
+ for (const a of affected.filter((x) => x.provider === vr.provider)) {
469
+ patch[a.id] = {
470
+ state: "needs_auth",
471
+ note: `verify failed: ${vr.hint || "unknown"}`,
472
+ checked: Math.floor(Date.now() / 1000),
473
+ };
474
+ }
475
+ }
476
+ }
477
+ if (Object.keys(patch).length > 0) writeState(patch);
478
+ const okCount = verifyResults.filter((v) => v.ok).length;
479
+ const failCount = verifyResults.length - okCount;
480
+ console.error(`external-agents ui: set_credential(${env_name}) — re-probed ${affected.length}, verified ${verifyResults.length} providers (${okCount} ok, ${failCount} failed): ${verifyResults.map((v) => v.provider + "=" + (v.ok ? "ok" : "FAIL:" + v.hint)).join(", ")}`);
426
481
  return json(res, 200, {
427
482
  ok: true,
428
483
  env_name,
429
484
  persisted_to: KEYS_FILE,
430
- restart_required: "Restart your MCP client (Claude Code / Codex) to pick up the new key.",
485
+ reprobed: affected.map((a) => a.id),
486
+ verified: verifyResults,
487
+ restart_required: "Restart your MCP client (Claude Code / Codex) so IT reads keys.env too.",
431
488
  });
432
489
  } catch (e) {
433
490
  return json(res, 400, { error: "invalid json: " + e.message });
@@ -436,35 +493,9 @@ const server = http.createServer(async (req, res) => {
436
493
  return;
437
494
  }
438
495
 
439
- if (req.method === "POST" && p === "/api/suggest") {
440
- let body = "";
441
- req.on("data", (c) => { body += c.toString(); });
442
- req.on("end", () => {
443
- try {
444
- const { name, url: docUrl } = JSON.parse(body || "{}");
445
- if (!name || typeof name !== "string") {
446
- return json(res, 400, { error: "missing 'name'" });
447
- }
448
- // In the standalone spike, we persist locally + log to stderr so operators
449
- // can see the request. Mrrlin consumers wire this endpoint into the
450
- // report-issue mechanism (see ADR 0021 § Consumer-side UX affordances).
451
- const id = "sug-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6);
452
- const entry = { id, ts: new Date().toISOString(), name: name.trim(), url: (docUrl || "").trim() };
453
- try {
454
- const dir = path.join(os.homedir(), ".local/state/external-agents");
455
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
456
- fs.appendFileSync(path.join(dir, "suggestions.jsonl"), JSON.stringify(entry) + "\n", { mode: 0o600 });
457
- } catch (e) {
458
- console.error(`external-agents ui: WARN — could not persist suggestion: ${e.message}`);
459
- }
460
- console.error(`external-agents ui: suggestion recorded → id=${id} name="${entry.name}"${entry.url ? " url=" + entry.url : ""}`);
461
- return json(res, 200, { ok: true, id });
462
- } catch (e) {
463
- return json(res, 400, { error: "invalid json: " + e.message });
464
- }
465
- });
466
- return;
467
- }
496
+ // NB: /api/suggest was removed the "Missing your model?" form now opens
497
+ // a pre-filled GitHub issue on the public tracker directly (no local write
498
+ // needed, and the local JSONL was write-only, never read).
468
499
 
469
500
  if ((req.method === "GET" || req.method === "POST") && p === "/api/probe") {
470
501
  const id = parsed.query.id;