@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +151 -28
  4. package/docs/reference.md +177 -0
  5. package/package.json +1 -1
  6. package/src/shared/agents/den-claude.ts +57 -12
  7. package/src/shared/agents/generic.ts +1 -0
  8. package/src/shared/agents/runtime-search.ts +416 -0
  9. package/src/shared/agents/runtime.ts +26 -1
  10. package/src/shared/agents/runtimes.ts +30 -3
  11. package/src/shared/den-config.ts +88 -3
  12. package/src/shared/edge/render.ts +31 -0
  13. package/src/shared/sessions/client.ts +7 -0
  14. package/src/shared/sessions/den-pty.ts +10 -2
  15. package/src/shared/sessions/host.ts +33 -8
  16. package/src/shared/sessions/types.ts +8 -0
  17. package/src/ui/cli/agent-ops.ts +109 -21
  18. package/src/ui/cli/agents-command.ts +100 -4
  19. package/src/ui/cli/index.ts +33 -1
  20. package/src/ui/cli/sessions-command.ts +26 -9
  21. package/src/ui/cli/web-command.ts +60 -9
  22. package/src/ui/server/agent-routes.ts +11 -3
  23. package/src/ui/server/edge-exposure.ts +143 -0
  24. package/src/ui/server/index.ts +105 -23
  25. package/src/ui/server/public/agent-start.js +202 -0
  26. package/src/ui/server/public/agents-page.js +90 -18
  27. package/src/ui/server/public/agents-section.js +6 -18
  28. package/src/ui/server/public/agents.html +70 -0
  29. package/src/ui/server/public/i18n/es.js +11 -0
  30. package/src/ui/server/public/i18n/ja.js +11 -0
  31. package/src/ui/server/public/local-dev.html +43 -14
  32. package/src/ui/server/public/settings-registry.js +1 -1
  33. package/src/ui/server/public/shell.js +40 -0
  34. package/src/ui/server/public/topbar.js +127 -28
  35. package/src/ui/server/request-guard.ts +122 -7
  36. 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: true
1522
- // when unlocked; otherwise the lock popover opens on its password
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 beyond this machine (the edge, or a LAN address); on localhost nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
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), isUnlocked(),
19
- * onLockChange(fn), setServiceRunner(fn) (the page that runs Start and
20
- * Restart itself), takePendingAction().
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
- * Local Dev's lock rule, for every page: true when unlocked; otherwise the
1139
- * lock popover opens on its password field and the action doesn't run.
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 (lock.unlocked) return true;
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
- const pop = document.createElement("div");
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 r = await fetch("/api/sudo-password", {
1339
- method: "POST",
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 && data.vaultError
1430
+ saveToVault && res.vaultError
1352
1431
  ? t("lock.saveFailed", {
1353
1432
  s: lock.vault.label || lock.vault.backend,
1354
- err: data.vaultError,
1433
+ err: res.vaultError,
1355
1434
  })
1356
1435
  : t("lock.session"),
1357
1436
  );
1358
1437
  return;
1359
1438
  }
1360
- // A wrong password is said in the page's language; another refusal
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 (the edge hostname, a
23
- * LAN name, or a peer that isn't loopback) every route needs a session
24
- * or a bearer. Otherwise 401 `signin_required`. On loopback no route
25
- * does, shell-grade ones included (Ward, 2026-11-06: the lock, R8, is
26
- * the only prompt there; Host, Origin and CSRF cover CSRF and DNS
27
- * rebinding). SIGNIN_FREE routes never need one.
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
- const beyond = via !== "loopback" || !peerLoopback;
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",