@monster-orange/den 0.4.1 → 0.4.3
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 +142 -0
- package/README.md +88 -158
- package/docs/distributions.md +151 -28
- package/docs/reference.md +177 -0
- package/package.json +1 -1
- package/src/shared/agents/den-claude.ts +57 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +416 -0
- package/src/shared/agents/runtime.ts +26 -1
- package/src/shared/agents/runtimes.ts +30 -3
- package/src/shared/den-config.ts +88 -3
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/sessions/client.ts +7 -0
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/host.ts +33 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/ui/cli/agent-ops.ts +109 -21
- package/src/ui/cli/agents-command.ts +100 -4
- package/src/ui/cli/index.ts +33 -1
- package/src/ui/cli/sessions-command.ts +26 -9
- package/src/ui/cli/web-command.ts +60 -9
- package/src/ui/server/agent-routes.ts +11 -3
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +105 -23
- package/src/ui/server/public/agent-start.js +202 -0
- package/src/ui/server/public/agents-page.js +90 -18
- package/src/ui/server/public/agents-section.js +6 -18
- package/src/ui/server/public/agents.html +70 -0
- package/src/ui/server/public/i18n/es.js +11 -0
- package/src/ui/server/public/i18n/ja.js +11 -0
- package/src/ui/server/public/local-dev.html +43 -14
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +40 -0
- package/src/ui/server/public/topbar.js +127 -28
- package/src/ui/server/request-guard.ts +122 -7
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -1371,6 +1371,34 @@
|
|
|
1371
1371
|
}
|
|
1372
1372
|
.agent-form select {
|
|
1373
1373
|
max-width: 320px;
|
|
1374
|
+
min-width: 0;
|
|
1375
|
+
}
|
|
1376
|
+
.agent-form-help {
|
|
1377
|
+
flex-basis: 100%;
|
|
1378
|
+
margin: 0;
|
|
1379
|
+
font-size: 12px;
|
|
1380
|
+
color: var(--muted);
|
|
1381
|
+
overflow-wrap: anywhere;
|
|
1382
|
+
}
|
|
1383
|
+
.agent-form-help[hidden],
|
|
1384
|
+
.agent-form-ready[hidden] {
|
|
1385
|
+
display: none;
|
|
1386
|
+
}
|
|
1387
|
+
/* AG-0.2: the runtime it will run, or why it can't (the mock's .helpl). */
|
|
1388
|
+
.agent-form-ready {
|
|
1389
|
+
flex-basis: 100%;
|
|
1390
|
+
order: 1;
|
|
1391
|
+
margin: 0;
|
|
1392
|
+
font-size: 12px;
|
|
1393
|
+
line-height: 1.4;
|
|
1394
|
+
color: var(--faint);
|
|
1395
|
+
overflow-wrap: anywhere;
|
|
1396
|
+
}
|
|
1397
|
+
.agent-form-ready[data-tone="warn"] {
|
|
1398
|
+
color: var(--warn);
|
|
1399
|
+
}
|
|
1400
|
+
.agent-form-ready .mono {
|
|
1401
|
+
font-family: var(--mono);
|
|
1374
1402
|
}
|
|
1375
1403
|
.agent-cwd {
|
|
1376
1404
|
max-width: 280px;
|
|
@@ -1518,8 +1546,9 @@
|
|
|
1518
1546
|
.replace(/"/g, """);
|
|
1519
1547
|
|
|
1520
1548
|
// ── The lock (R8): the shell's lock chip and popover (topbar.js) ──────────
|
|
1521
|
-
// requireSudo() is this page's lock rule, shared with every page
|
|
1522
|
-
// when unlocked
|
|
1549
|
+
// requireSudo() is this page's lock rule, shared with every page
|
|
1550
|
+
// (async): true when unlocked, after asking the server and the vault
|
|
1551
|
+
// (LK-2, LK-3); otherwise the lock popover opens on its password
|
|
1523
1552
|
// field and the command doesn't run.
|
|
1524
1553
|
function requireSudo() {
|
|
1525
1554
|
return requireUnlock();
|
|
@@ -3021,8 +3050,8 @@
|
|
|
3021
3050
|
|
|
3022
3051
|
// ── Workspace-level command (SSE) ─────────────────────────────────────────
|
|
3023
3052
|
// Routes output to each service's own tab as `label` events arrive.
|
|
3024
|
-
window.runWorkspaceSync = function () {
|
|
3025
|
-
if (!requireSudo()) return;
|
|
3053
|
+
window.runWorkspaceSync = async function () {
|
|
3054
|
+
if (!(await requireSudo())) return;
|
|
3026
3055
|
setAllButtons(true);
|
|
3027
3056
|
setWsStatus("syncing…");
|
|
3028
3057
|
const es = new EventSource("/api/workspace/sync");
|
|
@@ -3073,8 +3102,8 @@
|
|
|
3073
3102
|
// rather than a shared pane, so it sits with everything else for that
|
|
3074
3103
|
// system. Buttons are not globally disabled: it is one repo.
|
|
3075
3104
|
const _syncing = new Set();
|
|
3076
|
-
window.runRepoSync = function (repo) {
|
|
3077
|
-
if (!requireSudo()) return;
|
|
3105
|
+
window.runRepoSync = async function (repo) {
|
|
3106
|
+
if (!(await requireSudo())) return;
|
|
3078
3107
|
if (_syncing.has(repo)) return; // already in flight — ignore re-entry
|
|
3079
3108
|
_syncing.add(repo);
|
|
3080
3109
|
|
|
@@ -3126,8 +3155,8 @@
|
|
|
3126
3155
|
};
|
|
3127
3156
|
};
|
|
3128
3157
|
|
|
3129
|
-
window.runWorkspaceCmd = function (cmd, addresses) {
|
|
3130
|
-
if (!requireSudo()) return;
|
|
3158
|
+
window.runWorkspaceCmd = async function (cmd, addresses) {
|
|
3159
|
+
if (!(await requireSudo())) return;
|
|
3131
3160
|
const only =
|
|
3132
3161
|
Array.isArray(addresses) && addresses.length
|
|
3133
3162
|
? `&addresses=${encodeURIComponent(addresses.join(","))}`
|
|
@@ -3240,8 +3269,8 @@
|
|
|
3240
3269
|
// is derived on the SERVER (see the `seed` param in /api/workspace/exec):
|
|
3241
3270
|
// its rules are load-bearing, and the already-healthy skip it relies on is
|
|
3242
3271
|
// suppressed whenever an explicit address list is sent.
|
|
3243
|
-
window.runUpWithDeps = function (repo) {
|
|
3244
|
-
if (!requireSudo()) return;
|
|
3272
|
+
window.runUpWithDeps = async function (repo) {
|
|
3273
|
+
if (!(await requireSudo())) return;
|
|
3245
3274
|
void runWorkspaceCmdAsync("up", null, {
|
|
3246
3275
|
seed: repo,
|
|
3247
3276
|
undismiss: true,
|
|
@@ -3252,16 +3281,16 @@
|
|
|
3252
3281
|
// same closure as "up with deps", reversed. Derived server-side for the
|
|
3253
3282
|
// same reason as its counterpart: the already-stopped skip is suppressed
|
|
3254
3283
|
// when an explicit address list is sent.
|
|
3255
|
-
window.runDownWithDeps = function (repo) {
|
|
3256
|
-
if (!requireSudo()) return;
|
|
3284
|
+
window.runDownWithDeps = async function (repo) {
|
|
3285
|
+
if (!(await requireSudo())) return;
|
|
3257
3286
|
void runWorkspaceCmdAsync("down", null, {
|
|
3258
3287
|
seed: repo,
|
|
3259
3288
|
undismiss: true,
|
|
3260
3289
|
});
|
|
3261
3290
|
};
|
|
3262
3291
|
|
|
3263
|
-
window.runCmd = function (address, repo, service, cmd) {
|
|
3264
|
-
if (!requireSudo()) return;
|
|
3292
|
+
window.runCmd = async function (address, repo, service, cmd) {
|
|
3293
|
+
if (!(await requireSudo())) return;
|
|
3265
3294
|
// Explicit single-service action — undo any prior dismissal so its pane returns.
|
|
3266
3295
|
clearDismissed(address);
|
|
3267
3296
|
// up/restart/down go through the workspace-exec runner: it uses the full
|
|
@@ -472,7 +472,7 @@ export const DEN_SETTINGS = [
|
|
|
472
472
|
category: "cli",
|
|
473
473
|
label: "Browser sign-in",
|
|
474
474
|
description:
|
|
475
|
-
"Signing in is needed only when this server is reached
|
|
475
|
+
"Signing in is needed only when this server is reached from another machine (through the edge, or a LAN address); on localhost, and through the edge from this machine, nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
|
|
476
476
|
keywords: ["sign in", "sign out", "login", "logout", "session", "browser"],
|
|
477
477
|
scope: "user",
|
|
478
478
|
control: {
|
|
@@ -444,6 +444,7 @@
|
|
|
444
444
|
"ag.persona": "Persona",
|
|
445
445
|
"ag.runtime": "Runtime",
|
|
446
446
|
"ag.runtime.auto": "The persona's",
|
|
447
|
+
"ag.runtime.default": "{name} (persona default)",
|
|
447
448
|
"ag.unavailable": "unavailable",
|
|
448
449
|
"ag.go": "Start",
|
|
449
450
|
"ag.starting": "Starting…",
|
|
@@ -484,6 +485,16 @@
|
|
|
484
485
|
"ag.pal": "Agents › {ws} / {name}",
|
|
485
486
|
"ag.palStart": "Start agent…",
|
|
486
487
|
"ag.cap": "Up to 4 panes: {id}'s closed to make room (it keeps running)",
|
|
488
|
+
"ag.rt.ready": "Starts {name} {version} from",
|
|
489
|
+
"ag.rt.readyNoVer": "Starts {name} from",
|
|
490
|
+
"ag.rt.notFound": "{name} not found.",
|
|
491
|
+
"ag.rt.searched": "Searched:",
|
|
492
|
+
"ag.rt.more": "and {n} more",
|
|
493
|
+
"ag.rt.nowhere": "no directories",
|
|
494
|
+
"ag.rt.refused": "{name} can't start: {why}.",
|
|
495
|
+
"ag.rt.fix":
|
|
496
|
+
"Install it, or set {key} to its path in {file}, or run {cmd}.",
|
|
497
|
+
"ag.rt.unknown": "No runtime {id} here.",
|
|
487
498
|
};
|
|
488
499
|
// The languages the UI has a catalog for, each named in itself (a name is
|
|
489
500
|
// never translated). The server's list is ui-language.ts's UI_LANGUAGES.
|
|
@@ -3361,6 +3372,31 @@
|
|
|
3361
3372
|
}
|
|
3362
3373
|
}
|
|
3363
3374
|
|
|
3375
|
+
// 0.4.3: the edge is reachable from other machines (the server fills
|
|
3376
|
+
// data-edge-exposed on #den-shell). One line; dismissed per browser.
|
|
3377
|
+
var EDGE_LS = "den.edgeExposed.dismissed";
|
|
3378
|
+
var edgeDismissed = false;
|
|
3379
|
+
try {
|
|
3380
|
+
edgeDismissed = localStorage.getItem(EDGE_LS) === "1";
|
|
3381
|
+
} catch (e) {
|
|
3382
|
+
edgeDismissed = false;
|
|
3383
|
+
}
|
|
3384
|
+
if (host.dataset.edgeExposed && !edgeDismissed)
|
|
3385
|
+
showBanner("edge-exposed", {
|
|
3386
|
+
text: host.dataset.edgeExposed,
|
|
3387
|
+
action: {
|
|
3388
|
+
label: t("api.close"),
|
|
3389
|
+
run: function () {
|
|
3390
|
+
try {
|
|
3391
|
+
localStorage.setItem(EDGE_LS, "1");
|
|
3392
|
+
} catch (e) {
|
|
3393
|
+
/* no storage: dismissed for this page only */
|
|
3394
|
+
}
|
|
3395
|
+
hideBanner("edge-exposed");
|
|
3396
|
+
},
|
|
3397
|
+
},
|
|
3398
|
+
});
|
|
3399
|
+
|
|
3364
3400
|
// The config fails validation (0.4.1): the server marks every page it
|
|
3365
3401
|
// serves while it does (data-config-invalid on #den-shell), and the first
|
|
3366
3402
|
// /api answer of 422 config_invalid marks it too. From then on the shell
|
|
@@ -3817,6 +3853,10 @@
|
|
|
3817
3853
|
" max-height: calc(100vh - 72px); overflow-y: auto; box-sizing: border-box;",
|
|
3818
3854
|
" font: 14px/1.45 var(--sans); color: var(--text); }",
|
|
3819
3855
|
".den-pop:focus { outline: none; }",
|
|
3856
|
+
// LK-1: the lock popover is a <dialog> in the top layer (a popover, or
|
|
3857
|
+
// a modal over another modal): placed by its top/left, no UA box.
|
|
3858
|
+
"dialog.den-pop { margin: 0; inset: auto; max-width: none; }",
|
|
3859
|
+
"dialog.den-pop::backdrop { background: transparent; }",
|
|
3820
3860
|
".den-pop :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }",
|
|
3821
3861
|
".den-pop .den-ml { font: 11px var(--mono); color: var(--faint); text-transform: uppercase;",
|
|
3822
3862
|
" letter-spacing: .07em; padding: 6px 8px 2px; }",
|
|
@@ -15,9 +15,18 @@
|
|
|
15
15
|
* form in a popover (Local Dev's sudo banner, moved)
|
|
16
16
|
*
|
|
17
17
|
* Exports for pages (through header.js): requireUnlock() (Local Dev's lock
|
|
18
|
-
* rule: true when unlocked, else the lock popover opens),
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* rule, async: true when unlocked, else the lock popover opens), and
|
|
19
|
+
* checkUnlock() and unlockWith(password) for a modal that asks inline
|
|
20
|
+
* (LK-1), isUnlocked(), onLockChange(fn), setServiceRunner(fn) (the page
|
|
21
|
+
* that runs Start and Restart itself), takePendingAction().
|
|
22
|
+
*
|
|
23
|
+
* The lock (LK, 0.4.3): the page's own idea of it is never the last word.
|
|
24
|
+
* When it thinks it's locked, an action asks the server again first
|
|
25
|
+
* (another tab or device may have unlocked it, LK-2), then, when a
|
|
26
|
+
* password is stored in the vault, loads it from there (LK-3, once a page,
|
|
27
|
+
* on any page; a page view alone never wakes the vault). The popover is in
|
|
28
|
+
* the top layer (a popover, or its own modal <dialog> when a modal is
|
|
29
|
+
* open), so no modal ever covers it (LK-1).
|
|
21
30
|
*
|
|
22
31
|
* The pure parts (statusSummary, statusChip, problemCards, drawerGroups,
|
|
23
32
|
* checkLine, rankCommands, runOf) take plain data, so tests need no
|
|
@@ -745,9 +754,9 @@ export function setServiceRunner(fn) {
|
|
|
745
754
|
* stores it for this tab (sessionStorage, not a link anyone could open)
|
|
746
755
|
* and Local Dev takes it once (takePendingAction).
|
|
747
756
|
*/
|
|
748
|
-
export function runServiceAction(cmd, svc) {
|
|
757
|
+
export async function runServiceAction(cmd, svc) {
|
|
749
758
|
if (serviceRunner) return serviceRunner(cmd, svc);
|
|
750
|
-
if (!requireUnlock()) return;
|
|
759
|
+
if (!(await requireUnlock())) return;
|
|
751
760
|
try {
|
|
752
761
|
sessionStorage.setItem(
|
|
753
762
|
SS_PENDING,
|
|
@@ -1135,15 +1144,88 @@ export function onLockChange(fn) {
|
|
|
1135
1144
|
}
|
|
1136
1145
|
|
|
1137
1146
|
/**
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
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.
|
|
1140
1188
|
*/
|
|
1141
|
-
export function requireUnlock() {
|
|
1142
|
-
if (
|
|
1189
|
+
export async function requireUnlock() {
|
|
1190
|
+
if (await checkUnlock()) return true;
|
|
1143
1191
|
openLock();
|
|
1144
1192
|
return false;
|
|
1145
1193
|
}
|
|
1146
1194
|
|
|
1195
|
+
/**
|
|
1196
|
+
* Unlock with `password` (the popover's form, or a modal's own field):
|
|
1197
|
+
* { ok, savedToVault?, vaultError?, error? }. On success every page
|
|
1198
|
+
* subscriber hears it (onLockChange). Never logs the password.
|
|
1199
|
+
*/
|
|
1200
|
+
export async function unlockWith(password, { saveToVault = false } = {}) {
|
|
1201
|
+
if (!password) return { ok: false, error: t("lock.wrong") };
|
|
1202
|
+
try {
|
|
1203
|
+
const r = await fetch("/api/sudo-password", {
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
headers: { "Content-Type": "application/json" },
|
|
1206
|
+
body: JSON.stringify({ password, saveToVault }),
|
|
1207
|
+
});
|
|
1208
|
+
const data = await r.json().catch(() => ({}));
|
|
1209
|
+
if (r.ok) {
|
|
1210
|
+
if (data.savedToVault) lock.vault.hasStored = true;
|
|
1211
|
+
setUnlocked(true);
|
|
1212
|
+
return {
|
|
1213
|
+
ok: true,
|
|
1214
|
+
savedToVault: !!data.savedToVault,
|
|
1215
|
+
...(data.vaultError ? { vaultError: data.vaultError } : {}),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
// A wrong password is said in the page's language; another refusal
|
|
1219
|
+
// is the server's own message.
|
|
1220
|
+
return {
|
|
1221
|
+
ok: false,
|
|
1222
|
+
error: r.status === 401 || !data.error ? t("lock.wrong") : data.error,
|
|
1223
|
+
};
|
|
1224
|
+
} catch {
|
|
1225
|
+
return { ok: false, error: t("lock.reqFailed") };
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1147
1229
|
function setUnlocked(unlocked, passwordless = false) {
|
|
1148
1230
|
const changed = lock.unlocked !== unlocked || !lock.known;
|
|
1149
1231
|
lock.known = true;
|
|
@@ -1250,6 +1332,7 @@ async function initLock() {
|
|
|
1250
1332
|
await checkVault();
|
|
1251
1333
|
if (!lock.vault.backendAvailable || !lock.vault.hasStored || lock.unlocked)
|
|
1252
1334
|
return;
|
|
1335
|
+
vaultTried = true;
|
|
1253
1336
|
const loaded = await getJson(
|
|
1254
1337
|
"/api/sudo-password/load-from-vault",
|
|
1255
1338
|
{ ok: false },
|
|
@@ -1300,7 +1383,10 @@ function openLock() {
|
|
|
1300
1383
|
closeOverlay(false);
|
|
1301
1384
|
sh()?.closeMenu?.();
|
|
1302
1385
|
if (!lock.unlocked) void checkVault();
|
|
1303
|
-
|
|
1386
|
+
// LK-1: in the top layer, so no modal covers it. Under an open modal
|
|
1387
|
+
// <dialog> only another modal is reachable (a popover there is inert),
|
|
1388
|
+
// so it opens as its own modal; elsewhere it is a popover.
|
|
1389
|
+
const pop = document.createElement("dialog");
|
|
1304
1390
|
pop.className = "den-pop den-lock-pop";
|
|
1305
1391
|
pop.id = "den-lock-pop";
|
|
1306
1392
|
pop.setAttribute("role", "dialog");
|
|
@@ -1335,38 +1421,25 @@ function openLock() {
|
|
|
1335
1421
|
err.hidden = true;
|
|
1336
1422
|
const saveToVault = !!(lock.vault.backendAvailable && saveCb?.checked);
|
|
1337
1423
|
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) {
|
|
1424
|
+
const res = await unlockWith(pw, { saveToVault });
|
|
1425
|
+
if (res.ok) {
|
|
1345
1426
|
input.value = "";
|
|
1346
|
-
if (data.savedToVault) lock.vault.hasStored = true;
|
|
1347
1427
|
forceForm = false;
|
|
1348
|
-
setUnlocked(true);
|
|
1349
1428
|
closeOverlay(true);
|
|
1350
1429
|
toast(
|
|
1351
|
-
saveToVault &&
|
|
1430
|
+
saveToVault && res.vaultError
|
|
1352
1431
|
? t("lock.saveFailed", {
|
|
1353
1432
|
s: lock.vault.label || lock.vault.backend,
|
|
1354
|
-
err:
|
|
1433
|
+
err: res.vaultError,
|
|
1355
1434
|
})
|
|
1356
1435
|
: t("lock.session"),
|
|
1357
1436
|
);
|
|
1358
1437
|
return;
|
|
1359
1438
|
}
|
|
1360
|
-
|
|
1361
|
-
// is the server's own message.
|
|
1362
|
-
err.textContent =
|
|
1363
|
-
r.status === 401 || !data.error ? t("lock.wrong") : data.error;
|
|
1439
|
+
err.textContent = res.error;
|
|
1364
1440
|
err.hidden = false;
|
|
1365
1441
|
input.value = "";
|
|
1366
1442
|
input.focus();
|
|
1367
|
-
} catch {
|
|
1368
|
-
err.textContent = t("lock.reqFailed");
|
|
1369
|
-
err.hidden = false;
|
|
1370
1443
|
} finally {
|
|
1371
1444
|
if (btn.isConnected) {
|
|
1372
1445
|
btn.textContent = t("lock.go");
|
|
@@ -1376,6 +1449,23 @@ function openLock() {
|
|
|
1376
1449
|
};
|
|
1377
1450
|
draw();
|
|
1378
1451
|
document.body.appendChild(pop);
|
|
1452
|
+
const underModal = [...document.querySelectorAll("dialog")].some(
|
|
1453
|
+
(d) => d !== pop && d.open && d.matches(":modal"),
|
|
1454
|
+
);
|
|
1455
|
+
try {
|
|
1456
|
+
if (underModal) pop.showModal();
|
|
1457
|
+
else if (typeof pop.showPopover === "function") {
|
|
1458
|
+
pop.setAttribute("popover", "manual");
|
|
1459
|
+
pop.showPopover();
|
|
1460
|
+
} else pop.show();
|
|
1461
|
+
} catch {
|
|
1462
|
+
pop.setAttribute("open", "");
|
|
1463
|
+
}
|
|
1464
|
+
// A cancel (Escape on a modal) closes it as the chip would.
|
|
1465
|
+
pop.addEventListener("cancel", (e) => {
|
|
1466
|
+
e.preventDefault();
|
|
1467
|
+
closeOverlay(true);
|
|
1468
|
+
});
|
|
1379
1469
|
const r = chip.getBoundingClientRect();
|
|
1380
1470
|
pop.style.top = `${r.bottom + 6}px`;
|
|
1381
1471
|
pop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - pop.offsetWidth - 8))}px`;
|
|
@@ -1441,6 +1531,15 @@ export function initTopbar(opts = {}) {
|
|
|
1441
1531
|
$("den-lock")?.addEventListener("click", () =>
|
|
1442
1532
|
overlay?.kind === "lock" ? closeOverlay(true) : openLock(),
|
|
1443
1533
|
);
|
|
1534
|
+
// LK-2: back on this tab, the lock may have been opened elsewhere.
|
|
1535
|
+
document.addEventListener("visibilitychange", () => {
|
|
1536
|
+
if (document.visibilityState !== "visible" || lock.unlocked) return;
|
|
1537
|
+
if (sh()?.apiOff?.() === true) return;
|
|
1538
|
+
void getJson("/api/sudo-password/status", null).then((st) => {
|
|
1539
|
+
if (st && (st.set || st.passwordless))
|
|
1540
|
+
setUnlocked(true, !!st.passwordless && !st.set);
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1444
1543
|
document.addEventListener("keydown", (e) => {
|
|
1445
1544
|
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "k") {
|
|
1446
1545
|
e.preventDefault();
|
|
@@ -19,12 +19,17 @@
|
|
|
19
19
|
* session's /api/sessions/<id>/*: agent start and resume mint tokens.
|
|
20
20
|
* 403 `agent_forbidden`. WS-3a (0.4.2) adds the workspace check at
|
|
21
21
|
* agentRefusal's seam.
|
|
22
|
-
* - Sign-in (§4, route-classes.ts): beyond loopback (
|
|
23
|
-
*
|
|
24
|
-
* or a bearer. Otherwise 401
|
|
25
|
-
* does, shell-grade ones
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* - Sign-in (§4, route-classes.ts): beyond loopback (a LAN name, a peer
|
|
23
|
+
* that isn't loopback, or the edge hostname for a client on another
|
|
24
|
+
* machine) every route needs a session or a bearer. Otherwise 401
|
|
25
|
+
* `signin_required`. On loopback no route does, shell-grade ones
|
|
26
|
+
* included (Ward, 2026-11-06: the lock, R8, is the only prompt there;
|
|
27
|
+
* Host, Origin and CSRF cover CSRF and DNS rebinding). SIGNIN_FREE
|
|
28
|
+
* routes never need one.
|
|
29
|
+
* - The edge counts as local (0.4.3, edgeClientIsLocal): the edge hostname
|
|
30
|
+
* from an edge peer gets loopback's treatment unless the edge's
|
|
31
|
+
* X-Forwarded-For names another machine, or this server is deliberately
|
|
32
|
+
* exposed (`exposed`: a non-loopback DEN_BIND_HOST bind).
|
|
28
33
|
* - Origin, on POST/PUT/PATCH/DELETE and every WebSocket upgrade: an
|
|
29
34
|
* allowed host (http or https). No Origin needs a valid bearer.
|
|
30
35
|
* Otherwise 403 `origin_rejected`.
|
|
@@ -45,6 +50,7 @@
|
|
|
45
50
|
import { randomBytes, timingSafeEqual, createHash } from "crypto";
|
|
46
51
|
import type * as http from "http";
|
|
47
52
|
import * as net from "net";
|
|
53
|
+
import * as os from "os";
|
|
48
54
|
import {
|
|
49
55
|
actorFromHeaders,
|
|
50
56
|
type Actor,
|
|
@@ -125,6 +131,18 @@ export interface GuardContext {
|
|
|
125
131
|
* server's port gets 421. Unset: taken as yes (a unit test).
|
|
126
132
|
*/
|
|
127
133
|
peerIsEdge?: (req: http.IncomingMessage) => boolean;
|
|
134
|
+
/**
|
|
135
|
+
* This machine's own addresses and container/VM bridges (localNet), for
|
|
136
|
+
* telling whether an edge request's client is this machine. Unset: none
|
|
137
|
+
* (a unit test): only loopback and the Docker ranges count as local.
|
|
138
|
+
*/
|
|
139
|
+
localNet?: () => LocalNet;
|
|
140
|
+
/**
|
|
141
|
+
* A deliberate network exposure (a non-loopback DEN_BIND_HOST bind other
|
|
142
|
+
* than the Docker bridge the edge uses on Linux): the edge hostname
|
|
143
|
+
* needs a sign-in too, as before 0.4.3.
|
|
144
|
+
*/
|
|
145
|
+
exposed?: boolean;
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
export type GuardResult =
|
|
@@ -263,6 +281,97 @@ export function isEdgePeer(addr: string | undefined): boolean {
|
|
|
263
281
|
);
|
|
264
282
|
}
|
|
265
283
|
|
|
284
|
+
/** An address without its zone, IPv4-mapped prefix or brackets, lowercase. */
|
|
285
|
+
function normAddress(a: string): string {
|
|
286
|
+
return a
|
|
287
|
+
.trim()
|
|
288
|
+
.toLowerCase()
|
|
289
|
+
.replace(/^\[|\]$/g, "")
|
|
290
|
+
.replace(/%.*$/, "")
|
|
291
|
+
.replace(/^::ffff:(?=\d+\.)/, "");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** This machine's own addresses, and the container/VM bridges it hosts. */
|
|
295
|
+
export interface LocalNet {
|
|
296
|
+
/** Every interface address (loopback, LAN, a VPN's such as Tailscale's 100.x). */
|
|
297
|
+
own: readonly string[];
|
|
298
|
+
/** IPv4 subnets of its container/VM bridges (Docker, OrbStack, VMs). */
|
|
299
|
+
bridges: readonly { address: string; netmask: string }[];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Interface names of container/VM bridges: their peers are this machine's. */
|
|
303
|
+
const BRIDGE_IFACE =
|
|
304
|
+
/^(docker\d*|br-|bridge\d+|vmnet|virbr|veth|cni|podman|vboxnet)/;
|
|
305
|
+
|
|
306
|
+
/** localNet from os.networkInterfaces() (or a given table, in a test). */
|
|
307
|
+
export function localNet(
|
|
308
|
+
ifaces: ReturnType<typeof os.networkInterfaces> = os.networkInterfaces(),
|
|
309
|
+
): LocalNet {
|
|
310
|
+
const own: string[] = [];
|
|
311
|
+
const bridges: { address: string; netmask: string }[] = [];
|
|
312
|
+
for (const [name, list] of Object.entries(ifaces))
|
|
313
|
+
for (const a of list ?? []) {
|
|
314
|
+
own.push(normAddress(a.address));
|
|
315
|
+
if (a.family === "IPv4" && BRIDGE_IFACE.test(name))
|
|
316
|
+
bridges.push({ address: a.address, netmask: a.netmask });
|
|
317
|
+
}
|
|
318
|
+
return { own: [...new Set(own)], bridges };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function ipv4(a: string): number {
|
|
322
|
+
return a.split(".").reduce((n, o) => n * 256 + Number(o), 0);
|
|
323
|
+
}
|
|
324
|
+
function inSubnet(a: string, net4: { address: string; netmask: string }) {
|
|
325
|
+
if (!net.isIPv4(a) || !net.isIPv4(net4.address)) return false;
|
|
326
|
+
const m = ipv4(net4.netmask);
|
|
327
|
+
// Unsigned compare of the masked halves (no 32-bit sign games).
|
|
328
|
+
const lo = (x: number) => x - (x % (2 ** 32 - m));
|
|
329
|
+
return m > 0 && lo(ipv4(a)) === lo(ipv4(net4.address));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The original client X-Forwarded-For names: its left-most entry, as an
|
|
334
|
+
* IP (a port or brackets dropped), or undefined when there is none.
|
|
335
|
+
*/
|
|
336
|
+
export function forwardedClient(xff: string | undefined): string | undefined {
|
|
337
|
+
const first = xff?.split(",")[0]?.trim();
|
|
338
|
+
if (!first) return undefined;
|
|
339
|
+
let a = first;
|
|
340
|
+
const b = /^\[([^\]]+)\](?::\d+)?$/.exec(a);
|
|
341
|
+
if (b) a = b[1];
|
|
342
|
+
else if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(a)) a = a.replace(/:\d+$/, "");
|
|
343
|
+
a = normAddress(a);
|
|
344
|
+
return net.isIP(a) ? a : undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The edge counts as local (0.4.3): whether an edge request (the edge
|
|
349
|
+
* hostname from an edge peer) comes from this machine, by the left-most
|
|
350
|
+
* X-Forwarded-For entry (the original client):
|
|
351
|
+
* - loopback, or one of this machine's own interface addresses (its LAN
|
|
352
|
+
* or VPN address, a Tailscale 100.x one included): local;
|
|
353
|
+
* - missing, unreadable, or a container/VM network's address (the Docker
|
|
354
|
+
* ranges isEdgePeer takes, a subnet of one of this machine's bridges,
|
|
355
|
+
* one of the edge's own Docker networks, or OrbStack's 0.250.250.254):
|
|
356
|
+
* we can't tell (Docker Desktop and OrbStack on macOS hand the
|
|
357
|
+
* edge their gateway's address, and a TCP passthrough in front hides the
|
|
358
|
+
* client), so local;
|
|
359
|
+
* - anything else: another machine, beyond loopback (sign-in required).
|
|
360
|
+
* The edge's own page warns when it is reachable beyond this machine
|
|
361
|
+
* (edge-exposure.ts): with no sign-in, anyone who reaches it is local.
|
|
362
|
+
*/
|
|
363
|
+
export function edgeClientIsLocal(
|
|
364
|
+
xff: string | undefined,
|
|
365
|
+
local: LocalNet = { own: [], bridges: [] },
|
|
366
|
+
): boolean {
|
|
367
|
+
const c = forwardedClient(xff);
|
|
368
|
+
if (!c || isEdgePeer(c) || local.own.includes(c)) return true;
|
|
369
|
+
// 0.0.0.0/8 ("this network") is never a client on another machine:
|
|
370
|
+
// OrbStack presents the host to its containers as 0.250.250.254.
|
|
371
|
+
if (net.isIPv4(c) && c.startsWith("0.")) return true;
|
|
372
|
+
return local.bridges.some((b) => inSubnet(c, b));
|
|
373
|
+
}
|
|
374
|
+
|
|
266
375
|
/**
|
|
267
376
|
* Whether the request came over HTTPS: the socket's own encryption, or,
|
|
268
377
|
* only for the edge hostname from an edge peer, `X-Forwarded-Proto: https`.
|
|
@@ -370,7 +479,13 @@ export function checkRequest(
|
|
|
370
479
|
"127.0.0.1, [::1] with its port, or its edge hostname).",
|
|
371
480
|
};
|
|
372
481
|
const peerLoopback = ctx.peerIsLoopback ? ctx.peerIsLoopback(req) : true;
|
|
373
|
-
|
|
482
|
+
// The edge from this machine (edgeClientIsLocal) is local, unless this
|
|
483
|
+
// server is deliberately exposed; a LAN name or peer is beyond.
|
|
484
|
+
const beyond =
|
|
485
|
+
via === "edge"
|
|
486
|
+
? !!ctx.exposed ||
|
|
487
|
+
!edgeClientIsLocal(header(req, "x-forwarded-for"), ctx.localNet?.())
|
|
488
|
+
: via !== "loopback" || !peerLoopback;
|
|
374
489
|
|
|
375
490
|
let principal: Principal | null = null;
|
|
376
491
|
const auth = header(req, "authorization");
|
|
@@ -34,6 +34,8 @@ export const UI_MODULES: readonly string[] = [
|
|
|
34
34
|
"/error-page.js",
|
|
35
35
|
"/agents-section.js",
|
|
36
36
|
"/agents-page.js",
|
|
37
|
+
// The Start agent form's choices, shared by both (AG-10.0).
|
|
38
|
+
"/agent-start.js",
|
|
37
39
|
// The UI's catalogs other than English (R19; en is in shell.js).
|
|
38
40
|
"/i18n/ja.js",
|
|
39
41
|
"/i18n/es.js",
|