@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
|
@@ -11,22 +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
|
-
* (
|
|
21
|
-
*
|
|
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().
|
|
22
23
|
*
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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).
|
|
30
32
|
*
|
|
31
33
|
* The pure parts (statusSummary, statusChip, problemCards, drawerGroups,
|
|
32
34
|
* checkLine, rankCommands, runOf) take plain data, so tests need no
|
|
@@ -418,7 +420,6 @@ export function runOf(url, base = "http://localhost/") {
|
|
|
418
420
|
// ── the UI ──────────────────────────────────────────────────────────────────
|
|
419
421
|
|
|
420
422
|
const LS_LAST_RUN = "__DEN_CLI__-last-run";
|
|
421
|
-
const LS_LOCK = "__DEN_CLI__-lock";
|
|
422
423
|
const SS_PENDING = "__DEN_CLI__-pending-action";
|
|
423
424
|
const KIND = ["page", "service", "app", "agent", "action", "setting"];
|
|
424
425
|
const kindLabel = (k) =>
|
|
@@ -738,7 +739,7 @@ let serviceRunner = null;
|
|
|
738
739
|
|
|
739
740
|
/**
|
|
740
741
|
* The page that runs a service command itself (Local Dev: its runCmd, with
|
|
741
|
-
* its
|
|
742
|
+
* its output pane) registers `fn(cmd, svc)`; `svc` is
|
|
742
743
|
* `{ repo, service, address, workspaces }`.
|
|
743
744
|
*/
|
|
744
745
|
export function setServiceRunner(fn) {
|
|
@@ -747,16 +748,15 @@ export function setServiceRunner(fn) {
|
|
|
747
748
|
|
|
748
749
|
/**
|
|
749
750
|
* Start (`up`) or Restart from the drawer or the palette: an explicit
|
|
750
|
-
* action. The page's runner does it where there is one. Elsewhere the
|
|
751
|
-
*
|
|
752
|
-
*
|
|
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
|
|
753
754
|
* closes, so it can't run on a page that is about to be left): the page
|
|
754
755
|
* stores it for this tab (sessionStorage, not a link anyone could open)
|
|
755
756
|
* and Local Dev takes it once (takePendingAction).
|
|
756
757
|
*/
|
|
757
|
-
export
|
|
758
|
+
export function runServiceAction(cmd, svc) {
|
|
758
759
|
if (serviceRunner) return serviceRunner(cmd, svc);
|
|
759
|
-
if (!(await requireUnlock())) return;
|
|
760
760
|
try {
|
|
761
761
|
sessionStorage.setItem(
|
|
762
762
|
SS_PENDING,
|
|
@@ -1119,83 +1119,32 @@ function trackRuns() {
|
|
|
1119
1119
|
window.EventSource = DenEventSource;
|
|
1120
1120
|
}
|
|
1121
1121
|
|
|
1122
|
-
// ──
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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 = {
|
|
1128
1135
|
vault: {
|
|
1129
1136
|
backend: "none",
|
|
1130
1137
|
label: "",
|
|
1131
1138
|
backendAvailable: false,
|
|
1132
1139
|
hasStored: false,
|
|
1133
1140
|
},
|
|
1134
|
-
|
|
1141
|
+
/** The ask that is waiting: { need, promise, resolve }, or null. */
|
|
1142
|
+
waiting: null,
|
|
1135
1143
|
};
|
|
1136
1144
|
|
|
1137
|
-
export function isUnlocked() {
|
|
1138
|
-
return lock.unlocked;
|
|
1139
|
-
}
|
|
1140
|
-
/** fn(unlocked) on every change. */
|
|
1141
|
-
export function onLockChange(fn) {
|
|
1142
|
-
lock.subs.add(fn);
|
|
1143
|
-
return () => lock.subs.delete(fn);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Whether the lock is open, asking before saying no (never prompts): the
|
|
1148
|
-
* server's status (LK-2), then, when a password is stored, the vault, once
|
|
1149
|
-
* a page (LK-3). Resolves true when unlocked.
|
|
1150
|
-
*/
|
|
1151
|
-
let rechecking = null;
|
|
1152
|
-
let vaultTried = false;
|
|
1153
|
-
export function checkUnlock() {
|
|
1154
|
-
if (lock.unlocked) return Promise.resolve(true);
|
|
1155
|
-
rechecking ??= (async () => {
|
|
1156
|
-
const st = await getJson("/api/sudo-password/status", null);
|
|
1157
|
-
if (st && (st.set || st.passwordless)) {
|
|
1158
|
-
setUnlocked(true, !!st.passwordless && !st.set);
|
|
1159
|
-
return true;
|
|
1160
|
-
}
|
|
1161
|
-
if (!vaultTried) {
|
|
1162
|
-
await checkVault();
|
|
1163
|
-
if (
|
|
1164
|
-
!lock.unlocked &&
|
|
1165
|
-
lock.vault.backendAvailable &&
|
|
1166
|
-
lock.vault.hasStored
|
|
1167
|
-
) {
|
|
1168
|
-
vaultTried = true;
|
|
1169
|
-
const loaded = await getJson(
|
|
1170
|
-
"/api/sudo-password/load-from-vault",
|
|
1171
|
-
{ ok: false },
|
|
1172
|
-
{ method: "POST" },
|
|
1173
|
-
);
|
|
1174
|
-
if (loaded.ok && !lock.unlocked) setUnlocked(true);
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
return lock.unlocked;
|
|
1178
|
-
})().finally(() => {
|
|
1179
|
-
rechecking = null;
|
|
1180
|
-
});
|
|
1181
|
-
return rechecking;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
/**
|
|
1185
|
-
* Local Dev's lock rule, for every page (async): true when unlocked, after
|
|
1186
|
-
* checkUnlock; otherwise the lock popover opens on its password field and
|
|
1187
|
-
* the action doesn't run.
|
|
1188
|
-
*/
|
|
1189
|
-
export async function requireUnlock() {
|
|
1190
|
-
if (await checkUnlock()) return true;
|
|
1191
|
-
openLock();
|
|
1192
|
-
return false;
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
1145
|
/**
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
* subscriber hears it (onLockChange). Never logs the password.
|
|
1146
|
+
* Set the admin password (the prompt's form): { ok, savedToVault?,
|
|
1147
|
+
* vaultError?, error? }. Never logs the password.
|
|
1199
1148
|
*/
|
|
1200
1149
|
export async function unlockWith(password, { saveToVault = false } = {}) {
|
|
1201
1150
|
if (!password) return { ok: false, error: t("lock.wrong") };
|
|
@@ -1207,8 +1156,9 @@ export async function unlockWith(password, { saveToVault = false } = {}) {
|
|
|
1207
1156
|
});
|
|
1208
1157
|
const data = await r.json().catch(() => ({}));
|
|
1209
1158
|
if (r.ok) {
|
|
1210
|
-
|
|
1211
|
-
|
|
1159
|
+
// LK-4: a first save also wrote the ref, so it is configured now.
|
|
1160
|
+
if (data.savedToVault)
|
|
1161
|
+
sudo.vault = { ...sudo.vault, hasStored: true, configured: true };
|
|
1212
1162
|
return {
|
|
1213
1163
|
ok: true,
|
|
1214
1164
|
savedToVault: !!data.savedToVault,
|
|
@@ -1226,36 +1176,89 @@ export async function unlockWith(password, { saveToVault = false } = {}) {
|
|
|
1226
1176
|
}
|
|
1227
1177
|
}
|
|
1228
1178
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
lock.
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1179
|
+
/** What needed sudo, for the prompt: the command, else the step. */
|
|
1180
|
+
function needWhat(need) {
|
|
1181
|
+
return need?.command || need?.step || t("lock.cmd");
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* The run's server said sudo needs the password (`need` is its needs-sudo
|
|
1186
|
+
* event: { command?, step? }). Resolves true once the password is set
|
|
1187
|
+
* (from another tab, the vault or the field), false when the person
|
|
1188
|
+
* closes the prompt. One ask at a time: a second need while one waits
|
|
1189
|
+
* shares it.
|
|
1190
|
+
*/
|
|
1191
|
+
export function askSudo(need = {}) {
|
|
1192
|
+
if (sudo.waiting) return sudo.waiting.promise;
|
|
1193
|
+
let resolve;
|
|
1194
|
+
const promise = new Promise((r) => (resolve = r));
|
|
1195
|
+
sudo.waiting = { need, promise, resolve };
|
|
1239
1196
|
paintLock();
|
|
1240
|
-
|
|
1241
|
-
|
|
1197
|
+
void (async () => {
|
|
1198
|
+
const st = await getJson("/api/sudo-password/status", null);
|
|
1199
|
+
if (st?.set) return settleSudo(true);
|
|
1200
|
+
await checkVault();
|
|
1201
|
+
if (sudo.vault.backendAvailable && sudo.vault.hasStored) {
|
|
1202
|
+
const loaded = await getJson(
|
|
1203
|
+
"/api/sudo-password/load-from-vault",
|
|
1204
|
+
{ ok: false },
|
|
1205
|
+
{ method: "POST" },
|
|
1206
|
+
);
|
|
1207
|
+
if (loaded.ok) return settleSudo(true);
|
|
1208
|
+
}
|
|
1209
|
+
if (sudo.waiting) openLock();
|
|
1210
|
+
})();
|
|
1211
|
+
return promise;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function settleSudo(ok) {
|
|
1215
|
+
const w = sudo.waiting;
|
|
1216
|
+
if (!w) return;
|
|
1217
|
+
sudo.waiting = null;
|
|
1218
|
+
if (overlay?.kind === "lock") closeOverlay(false);
|
|
1219
|
+
paintLock();
|
|
1220
|
+
w.resolve(ok);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* For a page's run (an EventSource): `see(msg)` on every message (true
|
|
1225
|
+
* for a needs-sudo one, which starts the ask), and `done(retry)` when the
|
|
1226
|
+
* run ends: after the password, the action runs again (resolves to what
|
|
1227
|
+
* `retry()` returns; undefined when nothing needed sudo, or the person
|
|
1228
|
+
* said no).
|
|
1229
|
+
*/
|
|
1230
|
+
export function sudoRun() {
|
|
1231
|
+
let asked = null;
|
|
1232
|
+
return {
|
|
1233
|
+
see(msg) {
|
|
1234
|
+
if (msg?.type !== "needs-sudo") return false;
|
|
1235
|
+
asked ??= askSudo(msg);
|
|
1236
|
+
return true;
|
|
1237
|
+
},
|
|
1238
|
+
async done(retry) {
|
|
1239
|
+
const a = asked;
|
|
1240
|
+
asked = null;
|
|
1241
|
+
return a && (await a) ? retry() : undefined;
|
|
1242
|
+
},
|
|
1243
|
+
};
|
|
1242
1244
|
}
|
|
1243
1245
|
|
|
1246
|
+
/** The chip: only while an ask is waiting (LK-0). */
|
|
1244
1247
|
function paintLock() {
|
|
1245
1248
|
const chip = $("den-lock");
|
|
1246
1249
|
if (!chip) return;
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1250
|
+
const w = sudo.waiting;
|
|
1251
|
+
chip.hidden = !w;
|
|
1252
|
+
if (!w) {
|
|
1253
|
+
chip.removeAttribute("title");
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
const label = t("lock.waiting");
|
|
1257
|
+
chip.title = t("lock.needs", { cmd: needWhat(w.need) });
|
|
1252
1258
|
chip.innerHTML =
|
|
1253
|
-
|
|
1254
|
-
`<span class="den-lbl-l">${esc(
|
|
1255
|
-
chip.setAttribute(
|
|
1256
|
-
"aria-label",
|
|
1257
|
-
lock.unlocked ? t("lock.unlocked") : t("lock.locked"),
|
|
1258
|
-
);
|
|
1259
|
+
'<span class="den-lock-ic" aria-hidden="true">🔑</span>' +
|
|
1260
|
+
`<span class="den-lbl-l">${esc(label)}</span>`;
|
|
1261
|
+
chip.setAttribute("aria-label", label);
|
|
1259
1262
|
}
|
|
1260
1263
|
|
|
1261
1264
|
async function getJson(url, fallback, init) {
|
|
@@ -1267,28 +1270,24 @@ async function getJson(url, fallback, init) {
|
|
|
1267
1270
|
}
|
|
1268
1271
|
}
|
|
1269
1272
|
|
|
1270
|
-
/**
|
|
1271
|
-
* Local Dev's bootstrap, moved: the in-memory status first (fast), then,
|
|
1272
|
-
* when locked, the vault's stored password in the background (op can be
|
|
1273
|
-
* slow: Touch ID, a cold session) so nothing waits on it.
|
|
1274
|
-
*/
|
|
1275
1273
|
/**
|
|
1276
1274
|
* What the secrets backend can do here (`backend`, `backendAvailable`,
|
|
1277
|
-
* `reason`): asked once a page,
|
|
1278
|
-
*
|
|
1275
|
+
* `reason`): asked once a page, when sudo first needs the password (a
|
|
1276
|
+
* page view never wakes the vault), then kept. Not configured or
|
|
1277
|
+
* unavailable means no save option and no auto-load.
|
|
1279
1278
|
*/
|
|
1280
1279
|
let vaultAsked = null;
|
|
1281
1280
|
function checkVault() {
|
|
1282
1281
|
// 0.4.1: it reads the config's secrets refs; an invalid one answers 422.
|
|
1283
1282
|
if (window.denShell?.configInvalid?.() === true)
|
|
1284
|
-
return Promise.resolve(
|
|
1283
|
+
return Promise.resolve(sudo.vault);
|
|
1285
1284
|
vaultAsked ??= getJson("/api/sudo-password/vault-status", {
|
|
1286
1285
|
backend: "none",
|
|
1287
1286
|
label: "",
|
|
1288
1287
|
backendAvailable: false,
|
|
1289
1288
|
hasStored: false,
|
|
1290
1289
|
}).then((v) => {
|
|
1291
|
-
|
|
1290
|
+
sudo.vault = v;
|
|
1292
1291
|
// Only the save option changes: what's typed stays.
|
|
1293
1292
|
const label = document.querySelector("#den-lock-pop .den-lock-save");
|
|
1294
1293
|
const save = saveLabel();
|
|
@@ -1306,64 +1305,46 @@ function checkVault() {
|
|
|
1306
1305
|
return vaultAsked;
|
|
1307
1306
|
}
|
|
1308
1307
|
|
|
1309
|
-
async function initLock() {
|
|
1310
|
-
// A refusal page (SEC-1b): /api would answer the same refusal.
|
|
1311
|
-
if (sh()?.apiOff?.() === true) return;
|
|
1312
|
-
try {
|
|
1313
|
-
if (sessionStorage.getItem(LS_LOCK) === "1") {
|
|
1314
|
-
lock.unlocked = true;
|
|
1315
|
-
paintLock();
|
|
1316
|
-
}
|
|
1317
|
-
} catch {
|
|
1318
|
-
/* none */
|
|
1319
|
-
}
|
|
1320
|
-
const st = await getJson("/api/sudo-password/status", {
|
|
1321
|
-
set: false,
|
|
1322
|
-
passwordless: false,
|
|
1323
|
-
});
|
|
1324
|
-
if (st.set || st.passwordless) {
|
|
1325
|
-
setUnlocked(true, !!st.passwordless && !st.set);
|
|
1326
|
-
return;
|
|
1327
|
-
}
|
|
1328
|
-
setUnlocked(false);
|
|
1329
|
-
// The vault (op) only where Local Dev always asked it, on load; any
|
|
1330
|
-
// other page asks when the popover opens, so a page view never wakes op.
|
|
1331
|
-
if (location.pathname !== "/local-dev") return;
|
|
1332
|
-
await checkVault();
|
|
1333
|
-
if (!lock.vault.backendAvailable || !lock.vault.hasStored || lock.unlocked)
|
|
1334
|
-
return;
|
|
1335
|
-
vaultTried = true;
|
|
1336
|
-
const loaded = await getJson(
|
|
1337
|
-
"/api/sudo-password/load-from-vault",
|
|
1338
|
-
{ ok: false },
|
|
1339
|
-
{ method: "POST" },
|
|
1340
|
-
);
|
|
1341
|
-
if (loaded.ok && !lock.unlocked) setUnlocked(true);
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
1308
|
/**
|
|
1345
1309
|
* Why there is no save option, when the sudo ref is not configured: the
|
|
1346
1310
|
* server's reason, which names the distribution's real config file
|
|
1347
1311
|
* (`configFile`, e.g. config/acme.yml), never a placeholder.
|
|
1348
1312
|
*/
|
|
1349
1313
|
function notConfiguredNote() {
|
|
1350
|
-
const v =
|
|
1314
|
+
const v = sudo.vault;
|
|
1351
1315
|
if (!v || v.configured !== false || !v.configFile) return null;
|
|
1316
|
+
// LK-4: saving writes the ref itself; nothing to edit.
|
|
1317
|
+
if (v.canSave === true) return null;
|
|
1352
1318
|
return t("lock.cfg", { file: v.configFile });
|
|
1353
1319
|
}
|
|
1354
1320
|
|
|
1355
1321
|
function saveLabel() {
|
|
1356
|
-
if (!
|
|
1357
|
-
return
|
|
1322
|
+
if (!sudo.vault.backendAvailable || sudo.vault.canSave === false) return null;
|
|
1323
|
+
return sudo.vault.hasStored
|
|
1358
1324
|
? t("lock.update")
|
|
1359
|
-
: t("lock.saveTo", { s:
|
|
1325
|
+
: t("lock.saveTo", { s: sudo.vault.label || sudo.vault.backend });
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* "<command> needs your admin password", one string (so a language can
|
|
1330
|
+
* put the command where it reads right), then the step it ran in.
|
|
1331
|
+
*/
|
|
1332
|
+
function needsHtml(need) {
|
|
1333
|
+
const step =
|
|
1334
|
+
need?.command && need?.step
|
|
1335
|
+
? `<span class="den-note den-lock-step">${esc(need.step)}</span>`
|
|
1336
|
+
: "";
|
|
1337
|
+
return (
|
|
1338
|
+
`<b id="den-lock-t">${esc(t("lock.needs", { cmd: needWhat(need) }))}</b>` +
|
|
1339
|
+
step
|
|
1340
|
+
);
|
|
1360
1341
|
}
|
|
1361
1342
|
|
|
1362
|
-
function lockFormHtml() {
|
|
1343
|
+
function lockFormHtml(need) {
|
|
1363
1344
|
const save = saveLabel();
|
|
1364
1345
|
const note = notConfiguredNote();
|
|
1365
1346
|
return (
|
|
1366
|
-
`<form class="den-lock-form"
|
|
1347
|
+
`<form class="den-lock-form">${needsHtml(need)}` +
|
|
1367
1348
|
`<input type="password" id="den-lock-pw" placeholder="${esc(t("lock.pw"))}" aria-label="${esc(t("lock.pw"))}" autocomplete="current-password">` +
|
|
1368
1349
|
`<label class="den-lock-save"${save ? "" : " hidden"}><input type="checkbox" id="den-lock-vault"> <span>${esc(save ?? "")}</span></label>` +
|
|
1369
1350
|
'<span class="den-lock-err" id="den-lock-err" role="alert" hidden></span>' +
|
|
@@ -1375,14 +1356,15 @@ function lockFormHtml() {
|
|
|
1375
1356
|
|
|
1376
1357
|
function openLock() {
|
|
1377
1358
|
const chip = $("den-lock");
|
|
1378
|
-
|
|
1359
|
+
const w = sudo.waiting;
|
|
1360
|
+
if (!chip || !w) return;
|
|
1379
1361
|
if (overlay?.kind === "lock") {
|
|
1380
1362
|
overlay.el.querySelector("input,button")?.focus();
|
|
1381
1363
|
return;
|
|
1382
1364
|
}
|
|
1383
1365
|
closeOverlay(false);
|
|
1384
1366
|
sh()?.closeMenu?.();
|
|
1385
|
-
|
|
1367
|
+
void checkVault();
|
|
1386
1368
|
// LK-1: in the top layer, so no modal covers it. Under an open modal
|
|
1387
1369
|
// <dialog> only another modal is reachable (a popover there is inert),
|
|
1388
1370
|
// so it opens as its own modal; elsewhere it is a popover.
|
|
@@ -1390,24 +1372,8 @@ function openLock() {
|
|
|
1390
1372
|
pop.className = "den-pop den-lock-pop";
|
|
1391
1373
|
pop.id = "den-lock-pop";
|
|
1392
1374
|
pop.setAttribute("role", "dialog");
|
|
1393
|
-
pop.setAttribute("aria-
|
|
1394
|
-
|
|
1395
|
-
const draw = () => {
|
|
1396
|
-
if (!lock.unlocked || forceForm) pop.innerHTML = lockFormHtml();
|
|
1397
|
-
else
|
|
1398
|
-
pop.innerHTML =
|
|
1399
|
-
`<div class="den-ml">${esc(t("lock.unlocked"))}</div>` +
|
|
1400
|
-
`<div class="den-lock-desc">${esc(lock.passwordless ? `${t("lock.nopw")}.` : t("lock.sessionDesc"))}</div>` +
|
|
1401
|
-
(lock.passwordless
|
|
1402
|
-
? ""
|
|
1403
|
-
: `<button type="button" class="den-mi" data-reenter>${esc(t("lock.again"))}</button>`);
|
|
1404
|
-
pop.querySelector("form")?.addEventListener("submit", onSubmit);
|
|
1405
|
-
pop.querySelector("[data-reenter]")?.addEventListener("click", () => {
|
|
1406
|
-
forceForm = true;
|
|
1407
|
-
draw();
|
|
1408
|
-
pop.querySelector("input")?.focus();
|
|
1409
|
-
});
|
|
1410
|
-
};
|
|
1375
|
+
pop.setAttribute("aria-labelledby", "den-lock-t");
|
|
1376
|
+
pop.innerHTML = lockFormHtml(w.need);
|
|
1411
1377
|
const onSubmit = async (e) => {
|
|
1412
1378
|
e.preventDefault();
|
|
1413
1379
|
const input = $("den-lock-pw");
|
|
@@ -1419,21 +1385,20 @@ function openLock() {
|
|
|
1419
1385
|
btn.textContent = t("lock.checking");
|
|
1420
1386
|
btn.disabled = true;
|
|
1421
1387
|
err.hidden = true;
|
|
1422
|
-
const saveToVault = !!(
|
|
1388
|
+
const saveToVault = !!(saveLabel() && saveCb?.checked);
|
|
1423
1389
|
try {
|
|
1424
1390
|
const res = await unlockWith(pw, { saveToVault });
|
|
1425
1391
|
if (res.ok) {
|
|
1426
1392
|
input.value = "";
|
|
1427
|
-
forceForm = false;
|
|
1428
|
-
closeOverlay(true);
|
|
1429
1393
|
toast(
|
|
1430
1394
|
saveToVault && res.vaultError
|
|
1431
1395
|
? t("lock.saveFailed", {
|
|
1432
|
-
s:
|
|
1396
|
+
s: sudo.vault.label || sudo.vault.backend,
|
|
1433
1397
|
err: res.vaultError,
|
|
1434
1398
|
})
|
|
1435
|
-
: t("lock.
|
|
1399
|
+
: t("lock.saved"),
|
|
1436
1400
|
);
|
|
1401
|
+
settleSudo(true);
|
|
1437
1402
|
return;
|
|
1438
1403
|
}
|
|
1439
1404
|
err.textContent = res.error;
|
|
@@ -1447,7 +1412,7 @@ function openLock() {
|
|
|
1447
1412
|
}
|
|
1448
1413
|
}
|
|
1449
1414
|
};
|
|
1450
|
-
|
|
1415
|
+
pop.querySelector("form").addEventListener("submit", onSubmit);
|
|
1451
1416
|
document.body.appendChild(pop);
|
|
1452
1417
|
const underModal = [...document.querySelectorAll("dialog")].some(
|
|
1453
1418
|
(d) => d !== pop && d.open && d.matches(":modal"),
|
|
@@ -1461,37 +1426,37 @@ function openLock() {
|
|
|
1461
1426
|
} catch {
|
|
1462
1427
|
pop.setAttribute("open", "");
|
|
1463
1428
|
}
|
|
1464
|
-
//
|
|
1429
|
+
// Escape (or a modal's cancel) says no: the action doesn't run again
|
|
1430
|
+
// (its run already said why it failed), and the chip goes.
|
|
1431
|
+
const decline = () => {
|
|
1432
|
+
closeOverlay(true);
|
|
1433
|
+
settleSudo(false);
|
|
1434
|
+
};
|
|
1465
1435
|
pop.addEventListener("cancel", (e) => {
|
|
1466
1436
|
e.preventDefault();
|
|
1467
|
-
|
|
1437
|
+
decline();
|
|
1468
1438
|
});
|
|
1469
1439
|
const r = chip.getBoundingClientRect();
|
|
1470
1440
|
pop.style.top = `${r.bottom + 6}px`;
|
|
1471
1441
|
pop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - pop.offsetWidth - 8))}px`;
|
|
1472
1442
|
chip.setAttribute("aria-expanded", "true");
|
|
1473
1443
|
chip.setAttribute("aria-controls", pop.id);
|
|
1474
|
-
const onDown = (e) => {
|
|
1475
|
-
if (!pop.contains(e.target) && !chip.contains(e.target))
|
|
1476
|
-
closeOverlay(false);
|
|
1477
|
-
};
|
|
1478
1444
|
pop.addEventListener("keydown", (e) => {
|
|
1479
1445
|
if (e.key === "Escape") {
|
|
1480
1446
|
e.preventDefault();
|
|
1481
1447
|
e.stopPropagation();
|
|
1482
|
-
|
|
1448
|
+
decline();
|
|
1483
1449
|
}
|
|
1484
1450
|
});
|
|
1485
|
-
document.addEventListener("mousedown", onDown, true);
|
|
1486
1451
|
overlay = {
|
|
1487
1452
|
kind: "lock",
|
|
1488
1453
|
el: pop,
|
|
1489
|
-
|
|
1454
|
+
// Another overlay closing it leaves the ask waiting: the chip
|
|
1455
|
+
// stays, and opens the prompt again.
|
|
1490
1456
|
close(refocus) {
|
|
1491
|
-
document.removeEventListener("mousedown", onDown, true);
|
|
1492
1457
|
pop.remove();
|
|
1493
1458
|
chip.setAttribute("aria-expanded", "false");
|
|
1494
|
-
if (refocus && chip.isConnected) chip.focus();
|
|
1459
|
+
if (refocus && chip.isConnected && !chip.hidden) chip.focus();
|
|
1495
1460
|
},
|
|
1496
1461
|
};
|
|
1497
1462
|
(
|
|
@@ -1531,13 +1496,13 @@ export function initTopbar(opts = {}) {
|
|
|
1531
1496
|
$("den-lock")?.addEventListener("click", () =>
|
|
1532
1497
|
overlay?.kind === "lock" ? closeOverlay(true) : openLock(),
|
|
1533
1498
|
);
|
|
1534
|
-
// LK-2: back on this tab, the
|
|
1499
|
+
// LK-2: back on this tab while an ask waits, the password may have
|
|
1500
|
+
// been given elsewhere (another tab or device): the action goes on.
|
|
1535
1501
|
document.addEventListener("visibilitychange", () => {
|
|
1536
|
-
if (document.visibilityState !== "visible" ||
|
|
1502
|
+
if (document.visibilityState !== "visible" || !sudo.waiting) return;
|
|
1537
1503
|
if (sh()?.apiOff?.() === true) return;
|
|
1538
1504
|
void getJson("/api/sudo-password/status", null).then((st) => {
|
|
1539
|
-
if (st
|
|
1540
|
-
setUnlocked(true, !!st.passwordless && !st.set);
|
|
1505
|
+
if (st?.set) settleSudo(true);
|
|
1541
1506
|
});
|
|
1542
1507
|
});
|
|
1543
1508
|
document.addEventListener("keydown", (e) => {
|
|
@@ -1549,7 +1514,6 @@ export function initTopbar(opts = {}) {
|
|
|
1549
1514
|
closeOverlay(true);
|
|
1550
1515
|
}
|
|
1551
1516
|
});
|
|
1552
|
-
void initLock();
|
|
1553
1517
|
}
|
|
1554
1518
|
|
|
1555
1519
|
/** For tests and plugins: open an overlay by name. */
|
|
@@ -25,7 +25,7 @@ export const EN_FALLBACK = {
|
|
|
25
25
|
"ws.viewTitle":
|
|
26
26
|
"View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing.",
|
|
27
27
|
"ws.viewTitleFocus":
|
|
28
|
-
"View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and
|
|
28
|
+
"View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and the CLI default ({focus}) stays the target for commands.",
|
|
29
29
|
};
|
|
30
30
|
function enT(key, vars) {
|
|
31
31
|
return String(EN_FALLBACK[key] ?? key).replace(/\{(\w+)\}/g, (m, k) =>
|
|
@@ -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 });
|