@monster-orange/den 0.4.2 → 0.4.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.
- package/CHANGELOG.md +273 -0
- package/README.md +9 -9
- package/docs/distributions.md +313 -50
- 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 +53 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +543 -0
- package/src/shared/agents/runtime.ts +43 -1
- package/src/shared/agents/runtimes.ts +53 -4
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +92 -2
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -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/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/ui/cli/agent-ops.ts +298 -19
- package/src/ui/cli/agents-command.ts +129 -4
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- 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 +123 -26
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +171 -25
- package/src/ui/server/public/agent-start.js +241 -0
- package/src/ui/server/public/agents-page.js +246 -45
- package/src/ui/server/public/agents-section.js +7 -21
- package/src/ui/server/public/agents.html +55 -1
- 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 +26 -13
- package/src/ui/server/public/i18n/ja.js +25 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +62 -22
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +3 -2
- package/src/ui/server/public/settings-registry.js +226 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +67 -22
- package/src/ui/server/public/topbar.js +217 -158
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +123 -8
- package/src/ui/server/route-classes.ts +14 -2
- 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
|
@@ -11,13 +11,24 @@
|
|
|
11
11
|
* shell.js's denShell.runs, dev.30), where every page's
|
|
12
12
|
* output lands; an empty dock links to the page that ran
|
|
13
13
|
* the last run
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* sudo (R8, LK-0) #den-lock: shown only while a run waits for the admin
|
|
15
|
+
* password because a command it runs needs sudo; it
|
|
16
|
+
* opens the password prompt (in the top layer)
|
|
16
17
|
*
|
|
17
|
-
* Exports for pages (through header.js):
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* Exports for pages (through header.js): askSudo(need) (the prompt for a
|
|
19
|
+
* run's needs-sudo event: true once the password is set), sudoRun() (a
|
|
20
|
+
* run's needs-sudo, then the action again after the password),
|
|
21
|
+
* unlockWith(password), setServiceRunner(fn) (the page that runs Start
|
|
22
|
+
* and Restart itself), takePendingAction().
|
|
23
|
+
*
|
|
24
|
+
* The admin password is for sudo, and nothing else (LK-0): no action asks
|
|
25
|
+
* for it up front, and it is no access check. The server says when a
|
|
26
|
+
* command is about to run sudo without it; then the page asks the server
|
|
27
|
+
* again first (another tab or device may have set it, LK-2), then, when a
|
|
28
|
+
* password is stored in the vault, loads it from there (LK-3; a page view
|
|
29
|
+
* alone never wakes the vault), and only then shows the field. The prompt
|
|
30
|
+
* is in the top layer (a popover, or its own modal <dialog> when a modal
|
|
31
|
+
* is open), so no modal ever covers it (LK-1).
|
|
21
32
|
*
|
|
22
33
|
* The pure parts (statusSummary, statusChip, problemCards, drawerGroups,
|
|
23
34
|
* checkLine, rankCommands, runOf) take plain data, so tests need no
|
|
@@ -409,7 +420,6 @@ export function runOf(url, base = "http://localhost/") {
|
|
|
409
420
|
// ── the UI ──────────────────────────────────────────────────────────────────
|
|
410
421
|
|
|
411
422
|
const LS_LAST_RUN = "__DEN_CLI__-last-run";
|
|
412
|
-
const LS_LOCK = "__DEN_CLI__-lock";
|
|
413
423
|
const SS_PENDING = "__DEN_CLI__-pending-action";
|
|
414
424
|
const KIND = ["page", "service", "app", "agent", "action", "setting"];
|
|
415
425
|
const kindLabel = (k) =>
|
|
@@ -729,7 +739,7 @@ let serviceRunner = null;
|
|
|
729
739
|
|
|
730
740
|
/**
|
|
731
741
|
* The page that runs a service command itself (Local Dev: its runCmd, with
|
|
732
|
-
* its
|
|
742
|
+
* its output pane) registers `fn(cmd, svc)`; `svc` is
|
|
733
743
|
* `{ repo, service, address, workspaces }`.
|
|
734
744
|
*/
|
|
735
745
|
export function setServiceRunner(fn) {
|
|
@@ -738,16 +748,15 @@ export function setServiceRunner(fn) {
|
|
|
738
748
|
|
|
739
749
|
/**
|
|
740
750
|
* Start (`up`) or Restart from the drawer or the palette: an explicit
|
|
741
|
-
* action. The page's runner does it where there is one. Elsewhere the
|
|
742
|
-
*
|
|
743
|
-
*
|
|
751
|
+
* action. The page's runner does it where there is one. Elsewhere the
|
|
752
|
+
* action is handed to Local Dev, where the command runs with its output
|
|
753
|
+
* (and asks for the admin password only if it runs sudo, LK-0) (a run stops when its page
|
|
744
754
|
* closes, so it can't run on a page that is about to be left): the page
|
|
745
755
|
* stores it for this tab (sessionStorage, not a link anyone could open)
|
|
746
756
|
* and Local Dev takes it once (takePendingAction).
|
|
747
757
|
*/
|
|
748
758
|
export function runServiceAction(cmd, svc) {
|
|
749
759
|
if (serviceRunner) return serviceRunner(cmd, svc);
|
|
750
|
-
if (!requireUnlock()) return;
|
|
751
760
|
try {
|
|
752
761
|
sessionStorage.setItem(
|
|
753
762
|
SS_PENDING,
|
|
@@ -1110,70 +1119,144 @@ function trackRuns() {
|
|
|
1110
1119
|
window.EventSource = DenEventSource;
|
|
1111
1120
|
}
|
|
1112
1121
|
|
|
1113
|
-
// ──
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1122
|
+
// ── the admin password, for sudo (R8, LK-0) ───────────────────────────────
|
|
1123
|
+
|
|
1124
|
+
/*
|
|
1125
|
+
* LK-0 (Ward, 2026-11-06): the admin password is for sudo, and nothing
|
|
1126
|
+
* else. Nothing asks for it up front: a run that is about to run sudo
|
|
1127
|
+
* without it streams a `needs-sudo` event (the server decides), and only
|
|
1128
|
+
* then does the page ask, saying "<command> needs your admin password".
|
|
1129
|
+
* The chip (#den-lock) shows only while such an ask is waiting. Asking
|
|
1130
|
+
* goes: the server first (another tab or device may have set it, LK-2),
|
|
1131
|
+
* then the vault when a password is stored there (LK-3, Touch ID), then
|
|
1132
|
+
* the field, in the top layer (LK-1).
|
|
1133
|
+
*/
|
|
1134
|
+
const sudo = {
|
|
1119
1135
|
vault: {
|
|
1120
1136
|
backend: "none",
|
|
1121
1137
|
label: "",
|
|
1122
1138
|
backendAvailable: false,
|
|
1123
1139
|
hasStored: false,
|
|
1124
1140
|
},
|
|
1125
|
-
|
|
1141
|
+
/** The ask that is waiting: { need, promise, resolve }, or null. */
|
|
1142
|
+
waiting: null,
|
|
1126
1143
|
};
|
|
1127
1144
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1145
|
+
/**
|
|
1146
|
+
* Set the admin password (the prompt's form): { ok, savedToVault?,
|
|
1147
|
+
* vaultError?, error? }. Never logs the password.
|
|
1148
|
+
*/
|
|
1149
|
+
export async function unlockWith(password, { saveToVault = false } = {}) {
|
|
1150
|
+
if (!password) return { ok: false, error: t("lock.wrong") };
|
|
1151
|
+
try {
|
|
1152
|
+
const r = await fetch("/api/sudo-password", {
|
|
1153
|
+
method: "POST",
|
|
1154
|
+
headers: { "Content-Type": "application/json" },
|
|
1155
|
+
body: JSON.stringify({ password, saveToVault }),
|
|
1156
|
+
});
|
|
1157
|
+
const data = await r.json().catch(() => ({}));
|
|
1158
|
+
if (r.ok) {
|
|
1159
|
+
if (data.savedToVault) sudo.vault.hasStored = true;
|
|
1160
|
+
return {
|
|
1161
|
+
ok: true,
|
|
1162
|
+
savedToVault: !!data.savedToVault,
|
|
1163
|
+
...(data.vaultError ? { vaultError: data.vaultError } : {}),
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
// A wrong password is said in the page's language; another refusal
|
|
1167
|
+
// is the server's own message.
|
|
1168
|
+
return {
|
|
1169
|
+
ok: false,
|
|
1170
|
+
error: r.status === 401 || !data.error ? t("lock.wrong") : data.error,
|
|
1171
|
+
};
|
|
1172
|
+
} catch {
|
|
1173
|
+
return { ok: false, error: t("lock.reqFailed") };
|
|
1174
|
+
}
|
|
1130
1175
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
return
|
|
1176
|
+
|
|
1177
|
+
/** What needed sudo, for the prompt: the command, else the step. */
|
|
1178
|
+
function needWhat(need) {
|
|
1179
|
+
return need?.command || need?.step || t("lock.cmd");
|
|
1135
1180
|
}
|
|
1136
1181
|
|
|
1137
1182
|
/**
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1183
|
+
* The run's server said sudo needs the password (`need` is its needs-sudo
|
|
1184
|
+
* event: { command?, step? }). Resolves true once the password is set
|
|
1185
|
+
* (from another tab, the vault or the field), false when the person
|
|
1186
|
+
* closes the prompt. One ask at a time: a second need while one waits
|
|
1187
|
+
* shares it.
|
|
1140
1188
|
*/
|
|
1141
|
-
export function
|
|
1142
|
-
if (
|
|
1143
|
-
|
|
1144
|
-
|
|
1189
|
+
export function askSudo(need = {}) {
|
|
1190
|
+
if (sudo.waiting) return sudo.waiting.promise;
|
|
1191
|
+
let resolve;
|
|
1192
|
+
const promise = new Promise((r) => (resolve = r));
|
|
1193
|
+
sudo.waiting = { need, promise, resolve };
|
|
1194
|
+
paintLock();
|
|
1195
|
+
void (async () => {
|
|
1196
|
+
const st = await getJson("/api/sudo-password/status", null);
|
|
1197
|
+
if (st?.set) return settleSudo(true);
|
|
1198
|
+
await checkVault();
|
|
1199
|
+
if (sudo.vault.backendAvailable && sudo.vault.hasStored) {
|
|
1200
|
+
const loaded = await getJson(
|
|
1201
|
+
"/api/sudo-password/load-from-vault",
|
|
1202
|
+
{ ok: false },
|
|
1203
|
+
{ method: "POST" },
|
|
1204
|
+
);
|
|
1205
|
+
if (loaded.ok) return settleSudo(true);
|
|
1206
|
+
}
|
|
1207
|
+
if (sudo.waiting) openLock();
|
|
1208
|
+
})();
|
|
1209
|
+
return promise;
|
|
1145
1210
|
}
|
|
1146
1211
|
|
|
1147
|
-
function
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
lock
|
|
1152
|
-
try {
|
|
1153
|
-
sessionStorage.setItem(LS_LOCK, unlocked ? "1" : "0");
|
|
1154
|
-
} catch {
|
|
1155
|
-
/* a convenience only */
|
|
1156
|
-
}
|
|
1212
|
+
function settleSudo(ok) {
|
|
1213
|
+
const w = sudo.waiting;
|
|
1214
|
+
if (!w) return;
|
|
1215
|
+
sudo.waiting = null;
|
|
1216
|
+
if (overlay?.kind === "lock") closeOverlay(false);
|
|
1157
1217
|
paintLock();
|
|
1158
|
-
|
|
1159
|
-
|
|
1218
|
+
w.resolve(ok);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* For a page's run (an EventSource): `see(msg)` on every message (true
|
|
1223
|
+
* for a needs-sudo one, which starts the ask), and `done(retry)` when the
|
|
1224
|
+
* run ends: after the password, the action runs again (resolves to what
|
|
1225
|
+
* `retry()` returns; undefined when nothing needed sudo, or the person
|
|
1226
|
+
* said no).
|
|
1227
|
+
*/
|
|
1228
|
+
export function sudoRun() {
|
|
1229
|
+
let asked = null;
|
|
1230
|
+
return {
|
|
1231
|
+
see(msg) {
|
|
1232
|
+
if (msg?.type !== "needs-sudo") return false;
|
|
1233
|
+
asked ??= askSudo(msg);
|
|
1234
|
+
return true;
|
|
1235
|
+
},
|
|
1236
|
+
async done(retry) {
|
|
1237
|
+
const a = asked;
|
|
1238
|
+
asked = null;
|
|
1239
|
+
return a && (await a) ? retry() : undefined;
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1160
1242
|
}
|
|
1161
1243
|
|
|
1244
|
+
/** The chip: only while an ask is waiting (LK-0). */
|
|
1162
1245
|
function paintLock() {
|
|
1163
1246
|
const chip = $("den-lock");
|
|
1164
1247
|
if (!chip) return;
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1248
|
+
const w = sudo.waiting;
|
|
1249
|
+
chip.hidden = !w;
|
|
1250
|
+
if (!w) {
|
|
1251
|
+
chip.removeAttribute("title");
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const label = t("lock.waiting");
|
|
1255
|
+
chip.title = t("lock.needs", { cmd: needWhat(w.need) });
|
|
1170
1256
|
chip.innerHTML =
|
|
1171
|
-
|
|
1172
|
-
`<span class="den-lbl-l">${esc(
|
|
1173
|
-
chip.setAttribute(
|
|
1174
|
-
"aria-label",
|
|
1175
|
-
lock.unlocked ? t("lock.unlocked") : t("lock.locked"),
|
|
1176
|
-
);
|
|
1257
|
+
'<span class="den-lock-ic" aria-hidden="true">🔑</span>' +
|
|
1258
|
+
`<span class="den-lbl-l">${esc(label)}</span>`;
|
|
1259
|
+
chip.setAttribute("aria-label", label);
|
|
1177
1260
|
}
|
|
1178
1261
|
|
|
1179
1262
|
async function getJson(url, fallback, init) {
|
|
@@ -1185,28 +1268,24 @@ async function getJson(url, fallback, init) {
|
|
|
1185
1268
|
}
|
|
1186
1269
|
}
|
|
1187
1270
|
|
|
1188
|
-
/**
|
|
1189
|
-
* Local Dev's bootstrap, moved: the in-memory status first (fast), then,
|
|
1190
|
-
* when locked, the vault's stored password in the background (op can be
|
|
1191
|
-
* slow: Touch ID, a cold session) so nothing waits on it.
|
|
1192
|
-
*/
|
|
1193
1271
|
/**
|
|
1194
1272
|
* What the secrets backend can do here (`backend`, `backendAvailable`,
|
|
1195
|
-
* `reason`): asked once a page,
|
|
1196
|
-
*
|
|
1273
|
+
* `reason`): asked once a page, when sudo first needs the password (a
|
|
1274
|
+
* page view never wakes the vault), then kept. Not configured or
|
|
1275
|
+
* unavailable means no save option and no auto-load.
|
|
1197
1276
|
*/
|
|
1198
1277
|
let vaultAsked = null;
|
|
1199
1278
|
function checkVault() {
|
|
1200
1279
|
// 0.4.1: it reads the config's secrets refs; an invalid one answers 422.
|
|
1201
1280
|
if (window.denShell?.configInvalid?.() === true)
|
|
1202
|
-
return Promise.resolve(
|
|
1281
|
+
return Promise.resolve(sudo.vault);
|
|
1203
1282
|
vaultAsked ??= getJson("/api/sudo-password/vault-status", {
|
|
1204
1283
|
backend: "none",
|
|
1205
1284
|
label: "",
|
|
1206
1285
|
backendAvailable: false,
|
|
1207
1286
|
hasStored: false,
|
|
1208
1287
|
}).then((v) => {
|
|
1209
|
-
|
|
1288
|
+
sudo.vault = v;
|
|
1210
1289
|
// Only the save option changes: what's typed stays.
|
|
1211
1290
|
const label = document.querySelector("#den-lock-pop .den-lock-save");
|
|
1212
1291
|
const save = saveLabel();
|
|
@@ -1224,63 +1303,44 @@ function checkVault() {
|
|
|
1224
1303
|
return vaultAsked;
|
|
1225
1304
|
}
|
|
1226
1305
|
|
|
1227
|
-
async function initLock() {
|
|
1228
|
-
// A refusal page (SEC-1b): /api would answer the same refusal.
|
|
1229
|
-
if (sh()?.apiOff?.() === true) return;
|
|
1230
|
-
try {
|
|
1231
|
-
if (sessionStorage.getItem(LS_LOCK) === "1") {
|
|
1232
|
-
lock.unlocked = true;
|
|
1233
|
-
paintLock();
|
|
1234
|
-
}
|
|
1235
|
-
} catch {
|
|
1236
|
-
/* none */
|
|
1237
|
-
}
|
|
1238
|
-
const st = await getJson("/api/sudo-password/status", {
|
|
1239
|
-
set: false,
|
|
1240
|
-
passwordless: false,
|
|
1241
|
-
});
|
|
1242
|
-
if (st.set || st.passwordless) {
|
|
1243
|
-
setUnlocked(true, !!st.passwordless && !st.set);
|
|
1244
|
-
return;
|
|
1245
|
-
}
|
|
1246
|
-
setUnlocked(false);
|
|
1247
|
-
// The vault (op) only where Local Dev always asked it, on load; any
|
|
1248
|
-
// other page asks when the popover opens, so a page view never wakes op.
|
|
1249
|
-
if (location.pathname !== "/local-dev") return;
|
|
1250
|
-
await checkVault();
|
|
1251
|
-
if (!lock.vault.backendAvailable || !lock.vault.hasStored || lock.unlocked)
|
|
1252
|
-
return;
|
|
1253
|
-
const loaded = await getJson(
|
|
1254
|
-
"/api/sudo-password/load-from-vault",
|
|
1255
|
-
{ ok: false },
|
|
1256
|
-
{ method: "POST" },
|
|
1257
|
-
);
|
|
1258
|
-
if (loaded.ok && !lock.unlocked) setUnlocked(true);
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
1306
|
/**
|
|
1262
1307
|
* Why there is no save option, when the sudo ref is not configured: the
|
|
1263
1308
|
* server's reason, which names the distribution's real config file
|
|
1264
1309
|
* (`configFile`, e.g. config/acme.yml), never a placeholder.
|
|
1265
1310
|
*/
|
|
1266
1311
|
function notConfiguredNote() {
|
|
1267
|
-
const v =
|
|
1312
|
+
const v = sudo.vault;
|
|
1268
1313
|
if (!v || v.configured !== false || !v.configFile) return null;
|
|
1269
1314
|
return t("lock.cfg", { file: v.configFile });
|
|
1270
1315
|
}
|
|
1271
1316
|
|
|
1272
1317
|
function saveLabel() {
|
|
1273
|
-
if (!
|
|
1274
|
-
return
|
|
1318
|
+
if (!sudo.vault.backendAvailable) return null;
|
|
1319
|
+
return sudo.vault.hasStored
|
|
1275
1320
|
? t("lock.update")
|
|
1276
|
-
: t("lock.saveTo", { s:
|
|
1321
|
+
: t("lock.saveTo", { s: sudo.vault.label || sudo.vault.backend });
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* "<command> needs your admin password", one string (so a language can
|
|
1326
|
+
* put the command where it reads right), then the step it ran in.
|
|
1327
|
+
*/
|
|
1328
|
+
function needsHtml(need) {
|
|
1329
|
+
const step =
|
|
1330
|
+
need?.command && need?.step
|
|
1331
|
+
? `<span class="den-note den-lock-step">${esc(need.step)}</span>`
|
|
1332
|
+
: "";
|
|
1333
|
+
return (
|
|
1334
|
+
`<b id="den-lock-t">${esc(t("lock.needs", { cmd: needWhat(need) }))}</b>` +
|
|
1335
|
+
step
|
|
1336
|
+
);
|
|
1277
1337
|
}
|
|
1278
1338
|
|
|
1279
|
-
function lockFormHtml() {
|
|
1339
|
+
function lockFormHtml(need) {
|
|
1280
1340
|
const save = saveLabel();
|
|
1281
1341
|
const note = notConfiguredNote();
|
|
1282
1342
|
return (
|
|
1283
|
-
`<form class="den-lock-form"
|
|
1343
|
+
`<form class="den-lock-form">${needsHtml(need)}` +
|
|
1284
1344
|
`<input type="password" id="den-lock-pw" placeholder="${esc(t("lock.pw"))}" aria-label="${esc(t("lock.pw"))}" autocomplete="current-password">` +
|
|
1285
1345
|
`<label class="den-lock-save"${save ? "" : " hidden"}><input type="checkbox" id="den-lock-vault"> <span>${esc(save ?? "")}</span></label>` +
|
|
1286
1346
|
'<span class="den-lock-err" id="den-lock-err" role="alert" hidden></span>' +
|
|
@@ -1292,36 +1352,24 @@ function lockFormHtml() {
|
|
|
1292
1352
|
|
|
1293
1353
|
function openLock() {
|
|
1294
1354
|
const chip = $("den-lock");
|
|
1295
|
-
|
|
1355
|
+
const w = sudo.waiting;
|
|
1356
|
+
if (!chip || !w) return;
|
|
1296
1357
|
if (overlay?.kind === "lock") {
|
|
1297
1358
|
overlay.el.querySelector("input,button")?.focus();
|
|
1298
1359
|
return;
|
|
1299
1360
|
}
|
|
1300
1361
|
closeOverlay(false);
|
|
1301
1362
|
sh()?.closeMenu?.();
|
|
1302
|
-
|
|
1303
|
-
|
|
1363
|
+
void checkVault();
|
|
1364
|
+
// LK-1: in the top layer, so no modal covers it. Under an open modal
|
|
1365
|
+
// <dialog> only another modal is reachable (a popover there is inert),
|
|
1366
|
+
// so it opens as its own modal; elsewhere it is a popover.
|
|
1367
|
+
const pop = document.createElement("dialog");
|
|
1304
1368
|
pop.className = "den-pop den-lock-pop";
|
|
1305
1369
|
pop.id = "den-lock-pop";
|
|
1306
1370
|
pop.setAttribute("role", "dialog");
|
|
1307
|
-
pop.setAttribute("aria-
|
|
1308
|
-
|
|
1309
|
-
const draw = () => {
|
|
1310
|
-
if (!lock.unlocked || forceForm) pop.innerHTML = lockFormHtml();
|
|
1311
|
-
else
|
|
1312
|
-
pop.innerHTML =
|
|
1313
|
-
`<div class="den-ml">${esc(t("lock.unlocked"))}</div>` +
|
|
1314
|
-
`<div class="den-lock-desc">${esc(lock.passwordless ? `${t("lock.nopw")}.` : t("lock.sessionDesc"))}</div>` +
|
|
1315
|
-
(lock.passwordless
|
|
1316
|
-
? ""
|
|
1317
|
-
: `<button type="button" class="den-mi" data-reenter>${esc(t("lock.again"))}</button>`);
|
|
1318
|
-
pop.querySelector("form")?.addEventListener("submit", onSubmit);
|
|
1319
|
-
pop.querySelector("[data-reenter]")?.addEventListener("click", () => {
|
|
1320
|
-
forceForm = true;
|
|
1321
|
-
draw();
|
|
1322
|
-
pop.querySelector("input")?.focus();
|
|
1323
|
-
});
|
|
1324
|
-
};
|
|
1371
|
+
pop.setAttribute("aria-labelledby", "den-lock-t");
|
|
1372
|
+
pop.innerHTML = lockFormHtml(w.need);
|
|
1325
1373
|
const onSubmit = async (e) => {
|
|
1326
1374
|
e.preventDefault();
|
|
1327
1375
|
const input = $("den-lock-pw");
|
|
@@ -1333,40 +1381,26 @@ function openLock() {
|
|
|
1333
1381
|
btn.textContent = t("lock.checking");
|
|
1334
1382
|
btn.disabled = true;
|
|
1335
1383
|
err.hidden = true;
|
|
1336
|
-
const saveToVault = !!(
|
|
1384
|
+
const saveToVault = !!(sudo.vault.backendAvailable && saveCb?.checked);
|
|
1337
1385
|
try {
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
headers: { "Content-Type": "application/json" },
|
|
1341
|
-
body: JSON.stringify({ password: pw, saveToVault }),
|
|
1342
|
-
});
|
|
1343
|
-
const data = await r.json();
|
|
1344
|
-
if (r.ok) {
|
|
1386
|
+
const res = await unlockWith(pw, { saveToVault });
|
|
1387
|
+
if (res.ok) {
|
|
1345
1388
|
input.value = "";
|
|
1346
|
-
if (data.savedToVault) lock.vault.hasStored = true;
|
|
1347
|
-
forceForm = false;
|
|
1348
|
-
setUnlocked(true);
|
|
1349
|
-
closeOverlay(true);
|
|
1350
1389
|
toast(
|
|
1351
|
-
saveToVault &&
|
|
1390
|
+
saveToVault && res.vaultError
|
|
1352
1391
|
? t("lock.saveFailed", {
|
|
1353
|
-
s:
|
|
1354
|
-
err:
|
|
1392
|
+
s: sudo.vault.label || sudo.vault.backend,
|
|
1393
|
+
err: res.vaultError,
|
|
1355
1394
|
})
|
|
1356
|
-
: t("lock.
|
|
1395
|
+
: t("lock.saved"),
|
|
1357
1396
|
);
|
|
1397
|
+
settleSudo(true);
|
|
1358
1398
|
return;
|
|
1359
1399
|
}
|
|
1360
|
-
|
|
1361
|
-
// is the server's own message.
|
|
1362
|
-
err.textContent =
|
|
1363
|
-
r.status === 401 || !data.error ? t("lock.wrong") : data.error;
|
|
1400
|
+
err.textContent = res.error;
|
|
1364
1401
|
err.hidden = false;
|
|
1365
1402
|
input.value = "";
|
|
1366
1403
|
input.focus();
|
|
1367
|
-
} catch {
|
|
1368
|
-
err.textContent = t("lock.reqFailed");
|
|
1369
|
-
err.hidden = false;
|
|
1370
1404
|
} finally {
|
|
1371
1405
|
if (btn.isConnected) {
|
|
1372
1406
|
btn.textContent = t("lock.go");
|
|
@@ -1374,34 +1408,51 @@ function openLock() {
|
|
|
1374
1408
|
}
|
|
1375
1409
|
}
|
|
1376
1410
|
};
|
|
1377
|
-
|
|
1411
|
+
pop.querySelector("form").addEventListener("submit", onSubmit);
|
|
1378
1412
|
document.body.appendChild(pop);
|
|
1413
|
+
const underModal = [...document.querySelectorAll("dialog")].some(
|
|
1414
|
+
(d) => d !== pop && d.open && d.matches(":modal"),
|
|
1415
|
+
);
|
|
1416
|
+
try {
|
|
1417
|
+
if (underModal) pop.showModal();
|
|
1418
|
+
else if (typeof pop.showPopover === "function") {
|
|
1419
|
+
pop.setAttribute("popover", "manual");
|
|
1420
|
+
pop.showPopover();
|
|
1421
|
+
} else pop.show();
|
|
1422
|
+
} catch {
|
|
1423
|
+
pop.setAttribute("open", "");
|
|
1424
|
+
}
|
|
1425
|
+
// Escape (or a modal's cancel) says no: the action doesn't run again
|
|
1426
|
+
// (its run already said why it failed), and the chip goes.
|
|
1427
|
+
const decline = () => {
|
|
1428
|
+
closeOverlay(true);
|
|
1429
|
+
settleSudo(false);
|
|
1430
|
+
};
|
|
1431
|
+
pop.addEventListener("cancel", (e) => {
|
|
1432
|
+
e.preventDefault();
|
|
1433
|
+
decline();
|
|
1434
|
+
});
|
|
1379
1435
|
const r = chip.getBoundingClientRect();
|
|
1380
1436
|
pop.style.top = `${r.bottom + 6}px`;
|
|
1381
1437
|
pop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - pop.offsetWidth - 8))}px`;
|
|
1382
1438
|
chip.setAttribute("aria-expanded", "true");
|
|
1383
1439
|
chip.setAttribute("aria-controls", pop.id);
|
|
1384
|
-
const onDown = (e) => {
|
|
1385
|
-
if (!pop.contains(e.target) && !chip.contains(e.target))
|
|
1386
|
-
closeOverlay(false);
|
|
1387
|
-
};
|
|
1388
1440
|
pop.addEventListener("keydown", (e) => {
|
|
1389
1441
|
if (e.key === "Escape") {
|
|
1390
1442
|
e.preventDefault();
|
|
1391
1443
|
e.stopPropagation();
|
|
1392
|
-
|
|
1444
|
+
decline();
|
|
1393
1445
|
}
|
|
1394
1446
|
});
|
|
1395
|
-
document.addEventListener("mousedown", onDown, true);
|
|
1396
1447
|
overlay = {
|
|
1397
1448
|
kind: "lock",
|
|
1398
1449
|
el: pop,
|
|
1399
|
-
|
|
1450
|
+
// Another overlay closing it leaves the ask waiting: the chip
|
|
1451
|
+
// stays, and opens the prompt again.
|
|
1400
1452
|
close(refocus) {
|
|
1401
|
-
document.removeEventListener("mousedown", onDown, true);
|
|
1402
1453
|
pop.remove();
|
|
1403
1454
|
chip.setAttribute("aria-expanded", "false");
|
|
1404
|
-
if (refocus && chip.isConnected) chip.focus();
|
|
1455
|
+
if (refocus && chip.isConnected && !chip.hidden) chip.focus();
|
|
1405
1456
|
},
|
|
1406
1457
|
};
|
|
1407
1458
|
(
|
|
@@ -1441,6 +1492,15 @@ export function initTopbar(opts = {}) {
|
|
|
1441
1492
|
$("den-lock")?.addEventListener("click", () =>
|
|
1442
1493
|
overlay?.kind === "lock" ? closeOverlay(true) : openLock(),
|
|
1443
1494
|
);
|
|
1495
|
+
// LK-2: back on this tab while an ask waits, the password may have
|
|
1496
|
+
// been given elsewhere (another tab or device): the action goes on.
|
|
1497
|
+
document.addEventListener("visibilitychange", () => {
|
|
1498
|
+
if (document.visibilityState !== "visible" || !sudo.waiting) return;
|
|
1499
|
+
if (sh()?.apiOff?.() === true) return;
|
|
1500
|
+
void getJson("/api/sudo-password/status", null).then((st) => {
|
|
1501
|
+
if (st?.set) settleSudo(true);
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1444
1504
|
document.addEventListener("keydown", (e) => {
|
|
1445
1505
|
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "k") {
|
|
1446
1506
|
e.preventDefault();
|
|
@@ -1450,7 +1510,6 @@ export function initTopbar(opts = {}) {
|
|
|
1450
1510
|
closeOverlay(true);
|
|
1451
1511
|
}
|
|
1452
1512
|
});
|
|
1453
|
-
void initLock();
|
|
1454
1513
|
}
|
|
1455
1514
|
|
|
1456
1515
|
/** For tests and plugins: open an overlay by name. */
|
|
@@ -20,14 +20,20 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import * as http from "http";
|
|
22
22
|
import { readEdgeConfig } from "../../shared/edge/config.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
edgeStatus,
|
|
25
|
+
withHttpsServing,
|
|
26
|
+
type EdgeStatus,
|
|
27
|
+
} from "../../shared/edge/status.js";
|
|
24
28
|
import { getWorkspaceByName } from "../../shared/environment-manager/workspaces.js";
|
|
25
29
|
import {
|
|
30
|
+
emptyStacksReason,
|
|
26
31
|
loadStackContext,
|
|
27
32
|
resolveStackWorkspaces,
|
|
28
33
|
stackStatuses,
|
|
29
34
|
type StackStatus,
|
|
30
35
|
} from "../../shared/stacks/status.js";
|
|
36
|
+
import { readStacks } from "../../shared/stacks/config.js";
|
|
31
37
|
import { send400, sendJson } from "./http-helpers.js";
|
|
32
38
|
import { recentActions, type AuditEntry } from "./audit.js";
|
|
33
39
|
import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
@@ -50,8 +56,11 @@ export interface ReadonlyRouteDeps {
|
|
|
50
56
|
workspace: string;
|
|
51
57
|
stackWorkspace: string;
|
|
52
58
|
stacks: StackStatus[];
|
|
59
|
+
/** With no stacks: why the list is empty (MCP-2.4). */
|
|
60
|
+
why?: string;
|
|
53
61
|
};
|
|
54
|
-
edge
|
|
62
|
+
/** The edge's status; https.serving probed (MCP-2.5). */
|
|
63
|
+
edge: () => EdgeStatus | Promise<EdgeStatus>;
|
|
55
64
|
/** The edge block reads (no Docker asked); throws with the reason if not. */
|
|
56
65
|
edgeConfig: () => void;
|
|
57
66
|
actions: () => AuditEntry[];
|
|
@@ -63,13 +72,28 @@ export const defaultReadonlyRouteDeps: ReadonlyRouteDeps = {
|
|
|
63
72
|
stacks: ({ workspace, health }) => {
|
|
64
73
|
const ws = resolveStackWorkspaces(workspace);
|
|
65
74
|
const ctx = loadStackContext(ws.denWorkspace);
|
|
75
|
+
const stacks = stackStatuses(ctx, { health });
|
|
66
76
|
return {
|
|
67
77
|
workspace: ws.denWorkspace,
|
|
68
78
|
stackWorkspace: ws.stackWorkspace,
|
|
69
|
-
stacks
|
|
79
|
+
stacks,
|
|
80
|
+
...(stacks.length
|
|
81
|
+
? {}
|
|
82
|
+
: {
|
|
83
|
+
why: emptyStacksReason({
|
|
84
|
+
workspace: ws.denWorkspace,
|
|
85
|
+
workspaceExists: !!getWorkspaceByName(ws.denWorkspace),
|
|
86
|
+
configStacks: Object.keys(
|
|
87
|
+
readStacks(undefined, { manifests: false }),
|
|
88
|
+
).length,
|
|
89
|
+
}),
|
|
90
|
+
}),
|
|
70
91
|
};
|
|
71
92
|
},
|
|
72
|
-
edge: () =>
|
|
93
|
+
edge: () => {
|
|
94
|
+
const cfg = readEdgeConfig();
|
|
95
|
+
return withHttpsServing(edgeStatus(cfg), cfg);
|
|
96
|
+
},
|
|
73
97
|
edgeConfig: () => void readEdgeConfig(),
|
|
74
98
|
actions: recentActions,
|
|
75
99
|
securityPlan: async ({ env, mode, service }) => {
|
|
@@ -195,7 +219,7 @@ export async function handleReadonlyRoutes(
|
|
|
195
219
|
sendJson(res, { configured: true });
|
|
196
220
|
return true;
|
|
197
221
|
}
|
|
198
|
-
sendJson(res, { configured: true, ...deps.edge() });
|
|
222
|
+
sendJson(res, { configured: true, ...(await deps.edge()) });
|
|
199
223
|
} catch (e) {
|
|
200
224
|
// No edge configured is a state, not a server error.
|
|
201
225
|
sendJson(res, { configured: false, error: (e as Error).message });
|