@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.
- package/CHANGELOG.md +348 -0
- package/README.md +12 -11
- package/docs/distributions.md +438 -91
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +131 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +64 -3
- package/src/shared/dev-metrics/emit.ts +31 -1
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +540 -33
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +89 -21
- package/src/ui/server/public/i18n/ja.js +88 -21
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +44 -21
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +10 -2
- package/src/ui/server/public/settings-registry.js +617 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +151 -33
- package/src/ui/server/public/topbar.js +170 -206
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +116 -4
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- 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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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
|
-
"
|
|
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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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);
|