@monster-orange/den 0.4.3 → 0.4.5

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 (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/README.md +12 -11
  3. package/docs/distributions.md +438 -91
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/agents/catalog-runtime.ts +178 -0
  7. package/src/shared/agents/catalog.ts +343 -0
  8. package/src/shared/agents/den-claude.ts +14 -18
  9. package/src/shared/agents/runtime-search.ts +142 -15
  10. package/src/shared/agents/runtime.ts +18 -1
  11. package/src/shared/agents/runtimes.ts +23 -1
  12. package/src/shared/auth/sudo-vault.ts +131 -11
  13. package/src/shared/cli-command.ts +186 -0
  14. package/src/shared/confirm-code.ts +34 -0
  15. package/src/shared/den-config.ts +64 -3
  16. package/src/shared/dev-metrics/emit.ts +31 -1
  17. package/src/shared/edge/config.ts +39 -0
  18. package/src/shared/edge/render.ts +92 -18
  19. package/src/shared/edge/runtime.ts +57 -1
  20. package/src/shared/edge/status.ts +134 -0
  21. package/src/shared/edge/types.ts +11 -1
  22. package/src/shared/environment-manager/health.ts +82 -8
  23. package/src/shared/environment-manager/workspaces.ts +54 -0
  24. package/src/shared/github-app.ts +94 -0
  25. package/src/shared/github-device.ts +286 -0
  26. package/src/shared/health/not-checked.ts +42 -0
  27. package/src/shared/health/rollup.ts +4 -0
  28. package/src/shared/process/spawn-streaming.ts +8 -3
  29. package/src/shared/secrets/onepassword.ts +27 -4
  30. package/src/shared/sessions/client.ts +5 -1
  31. package/src/shared/sessions/host.ts +29 -1
  32. package/src/shared/sessions/protocol.ts +6 -1
  33. package/src/shared/sessions/types.ts +6 -0
  34. package/src/shared/stacks/status.ts +24 -0
  35. package/src/shared/strategies/registry.ts +5 -0
  36. package/src/shared/types/index.ts +6 -0
  37. package/src/shared/web-owner.ts +91 -0
  38. package/src/shared/web-session.ts +135 -1
  39. package/src/ui/cli/agent-ops.ts +210 -19
  40. package/src/ui/cli/agents-command.ts +50 -21
  41. package/src/ui/cli/edge-command.ts +16 -3
  42. package/src/ui/cli/flag-parser.ts +1 -1
  43. package/src/ui/cli/health-display.ts +4 -1
  44. package/src/ui/cli/web-command.ts +512 -33
  45. package/src/ui/mcp/scope.ts +23 -3
  46. package/src/ui/mcp/server.ts +31 -7
  47. package/src/ui/mcp/tools.ts +327 -20
  48. package/src/ui/mcp/views.ts +450 -0
  49. package/src/ui/server/agent-routes.ts +118 -28
  50. package/src/ui/server/device-flows.ts +274 -0
  51. package/src/ui/server/edge-exposure.ts +16 -4
  52. package/src/ui/server/github-routes.ts +321 -0
  53. package/src/ui/server/http-errors.ts +10 -0
  54. package/src/ui/server/index.ts +540 -33
  55. package/src/ui/server/login-waits.ts +203 -0
  56. package/src/ui/server/pair-requests.ts +237 -0
  57. package/src/ui/server/pair-routes.ts +196 -0
  58. package/src/ui/server/public/agent-start.js +46 -7
  59. package/src/ui/server/public/agents-page.js +232 -97
  60. package/src/ui/server/public/agents-section.js +3 -5
  61. package/src/ui/server/public/agents.html +12 -28
  62. package/src/ui/server/public/error-page.js +452 -40
  63. package/src/ui/server/public/error.html +59 -0
  64. package/src/ui/server/public/graph-model.js +2 -0
  65. package/src/ui/server/public/header.js +2 -3
  66. package/src/ui/server/public/health-rollup.js +20 -8
  67. package/src/ui/server/public/i18n/es.js +89 -21
  68. package/src/ui/server/public/i18n/ja.js +88 -21
  69. package/src/ui/server/public/index.html +17 -4
  70. package/src/ui/server/public/local-dev.html +44 -21
  71. package/src/ui/server/public/qr.js +317 -0
  72. package/src/ui/server/public/services.html +21 -5
  73. package/src/ui/server/public/settings-page.js +10 -2
  74. package/src/ui/server/public/settings-registry.js +617 -1
  75. package/src/ui/server/public/settings.html +90 -1
  76. package/src/ui/server/public/shell.js +151 -33
  77. package/src/ui/server/public/topbar.js +170 -206
  78. package/src/ui/server/public/workspace-picker.js +1 -1
  79. package/src/ui/server/readonly-routes.ts +29 -5
  80. package/src/ui/server/request-guard.ts +103 -36
  81. package/src/ui/server/route-classes.ts +116 -4
  82. package/src/ui/server/session-routes.ts +9 -1
  83. package/src/ui/server/sudo-store.ts +170 -6
  84. package/src/ui/server/ui-assets.ts +2 -0
  85. package/src/ui/server/workspace-routes.ts +10 -3
@@ -28,6 +28,7 @@
28
28
  * config.
29
29
  */
30
30
  import { themeNotice, themeOptions } from "./appearance.js";
31
+ import { qrSvg } from "./qr.js";
31
32
 
32
33
  export const DEN_SETTING_CATEGORIES = [
33
34
  {
@@ -64,6 +65,21 @@ export const DEN_SETTING_CATEGORIES = [
64
65
  description:
65
66
  "Settings for one workspace. They follow the Workspace you view in the top bar.",
66
67
  },
68
+ {
69
+ id: "agents",
70
+ label: "Agents",
71
+ order: 55,
72
+ description:
73
+ "The runtimes agents run on, installed first. Paths you set are this machine's only: everywhere (config/local/user.yml) or for one workspace (config/local/workspaces.yml), which wins.",
74
+ },
75
+ {
76
+ // SEC-2a: who may come in from other devices.
77
+ id: "access",
78
+ label: "Access",
79
+ order: 57,
80
+ description:
81
+ "Reach this Den from your other devices: sign in with GitHub as its owner, or pair a device with a one-time link. On localhost nothing asks.",
82
+ },
67
83
  {
68
84
  id: "cli",
69
85
  label: "Command line",
@@ -106,6 +122,386 @@ const distRow = (id, label, description, keywords, value) => ({
106
122
  const effective = (list, chosen) =>
107
123
  chosen ?? (list || []).find((e) => e.available)?.key ?? null;
108
124
 
125
+ // ── Access (SEC-2a) ──────────────────────────────────────────────────────
126
+ // What these rows draw, and the routes they use (github-routes.ts). Pure
127
+ // helpers are exported for tests.
128
+
129
+ function escHtml(v) {
130
+ return String(v ?? "").replace(
131
+ /[&<>"']/g,
132
+ (c) =>
133
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
134
+ c
135
+ ],
136
+ );
137
+ }
138
+
139
+ /** "3 min ago", "2 h ago", "5 d ago", or "just now"; `now` for tests. */
140
+ export function seenAgo(iso, now = Date.now()) {
141
+ const t = Date.parse(iso);
142
+ if (!Number.isFinite(t)) return "";
143
+ const s = Math.max(0, Math.round((now - t) / 1000));
144
+ if (s < 90) return "just now";
145
+ if (s < 5400) return `${Math.round(s / 60)} min ago`;
146
+ if (s < 129600) return `${Math.round(s / 3600)} h ago`;
147
+ return `${Math.round(s / 86400)} d ago`;
148
+ }
149
+
150
+ /**
151
+ * One linked device's line (SEC-2a: every sign-in, whatever the method):
152
+ * its browser and OS, "This device" for this browser, then how it signed
153
+ * in, when, from where (the client address and the browser), and when it
154
+ * was last seen.
155
+ */
156
+ export function deviceLine(d, now = Date.now()) {
157
+ const how =
158
+ d.via === "github"
159
+ ? `GitHub${d.login ? ` (@${d.login})` : ""}`
160
+ : d.via === "code"
161
+ ? "approved with a code"
162
+ : "one-time link";
163
+ const name = d.name || "Unknown browser";
164
+ const from = d.client || d.host;
165
+ return {
166
+ title: name,
167
+ current: !!d.current,
168
+ detail: [
169
+ `signed in with ${how}`,
170
+ `created ${seenAgo(d.createdAt, now)}`,
171
+ from ? `from ${from} (${name})` : "",
172
+ `last seen ${seenAgo(d.lastSeenAt, now)}`,
173
+ ]
174
+ .filter(Boolean)
175
+ .join(" · "),
176
+ };
177
+ }
178
+
179
+ const ROW_STYLE =
180
+ "display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%;padding:6px 0;border-top:1px solid var(--border)";
181
+ const CODE_STYLE =
182
+ "font:22px/1.4 var(--mono);letter-spacing:0.12em;padding:6px 12px;border:1px solid var(--border);border-radius:var(--radius-button);background:var(--sunken)";
183
+
184
+ /** Linking the owner in the page: start, show the code, poll the server. */
185
+ function drawOwner(host, ctx) {
186
+ const api = (m, p, b) => ctx.shell.api(m, p, b);
187
+ let timer = null;
188
+ const draw = (o) => {
189
+ clearTimeout(timer);
190
+ if (o.owner) {
191
+ host.innerHTML =
192
+ `<output class="set-ro" id="set-owner">Owner: <b>@${escHtml(o.owner.login)}</b> <span class="set-note">(${escHtml(o.owner.id)})</span></output> ` +
193
+ '<button type="button" class="btn" id="set-owner-unlink">Unlink</button>';
194
+ host.querySelector("#set-owner-unlink").addEventListener("click", () =>
195
+ api("POST", "/api/web/owner/unlink", {})
196
+ .then(load)
197
+ .catch((e) => fail(e.message)),
198
+ );
199
+ return;
200
+ }
201
+ host.innerHTML =
202
+ '<output class="set-ro" id="set-owner">This Den has no owner identity yet.</output> ' +
203
+ (o.clientId
204
+ ? '<button type="button" class="btn primary" id="set-owner-link">Link your GitHub account</button>'
205
+ : '<span class="set-note" id="set-owner-noclient" style="flex-basis:100%">Linking needs a GitHub App client id, and none is configured: set <code>web.github_client_id</code> in config/local/user.yml, then restart the server.</span>') +
206
+ '<div id="set-owner-flow" style="flex-basis:100%" aria-live="polite"></div>';
207
+ host.querySelector("#set-owner-link")?.addEventListener("click", startLink);
208
+ };
209
+ const fail = (text) => {
210
+ const box = host.querySelector("#set-owner-flow");
211
+ if (box)
212
+ box.innerHTML = `<span class="set-note set-warn" role="alert">${escHtml(text)}</span>`;
213
+ const b = host.querySelector("#set-owner-link");
214
+ if (b) b.disabled = false;
215
+ };
216
+ const poll = (id) =>
217
+ fetch(`/api/web/owner/link/${encodeURIComponent(id)}`, {
218
+ cache: "no-store",
219
+ })
220
+ .then((r) => r.json())
221
+ .then((b) => {
222
+ if (b.state === "pending") {
223
+ timer = setTimeout(() => poll(id), 1500);
224
+ return;
225
+ }
226
+ if (b.state === "ok") return load();
227
+ fail(b.error || "Linking failed. Try again.");
228
+ })
229
+ .catch(() => fail("The server didn't answer. Try again."));
230
+ function startLink() {
231
+ const b = host.querySelector("#set-owner-link");
232
+ if (b) b.disabled = true;
233
+ api("POST", "/api/web/owner/link", {})
234
+ .then((f) => {
235
+ const box = host.querySelector("#set-owner-flow");
236
+ const url = f.verificationUri || "https://github.com/login/device";
237
+ box.innerHTML =
238
+ `<p class="set-note" style="margin:8px 0">Enter this code at <a href="${escHtml(url)}" target="_blank" rel="noopener noreferrer">${escHtml(url.replace(/^https:\/\//, ""))}</a> and approve the app. It asks for no permissions.</p>` +
239
+ `<span id="set-owner-code" style="${CODE_STYLE}">${escHtml(f.userCode)}</span> ` +
240
+ `<a class="btn" href="${escHtml(url)}" target="_blank" rel="noopener noreferrer">Open GitHub</a>` +
241
+ '<p class="set-note" style="margin:8px 0 0">Waiting for GitHub…</p>';
242
+ timer = setTimeout(() => poll(f.id), 1500);
243
+ })
244
+ .catch((e) => fail(e.message));
245
+ }
246
+ function load() {
247
+ fetch("/api/web/owner", { cache: "no-store" })
248
+ .then((r) => r.json())
249
+ .then(draw)
250
+ .catch(() => {
251
+ host.innerHTML =
252
+ '<output class="set-ro">The owner can only be shown signed in.</output>';
253
+ });
254
+ }
255
+ load();
256
+ }
257
+
258
+ function drawDevices(host, ctx) {
259
+ const load = () =>
260
+ fetch("/api/web/sessions", { cache: "no-store" })
261
+ .then((r) =>
262
+ r.ok ? r.json() : Promise.reject(new Error(String(r.status))),
263
+ )
264
+ .then((b) => {
265
+ const list = b.sessions || [];
266
+ if (!list.length) {
267
+ host.innerHTML =
268
+ '<output class="set-ro" id="set-devices">No linked devices. A browser that signs in with GitHub, is approved with a code, or opens a one-time link appears here.</output>';
269
+ return;
270
+ }
271
+ const now = Date.now();
272
+ host.innerHTML =
273
+ '<div id="set-devices" style="width:100%">' +
274
+ list
275
+ .map((d) => {
276
+ const l = deviceLine(d, now);
277
+ return (
278
+ `<div class="set-device" data-id="${escHtml(d.id)}" style="${ROW_STYLE}">` +
279
+ `<div style="flex:1 1 220px;min-width:0"><div>${escHtml(l.title)}` +
280
+ (l.current
281
+ ? ' <b class="set-this-device" style="font-weight:600">· This device</b>'
282
+ : "") +
283
+ `</div>` +
284
+ `<div class="set-note" style="overflow-wrap:anywhere">${escHtml(l.detail)}</div></div>` +
285
+ `<button type="button" class="btn" data-revoke="${escHtml(d.id)}">Revoke</button></div>`
286
+ );
287
+ })
288
+ .join("") +
289
+ "</div>";
290
+ host.querySelectorAll("[data-revoke]").forEach((btn) =>
291
+ btn.addEventListener("click", () => {
292
+ btn.disabled = true;
293
+ ctx.shell
294
+ .api("POST", "/api/web/logout", { id: btn.dataset.revoke })
295
+ .then(load)
296
+ .catch(() => {
297
+ btn.disabled = false;
298
+ });
299
+ }),
300
+ );
301
+ })
302
+ .catch(() => {
303
+ host.innerHTML =
304
+ '<output class="set-ro">The devices can only be listed signed in.</output>';
305
+ });
306
+ load();
307
+ }
308
+
309
+ /** One waiting device's line: its code, browser and OS, address, age. */
310
+ export function waitingLine(w, now = Date.now()) {
311
+ return {
312
+ title: `${w.code} · ${w.device || "Unknown browser"}`,
313
+ detail: [w.client, `asked ${seenAgo(w.createdAt, now)}`]
314
+ .filter(Boolean)
315
+ .join(" · "),
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Waiting devices (SEC-2a): the computers whose signed-out page shows a
321
+ * code, with Approve and Deny. Approving signs that page in by itself.
322
+ */
323
+ function drawWaiting(host, ctx) {
324
+ let timer = null;
325
+ const load = () => {
326
+ clearTimeout(timer);
327
+ if (!host.isConnected && timer !== null) return;
328
+ fetch("/api/web/waiting", { cache: "no-store" })
329
+ .then((r) =>
330
+ r.ok ? r.json() : Promise.reject(new Error(String(r.status))),
331
+ )
332
+ .then((b) => {
333
+ const list = b.waiting || [];
334
+ const now = Date.now();
335
+ host.innerHTML = list.length
336
+ ? '<div id="set-waiting" style="width:100%">' +
337
+ list
338
+ .map((w) => {
339
+ const l = waitingLine(w, now);
340
+ return (
341
+ `<div class="set-waiting" data-code="${escHtml(w.code)}" style="${ROW_STYLE}">` +
342
+ `<div style="flex:1 1 220px;min-width:0"><div class="mono">${escHtml(l.title)}</div>` +
343
+ `<div class="set-note" style="overflow-wrap:anywhere">${escHtml(l.detail)}</div></div>` +
344
+ `<button type="button" class="btn primary" data-approve="${escHtml(w.code)}">Approve</button>` +
345
+ `<button type="button" class="btn" data-deny="${escHtml(w.code)}">Deny</button></div>`
346
+ );
347
+ })
348
+ .join("") +
349
+ '</div><div id="set-waiting-out" style="flex-basis:100%" aria-live="polite"></div>'
350
+ : '<output class="set-ro" id="set-waiting">No computers are waiting. A signed-out page on another computer shows a code; it appears here to approve.</output>';
351
+ const act = (btn, path, code) => {
352
+ btn.disabled = true;
353
+ ctx.shell
354
+ .api("POST", path, { code })
355
+ .then(load)
356
+ .catch((e) => {
357
+ btn.disabled = false;
358
+ const out = host.querySelector("#set-waiting-out");
359
+ if (out)
360
+ out.innerHTML = `<span class="set-note set-warn" role="alert">${escHtml(e.message)}</span>`;
361
+ });
362
+ };
363
+ host
364
+ .querySelectorAll("[data-approve]")
365
+ .forEach((b) =>
366
+ b.addEventListener("click", () =>
367
+ act(b, "/api/web/approve", b.dataset.approve),
368
+ ),
369
+ );
370
+ host
371
+ .querySelectorAll("[data-deny]")
372
+ .forEach((b) =>
373
+ b.addEventListener("click", () =>
374
+ act(b, "/api/web/deny", b.dataset.deny),
375
+ ),
376
+ );
377
+ // A new request shows up without a reload.
378
+ timer = setTimeout(load, 5000);
379
+ })
380
+ .catch(() => {
381
+ host.innerHTML =
382
+ '<output class="set-ro">Waiting devices can only be listed signed in, or on this machine.</output>';
383
+ });
384
+ };
385
+ load();
386
+ }
387
+
388
+ function drawPair(host, ctx) {
389
+ let timer = null;
390
+ fetch("/api/web/pair", { cache: "no-store" })
391
+ .then((r) => r.json())
392
+ .then((b) => {
393
+ const hosts = b.hosts || [];
394
+ if (!hosts.length) {
395
+ host.innerHTML =
396
+ '<output class="set-ro" id="set-pair">No edge hostname routes to this server, so there is no address another device could open. Add one to the config\'s edge block (edge.aliases for a VPN name), then <code>__DEN_CLI__ edge up</code>.</output>';
397
+ return;
398
+ }
399
+ host.innerHTML =
400
+ '<select id="set-pair-host" aria-label="Edge hostname">' +
401
+ hosts
402
+ .map((h) => `<option value="${escHtml(h)}">${escHtml(h)}</option>`)
403
+ .join("") +
404
+ "</select> " +
405
+ '<button type="button" class="btn primary" id="set-pair-go">Pair a device</button>' +
406
+ '<div id="set-pair-out" style="flex-basis:100%" aria-live="polite"></div>';
407
+ const out = host.querySelector("#set-pair-out");
408
+ const go = () => {
409
+ clearTimeout(timer);
410
+ ctx.shell
411
+ .api("POST", "/api/web/pair", {
412
+ host: host.querySelector("#set-pair-host").value,
413
+ })
414
+ .then((p) => {
415
+ out.innerHTML =
416
+ `<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;margin-top:8px">` +
417
+ `<div id="set-pair-qr" style="line-height:0">${qrSvg(p.url, { scale: 4, margin: 4 })}</div>` +
418
+ `<div style="flex:1 1 220px;min-width:0"><p class="set-note" style="margin:0 0 8px">Scan it with the other device, or open the link there. It works once, within 60 seconds, and signs that browser in for good.</p>` +
419
+ `<a id="set-pair-link" href="${escHtml(p.url)}" style="font:12px var(--mono);overflow-wrap:anywhere">${escHtml(p.url)}</a>` +
420
+ `<p class="set-note" id="set-pair-left" style="margin:8px 0 0"></p></div></div>`;
421
+ const until = Date.parse(p.expiresAt);
422
+ const left = out.querySelector("#set-pair-left");
423
+ const tick = () => {
424
+ const s = Math.max(0, Math.round((until - Date.now()) / 1000));
425
+ left.textContent = s
426
+ ? `Expires in ${s} s.`
427
+ : "This link expired. Pair again for a new one.";
428
+ if (s) timer = setTimeout(tick, 1000);
429
+ };
430
+ tick();
431
+ })
432
+ .catch((e) => {
433
+ out.innerHTML = `<span class="set-note set-warn" role="alert">${escHtml(e.message)}</span>`;
434
+ });
435
+ };
436
+ host.querySelector("#set-pair-go").addEventListener("click", go);
437
+ })
438
+ .catch(() => {
439
+ host.innerHTML =
440
+ '<output class="set-ro">Pairing works only signed in.</output>';
441
+ });
442
+ }
443
+
444
+ const ACCESS_ROWS = [
445
+ {
446
+ id: "access.owner",
447
+ category: "access",
448
+ label: "Owner",
449
+ description:
450
+ "The GitHub account this Den belongs to. Linking stores only its id (the token is thrown away), and from then on that account can sign in from any device. __DEN_CLI__ web owner link does the same from the command line.",
451
+ keywords: ["owner", "github", "identity", "link", "account"],
452
+ scope: "user",
453
+ control: { type: "custom", render: drawOwner },
454
+ read: () => null,
455
+ },
456
+ {
457
+ id: "access.devices",
458
+ category: "access",
459
+ label: "Linked devices",
460
+ description:
461
+ "The browsers signed in to this Den. Revoke ends one at once. __DEN_CLI__ web logout --all ends them all.",
462
+ keywords: ["devices", "sessions", "revoke", "browsers", "signed in"],
463
+ scope: "user",
464
+ control: { type: "custom", render: drawDevices },
465
+ read: () => null,
466
+ },
467
+ {
468
+ id: "access.waiting",
469
+ category: "access",
470
+ label: "Waiting devices",
471
+ description:
472
+ "Computers whose signed-out page shows a code. Approve one and its page signs in by itself; the code lasts 10 minutes. __DEN_CLI__ web approve <code> does the same from the command line.",
473
+ keywords: ["waiting", "approve", "code", "pair", "device", "deny"],
474
+ scope: "user",
475
+ control: { type: "custom", render: drawWaiting },
476
+ read: () => null,
477
+ },
478
+ {
479
+ id: "access.machine_name",
480
+ category: "access",
481
+ label: "Machine name",
482
+ description:
483
+ 'What the signed-out page calls this machine ("runs on <name>"), so people on other computers know where to run a command. Empty: its short hostname.',
484
+ keywords: ["machine", "name", "hostname", "host", "signed out"],
485
+ scope: "user",
486
+ control: { type: "text" },
487
+ keys: ["machine_name"],
488
+ read: (ctx) => ctx.data.machine_name ?? "",
489
+ write: (v, ctx) =>
490
+ ctx.post({ machine_name: String(v ?? "").trim() || null }),
491
+ },
492
+ {
493
+ id: "access.pair",
494
+ category: "access",
495
+ label: "Pair a device",
496
+ description:
497
+ "Without GitHub, or offline: a one-time link and QR code for one edge hostname (__DEN_CLI__ web login --host <name>).",
498
+ keywords: ["pair", "qr", "device", "link", "phone", "edge"],
499
+ scope: "user",
500
+ control: { type: "custom", render: drawPair },
501
+ read: () => null,
502
+ },
503
+ ];
504
+
109
505
  export const DEN_SETTINGS = [
110
506
  // ── Appearance ─────────────────────────────────────────────────────────
111
507
  {
@@ -416,6 +812,29 @@ export const DEN_SETTINGS = [
416
812
  write: (v, ctx) => ctx.saveWorkspace({ editor: v || null }),
417
813
  },
418
814
 
815
+ // ── Agents ─────────────────────────────────────────────────────────────
816
+ {
817
+ id: "agents.runtimes",
818
+ category: "agents",
819
+ label: "Runtimes",
820
+ description:
821
+ "Each runtime's status and version, the path in use and where it came from. Set path… runs its version command first and refuses a path that isn't that runtime.",
822
+ keywords: [
823
+ "runtime",
824
+ "claude",
825
+ "codex",
826
+ "gemini",
827
+ "grok",
828
+ "path",
829
+ "bin",
830
+ "cli",
831
+ "agent",
832
+ ],
833
+ scope: "user",
834
+ control: { type: "custom", render: renderRuntimes },
835
+ read: () => null,
836
+ },
837
+
419
838
  // ── Command line ───────────────────────────────────────────────────────
420
839
  {
421
840
  id: "cli.local_dev",
@@ -472,7 +891,7 @@ export const DEN_SETTINGS = [
472
891
  category: "cli",
473
892
  label: "Browser sign-in",
474
893
  description:
475
- "Signing in is needed only when this server is reached from another machine (through the edge, or a LAN address); on localhost, and through the edge from this machine, nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
894
+ "On localhost nothing asks for a sign-in. Through the edge, a browser signs in unless the edge shows it is this machine (web.edge_unattested: local counts one it can't place as local), and from another machine it always does: Sign in with GitHub, or a one-time link (__DEN_CLI__ web login). Settings › Access lists the signed-in browsers.",
476
895
  keywords: ["sign in", "sign out", "login", "logout", "session", "browser"],
477
896
  scope: "user",
478
897
  control: {
@@ -498,6 +917,55 @@ export const DEN_SETTINGS = [
498
917
  },
499
918
  read: () => null,
500
919
  },
920
+ {
921
+ // R8, LK-0: the admin password is for sudo, and nothing else. Den asks
922
+ // for it only when a command it runs needs sudo; here is whether it is
923
+ // saved, and Forget. Reading never wakes the vault.
924
+ id: "cli.sudo",
925
+ category: "cli",
926
+ label: "Admin password",
927
+ description:
928
+ "Used only when a command Den runs needs sudo, and asked for only then. It is no sign-in: nothing else needs it.",
929
+ keywords: ["admin", "password", "sudo", "forget", "vault", "1password"],
930
+ scope: "user",
931
+ control: {
932
+ type: "custom",
933
+ render(host, ctx) {
934
+ const draw = (st) => {
935
+ const saved = st?.saved;
936
+ const where =
937
+ saved === "vault"
938
+ ? `Saved in ${st.vault?.label || "the vault"}`
939
+ : saved === "session"
940
+ ? "Saved for this session"
941
+ : "Not saved";
942
+ host.innerHTML =
943
+ '<output class="set-ro" id="set-sudo"></output>' +
944
+ (saved
945
+ ? ' <button type="button" class="btn" id="set-sudo-forget">Forget</button>'
946
+ : "");
947
+ host.querySelector("output").textContent = where;
948
+ host
949
+ .querySelector("#set-sudo-forget")
950
+ ?.addEventListener("click", () =>
951
+ ctx.shell
952
+ .api("POST", "/api/sudo-password/forget", {})
953
+ .then(load)
954
+ .catch(load),
955
+ );
956
+ };
957
+ const load = () =>
958
+ fetch("/api/sudo-password/status", { cache: "no-store" })
959
+ .then((r) => (r.ok ? r.json() : null))
960
+ .then(draw)
961
+ .catch(() => draw(null));
962
+ void load();
963
+ },
964
+ },
965
+ read: () => null,
966
+ },
967
+
968
+ ...ACCESS_ROWS,
501
969
 
502
970
  // ── Distribution (read-only) ───────────────────────────────────────────
503
971
  distRow(
@@ -547,6 +1015,154 @@ export const DEN_SETTINGS = [
547
1015
  ),
548
1016
  ];
549
1017
 
1018
+ // ── Settings › Agents › Runtimes (AG-0.5) ────────────────────────────────
1019
+
1020
+ const escR = (s) =>
1021
+ String(s ?? "").replace(
1022
+ /[&<>"']/g,
1023
+ (c) =>
1024
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
1025
+ c
1026
+ ],
1027
+ );
1028
+
1029
+ /** Where a runtime's path came from, as the row says it. */
1030
+ export const RUNTIME_SOURCE = {
1031
+ workspace: "workspace",
1032
+ setting: "global",
1033
+ "login-shell": "detected · login shell",
1034
+ PATH: "detected · PATH",
1035
+ "well-known": "detected · install directory",
1036
+ "interactive-shell": "detected · interactive shell",
1037
+ };
1038
+
1039
+ /** One runtime's row (r: GET /api/agents/runtimes' entry). */
1040
+ export function runtimeRowHtml(r, scopeWs) {
1041
+ const id = escR(r.id);
1042
+ const name =
1043
+ `<b>${escR(r.label || r.id)}</b>` +
1044
+ (r.vendor ? ` <span class="rt-v">${escR(r.vendor)}</span>` : "") +
1045
+ (r.denTools === false
1046
+ ? ` <span class="set-tag" data-den-tools="coming">Den tools: coming</span>`
1047
+ : "");
1048
+ const set = r.source === "workspace" || r.source === "setting";
1049
+ const status = r.available
1050
+ ? `<span class="rt-ok">✓ ${escR(r.version ? r.version : "installed")}</span>`
1051
+ : `<span class="rt-bad">${escR(set ? r.why : "Not installed")}</span>`;
1052
+ const where = r.path
1053
+ ? `<span class="den-mono rt-p">${escR(r.path)}</span> <span class="set-tag" data-src="${escR(r.source || "")}">${escR(RUNTIME_SOURCE[r.source] || r.source || "")}</span>`
1054
+ : set
1055
+ ? `<span class="set-tag" data-src="${escR(r.source)}">${escR(RUNTIME_SOURCE[r.source])}</span>`
1056
+ : "";
1057
+ // A path set at the scope shown can be cleared from here.
1058
+ const mine = scopeWs ? r.source === "workspace" : r.source === "setting";
1059
+ return (
1060
+ `<div class="rt" data-runtime="${id}" data-available="${r.available ? "true" : "false"}">` +
1061
+ `<div class="rt-n">${name}</div>` +
1062
+ `<div class="rt-s">${status}</div>` +
1063
+ `<div class="rt-w">${where}</div>` +
1064
+ `<div class="rt-a"><button type="button" class="btn sm" data-set-path="${id}">Set path…</button>` +
1065
+ (mine
1066
+ ? ` <button type="button" class="btn sm" data-clear-path="${id}">Clear</button>`
1067
+ : "") +
1068
+ `</div>` +
1069
+ `<form class="rt-f" data-path-form="${id}" hidden>` +
1070
+ `<label class="rt-fl" for="rt-in-${id}">Path to ${escR(r.label || r.id)}${scopeWs ? ` for ${escR(scopeWs)}` : " (every workspace)"}</label>` +
1071
+ `<input type="text" id="rt-in-${id}" class="den-mono" spellcheck="false" autocomplete="off" placeholder="/absolute/path/to/${escR(r.id === "den-claude" ? "claude" : r.id)}" value="${escR(mine ? r.path || "" : "")}">` +
1072
+ `<button type="submit" class="btn sm primary">Save</button> <button type="button" class="btn sm" data-cancel-path>Cancel</button>` +
1073
+ `<div class="rt-err" role="alert" hidden></div></form>` +
1074
+ `</div>`
1075
+ );
1076
+ }
1077
+
1078
+ /** The Runtimes row: the list, a workspace selector, Set path… and Clear. */
1079
+ function renderRuntimes(host, ctx) {
1080
+ const wss = ctx.workspaces || [];
1081
+ let ws = "";
1082
+ const draw = (rows, note) => {
1083
+ host.innerHTML =
1084
+ `<div class="rt-scope"><label for="rt-ws">Paths for</label> <select id="rt-ws" data-focus-key="rt-ws">` +
1085
+ `<option value="">every workspace (user.yml)</option>` +
1086
+ wss
1087
+ .map(
1088
+ (w) =>
1089
+ `<option value="${escR(w)}"${w === ws ? " selected" : ""}>${escR(w)} (workspaces.yml)</option>`,
1090
+ )
1091
+ .join("") +
1092
+ `</select></div>` +
1093
+ (note ? `<div class="set-note">${escR(note)}</div>` : "") +
1094
+ `<div class="rt-list" role="list">${rows.map((r) => runtimeRowHtml(r, ws)).join("")}</div>`;
1095
+ };
1096
+ const load = () => {
1097
+ // Under an invalid config the runtimes answer 422: say why, ask nothing.
1098
+ if (ctx.shell?.configInvalid?.() === true)
1099
+ return draw([], "Fix the config first: the runtimes are read from it.");
1100
+ return ctx.shell
1101
+ .api(
1102
+ "GET",
1103
+ `/api/agents/runtimes${ws ? `?workspace=${encodeURIComponent(ws)}` : ""}`,
1104
+ )
1105
+ .then((b) =>
1106
+ // Only what has a CLI to find: generic is the persona's argv.
1107
+ draw((b?.runtimes || []).filter((r) => r.setting)),
1108
+ )
1109
+ .catch((e) => draw([], e?.message || "The runtimes did not load."));
1110
+ };
1111
+ host.addEventListener("change", (e) => {
1112
+ if (e.target.id !== "rt-ws") return;
1113
+ ws = e.target.value;
1114
+ void load();
1115
+ });
1116
+ host.addEventListener("click", async (e) => {
1117
+ const b = e.target.closest("button");
1118
+ if (!b) return;
1119
+ const row = b.closest(".rt");
1120
+ const form = row?.querySelector(".rt-f");
1121
+ if (b.dataset.setPath && form) {
1122
+ form.hidden = false;
1123
+ form.querySelector("input").focus();
1124
+ } else if (b.hasAttribute("data-cancel-path") && form) {
1125
+ form.hidden = true;
1126
+ } else if (b.dataset.clearPath) {
1127
+ await savePath(b.dataset.clearPath, null, form);
1128
+ }
1129
+ });
1130
+ host.addEventListener("submit", async (e) => {
1131
+ e.preventDefault();
1132
+ const form = e.target.closest(".rt-f");
1133
+ if (!form) return;
1134
+ const bin = form.querySelector("input").value.trim();
1135
+ if (!bin) return;
1136
+ await savePath(form.dataset.pathForm, bin, form);
1137
+ });
1138
+ async function savePath(id, bin, form) {
1139
+ const err = form?.querySelector(".rt-err");
1140
+ const btns = form ? [...form.querySelectorAll("button")] : [];
1141
+ btns.forEach((x) => (x.disabled = true));
1142
+ try {
1143
+ await ctx.shell.api(
1144
+ "POST",
1145
+ `/api/agents/runtimes/${encodeURIComponent(id)}/path`,
1146
+ {
1147
+ scope: ws ? "workspace" : "global",
1148
+ ...(ws ? { workspace: ws } : {}),
1149
+ bin,
1150
+ },
1151
+ );
1152
+ await load();
1153
+ } catch (x) {
1154
+ // Its refusal is shown here, under the field, not as a toast.
1155
+ if (err) {
1156
+ err.textContent = x?.message || "Not saved";
1157
+ err.hidden = false;
1158
+ }
1159
+ } finally {
1160
+ btns.forEach((x) => (x.disabled = false));
1161
+ }
1162
+ }
1163
+ void load();
1164
+ }
1165
+
550
1166
  /** Register Den's categories and rows with the shell's SETTINGS registry. */
551
1167
  export function registerDenSettings(shell) {
552
1168
  for (const c of DEN_SETTING_CATEGORIES) shell.registerSettingsCategory(c);