@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +313 -50
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +53 -12
  8. package/src/shared/agents/generic.ts +1 -0
  9. package/src/shared/agents/runtime-search.ts +543 -0
  10. package/src/shared/agents/runtime.ts +43 -1
  11. package/src/shared/agents/runtimes.ts +53 -4
  12. package/src/shared/auth/sudo-vault.ts +16 -0
  13. package/src/shared/den-config.ts +92 -2
  14. package/src/shared/edge/render.ts +31 -0
  15. package/src/shared/edge/status.ts +134 -0
  16. package/src/shared/environment-manager/health.ts +82 -8
  17. package/src/shared/environment-manager/workspaces.ts +54 -0
  18. package/src/shared/health/not-checked.ts +42 -0
  19. package/src/shared/health/rollup.ts +4 -0
  20. package/src/shared/process/spawn-streaming.ts +8 -3
  21. package/src/shared/sessions/client.ts +5 -1
  22. package/src/shared/sessions/host.ts +29 -1
  23. package/src/shared/sessions/protocol.ts +6 -1
  24. package/src/shared/sessions/types.ts +6 -0
  25. package/src/shared/stacks/status.ts +24 -0
  26. package/src/shared/strategies/registry.ts +5 -0
  27. package/src/shared/types/index.ts +6 -0
  28. package/src/ui/cli/agent-ops.ts +298 -19
  29. package/src/ui/cli/agents-command.ts +129 -4
  30. package/src/ui/cli/edge-command.ts +16 -3
  31. package/src/ui/cli/health-display.ts +4 -1
  32. package/src/ui/cli/sessions-command.ts +9 -4
  33. package/src/ui/cli/web-command.ts +53 -8
  34. package/src/ui/mcp/scope.ts +23 -3
  35. package/src/ui/mcp/server.ts +31 -7
  36. package/src/ui/mcp/tools.ts +327 -20
  37. package/src/ui/mcp/views.ts +450 -0
  38. package/src/ui/server/agent-routes.ts +123 -26
  39. package/src/ui/server/edge-exposure.ts +143 -0
  40. package/src/ui/server/index.ts +171 -25
  41. package/src/ui/server/public/agent-start.js +241 -0
  42. package/src/ui/server/public/agents-page.js +246 -45
  43. package/src/ui/server/public/agents-section.js +7 -21
  44. package/src/ui/server/public/agents.html +55 -1
  45. package/src/ui/server/public/graph-model.js +2 -0
  46. package/src/ui/server/public/header.js +2 -3
  47. package/src/ui/server/public/health-rollup.js +20 -8
  48. package/src/ui/server/public/i18n/es.js +26 -13
  49. package/src/ui/server/public/i18n/ja.js +25 -13
  50. package/src/ui/server/public/index.html +17 -4
  51. package/src/ui/server/public/local-dev.html +62 -22
  52. package/src/ui/server/public/services.html +21 -5
  53. package/src/ui/server/public/settings-page.js +3 -2
  54. package/src/ui/server/public/settings-registry.js +226 -1
  55. package/src/ui/server/public/settings.html +90 -1
  56. package/src/ui/server/public/shell.js +67 -22
  57. package/src/ui/server/public/topbar.js +217 -158
  58. package/src/ui/server/readonly-routes.ts +29 -5
  59. package/src/ui/server/request-guard.ts +123 -8
  60. package/src/ui/server/route-classes.ts +14 -2
  61. package/src/ui/server/session-routes.ts +9 -1
  62. package/src/ui/server/sudo-store.ts +170 -6
  63. package/src/ui/server/ui-assets.ts +2 -0
  64. package/src/ui/server/workspace-routes.ts +10 -3
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The edge counts as local (0.4.3): with no sign-in through the edge, who
3
+ * can reach the edge can use terminals. When Den can tell the edge is
4
+ * reachable beyond this machine it says so, once in web.log and in a
5
+ * banner on every page (the shell, dismissible per browser).
6
+ *
7
+ * How it tells (read-only, once at start):
8
+ * - an edge hostname that routes to Den resolves to an address that isn't
9
+ * loopback (a LAN or a VPN address, such as Tailscale's 100.x); or
10
+ * - the edge's container publishes a port on every interface (a
11
+ * PortBindings HostIp of "", 0.0.0.0 or ::), from `docker inspect`.
12
+ * Neither (an edge on `*.localhost`, published on 127.0.0.1, or not
13
+ * running): no warning.
14
+ */
15
+ import { execFile } from "child_process";
16
+ import { promises as dns } from "dns";
17
+ import { isLoopbackAddress } from "./request-guard.js";
18
+
19
+ export const EDGE_EXPOSED_MESSAGE =
20
+ "Your edge is reachable from other machines; anyone who can reach it can use terminals. Auth is coming.";
21
+
22
+ export interface ExposureDeps {
23
+ /** Every address a hostname resolves to (none when it doesn't). */
24
+ lookup?: (host: string) => Promise<string[]>;
25
+ /** The edge container's `docker inspect` PortBindings, or null. */
26
+ portBindings?: (container: string) => Promise<unknown>;
27
+ }
28
+
29
+ async function lookupAll(host: string): Promise<string[]> {
30
+ try {
31
+ return (await dns.lookup(host, { all: true })).map((a) => a.address);
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ function inspectPortBindings(container: string): Promise<unknown> {
38
+ return new Promise((resolve) => {
39
+ execFile(
40
+ "docker",
41
+ ["inspect", "--format", "{{json .HostConfig.PortBindings}}", container],
42
+ { timeout: 5000 },
43
+ (err, stdout) => {
44
+ if (err) return resolve(null);
45
+ try {
46
+ resolve(JSON.parse(String(stdout)));
47
+ } catch {
48
+ resolve(null);
49
+ }
50
+ },
51
+ );
52
+ });
53
+ }
54
+
55
+ /** A published port bound on every interface, from PortBindings. */
56
+ export function publishedOnAllInterfaces(bindings: unknown): string | null {
57
+ if (!bindings || typeof bindings !== "object") return null;
58
+ for (const [port, list] of Object.entries(bindings as object)) {
59
+ if (!Array.isArray(list)) continue;
60
+ for (const b of list as { HostIp?: string; HostPort?: string }[]) {
61
+ const ip = String(b?.HostIp ?? "");
62
+ if (ip === "" || ip === "0.0.0.0" || ip === "::")
63
+ return `the edge publishes ${b?.HostPort || port} on every interface`;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Why the edge is reachable beyond this machine, or null when Den can't
71
+ * tell that it is.
72
+ */
73
+ export async function edgeExposure(
74
+ hosts: readonly string[],
75
+ container: string | null,
76
+ deps: ExposureDeps = {},
77
+ ): Promise<string | null> {
78
+ const lookup = deps.lookup ?? lookupAll;
79
+ for (const h of hosts) {
80
+ const away = (await lookup(h)).find((a) => !isLoopbackAddress(a));
81
+ if (away) return `${h} resolves to ${away}`;
82
+ }
83
+ if (!container || hosts.length === 0) return null;
84
+ const bindings = await (deps.portBindings ?? inspectPortBindings)(container);
85
+ return publishedOnAllInterfaces(bindings);
86
+ }
87
+
88
+ /**
89
+ * The IPv4 subnets of the edge's Docker networks (`<org>-edge` and the
90
+ * external networks it joins), from `docker network inspect`, read-only,
91
+ * once at start. On OrbStack and Docker Desktop a user-defined network is
92
+ * no host interface, so localNet can't see it: a proxy container on it, or
93
+ * its gateway, is this machine's container network ("can't tell", so
94
+ * local), not another machine. None when Docker or the network isn't
95
+ * there.
96
+ */
97
+ export async function edgeNetworkSubnets(
98
+ names: readonly string[],
99
+ inspect: (name: string) => Promise<unknown> = inspectNetworkIpam,
100
+ ): Promise<{ address: string; netmask: string }[]> {
101
+ const out: { address: string; netmask: string }[] = [];
102
+ for (const n of [...new Set(names)]) {
103
+ const ipam = await inspect(n);
104
+ if (!Array.isArray(ipam)) continue;
105
+ for (const c of ipam as { Subnet?: unknown }[]) {
106
+ const s = cidrToSubnet(String(c?.Subnet ?? ""));
107
+ if (s) out.push(s);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** "192.168.214.0/24" → { address, netmask }; null for IPv6 or junk. */
114
+ export function cidrToSubnet(
115
+ cidr: string,
116
+ ): { address: string; netmask: string } | null {
117
+ const m = /^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/.exec(cidr.trim());
118
+ if (!m) return null;
119
+ const bits = Number(m[2]);
120
+ if (bits < 8 || bits > 32) return null;
121
+ if (m[1].split(".").some((o) => Number(o) > 255)) return null;
122
+ const mask = (0xffffffff << (32 - bits)) >>> 0;
123
+ const netmask = [24, 16, 8, 0].map((s) => (mask >>> s) & 255).join(".");
124
+ return { address: m[1], netmask };
125
+ }
126
+
127
+ function inspectNetworkIpam(name: string): Promise<unknown> {
128
+ return new Promise((resolve) => {
129
+ execFile(
130
+ "docker",
131
+ ["network", "inspect", "--format", "{{json .IPAM.Config}}", name],
132
+ { timeout: 5000 },
133
+ (err, stdout) => {
134
+ if (err) return resolve(null);
135
+ try {
136
+ resolve(JSON.parse(String(stdout)));
137
+ } catch {
138
+ resolve(null);
139
+ }
140
+ },
141
+ );
142
+ });
143
+ }
@@ -17,6 +17,7 @@ import {
17
17
  type ContainerHealthTarget,
18
18
  } from "../../shared/stacks/compose-health.js";
19
19
  import { healthCheckFields, type HealthCheckType } from "./health-row.js";
20
+ import { notCheckedReason } from "../../shared/health/not-checked.js";
20
21
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
21
22
  import * as http from "http";
22
23
  import * as net from "net";
@@ -162,6 +163,7 @@ import {
162
163
  saveSudoToVault,
163
164
  sudoRef,
164
165
  sudoVaultStatus,
166
+ sudoVaultWhere,
165
167
  SUDO_REF_NAME,
166
168
  } from "../../shared/auth/sudo-vault.js";
167
169
  import {
@@ -184,8 +186,10 @@ import {
184
186
  bindRefusal,
185
187
  checkRequest,
186
188
  isEdgePeer,
189
+ isDockerBridgeAddress,
187
190
  isLoopbackAddress,
188
191
  isLoopbackBind,
192
+ localNet,
189
193
  newCsrfToken,
190
194
  principalOf,
191
195
  requestIsHttps,
@@ -273,6 +277,10 @@ interface HealthResult {
273
277
  pending?: boolean;
274
278
  status?: number;
275
279
  error?: string;
280
+ /** A 3xx answer's target (MCP-2.4): ok null, not down. */
281
+ redirect?: string;
282
+ /** With ok null and nothing checked: why not (MCP-2.4). */
283
+ why?: string;
276
284
  /** Where the app opens for this env (open-in-new-tab), or null. */
277
285
  openUrl?: string | null;
278
286
  /** Extra named links for this env. */
@@ -549,6 +557,13 @@ setHtmlFill((html) => {
549
557
  () => '<div id="den-shell" data-config-invalid="1"',
550
558
  );
551
559
  }
560
+ // 0.4.3: every page says when the edge is reachable from other machines.
561
+ if (EDGE_EXPOSED)
562
+ out = out.replace(
563
+ '<div id="den-shell"',
564
+ () =>
565
+ `<div id="den-shell" data-edge-exposed="${escAttr(EDGE_EXPOSED_MESSAGE)}"`,
566
+ );
552
567
  // MD-2: every page says when the engine on disk is not the one running.
553
568
  const engine = engineCheck();
554
569
  if (engine.stale)
@@ -707,12 +722,14 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
707
722
  import {
708
723
  setSudoPassword,
709
724
  clearSudoPassword,
725
+ clearSudoShims,
710
726
  closeLockForThisRequest,
711
727
  inRequestLockScope,
712
728
  getSudoEnv,
713
729
  prewarmSudo,
714
730
  hasSudoPassword,
715
731
  hasPasswordlessSudo,
732
+ watchSudo,
716
733
  } from "./sudo-store.js";
717
734
  import {
718
735
  guardStdio,
@@ -721,6 +738,9 @@ import {
721
738
  requestShutdown,
722
739
  } from "./shutdown-guard.js";
723
740
 
741
+ /** This server saved the admin password to the vault or loaded it from there. */
742
+ let sudoInVault = false;
743
+
724
744
  // ── HTTP helpers ─────────────────────────────────────────────────────────────
725
745
  import { sweepLogs } from "../../shared/process/log-rotation.js";
726
746
  import {
@@ -773,7 +793,17 @@ import {
773
793
  import { refuseUpgrade } from "./websocket.js";
774
794
  import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
775
795
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
776
- import { edgeDomains, edgeHttps, routeHost } from "../../shared/edge/render.js";
796
+ import {
797
+ edgeHttps,
798
+ edgeNames,
799
+ webRouteHosts,
800
+ } from "../../shared/edge/render.js";
801
+ import {
802
+ EDGE_EXPOSED_MESSAGE,
803
+ edgeExposure,
804
+ edgeNetworkSubnets,
805
+ } from "./edge-exposure.js";
806
+ import { initLoginPath } from "../../shared/agents/runtime-search.js";
777
807
  import {
778
808
  linksFor,
779
809
  openUrlFor,
@@ -904,24 +934,8 @@ function denEdgeHosts(): string[] {
904
934
  } catch {
905
935
  return [];
906
936
  }
907
- const ports = new Set([PORT, readWebPortSafe()]);
908
- const names = Object.entries(cfg.routes)
909
- .filter(([, upstream]) => {
910
- try {
911
- const u = new URL(upstream);
912
- return (
913
- ports.has(Number(u.port || (u.protocol === "https:" ? 443 : 80))) &&
914
- ["host.docker.internal", "localhost", "127.0.0.1"].includes(
915
- u.hostname,
916
- )
917
- );
918
- } catch {
919
- return false;
920
- }
921
- })
922
- .map(([name]) => name);
923
- return names.flatMap((n) =>
924
- edgeDomains(cfg).map((d) => routeHost(n, d).toLowerCase()),
937
+ return webRouteHosts(cfg, new Set([PORT, readWebPortSafe()])).map(
938
+ (r) => r.host,
925
939
  );
926
940
  }
927
941
 
@@ -1016,6 +1030,36 @@ function sessionCookie(req: http.IncomingMessage): string | undefined {
1016
1030
  }
1017
1031
 
1018
1032
  const LAN_HOSTS = lanHosts();
1033
+ /** This machine's addresses (localNet), re-read at most every 10 s. */
1034
+ let LOCAL_NET = { at: 0, net: localNet() };
1035
+ /** The edge's Docker networks' subnets (edgeNetworkSubnets), once at start. */
1036
+ let EDGE_NETS: { address: string; netmask: string }[] = [];
1037
+ function currentLocalNet(): ReturnType<typeof localNet> {
1038
+ if (Date.now() - LOCAL_NET.at > 10_000) {
1039
+ const here = localNet();
1040
+ LOCAL_NET = {
1041
+ at: Date.now(),
1042
+ net: { own: here.own, bridges: [...here.bridges, ...EDGE_NETS] },
1043
+ };
1044
+ }
1045
+ return LOCAL_NET.net;
1046
+ }
1047
+ /** Read the edge's Docker networks' subnets in the background (no edge: none). */
1048
+ function readEdgeNetworks(): void {
1049
+ let names: string[];
1050
+ try {
1051
+ const cfg = readEdgeConfig();
1052
+ names = [edgeNames(cfg.org).network, ...cfg.networks];
1053
+ } catch {
1054
+ return;
1055
+ }
1056
+ void edgeNetworkSubnets(names)
1057
+ .then((nets) => {
1058
+ EDGE_NETS = nets;
1059
+ LOCAL_NET = { at: 0, net: LOCAL_NET.net }; // re-read on the next request
1060
+ })
1061
+ .catch(() => undefined);
1062
+ }
1019
1063
  const GUARD: GuardContext = {
1020
1064
  port: PORT,
1021
1065
  loopbackNames: loopbackNames(),
@@ -1044,8 +1088,43 @@ const GUARD: GuardContext = {
1044
1088
  peerIsLoopback: (req) => isLoopbackAddress(req.socket?.remoteAddress),
1045
1089
  // The edge hostname only from the edge's own peers (loopback, Docker).
1046
1090
  peerIsEdge: (req) => isEdgePeer(req.socket?.remoteAddress),
1091
+ // 0.4.3: the edge from this machine counts as local (edgeClientIsLocal).
1092
+ localNet: currentLocalNet,
1093
+ // A deliberate exposure (DEN_BIND_HOST beyond loopback, other than the
1094
+ // Linux edge's Docker bridge): the edge needs a sign-in too.
1095
+ exposed: !isLoopbackBind(BIND_HOST) && !isDockerBridgeAddress(BIND_HOST),
1047
1096
  };
1048
1097
 
1098
+ /**
1099
+ * 0.4.3: why the edge is reachable beyond this machine (edge-exposure.ts),
1100
+ * once at start; null until known, or when Den can't tell.
1101
+ */
1102
+ let EDGE_EXPOSED: string | null = null;
1103
+ function checkEdgeExposure(): void {
1104
+ // `docker inspect` only when the edge publishes host ports itself
1105
+ // (edge.publish); behind another proxy, only the hostnames tell.
1106
+ let container: string | null = null;
1107
+ try {
1108
+ const cfg = readEdgeConfig();
1109
+ container = cfg.ports ? edgeNames(cfg.org).container : null;
1110
+ } catch {
1111
+ return; // no edge
1112
+ }
1113
+ const hosts = denEdgeHosts();
1114
+ if (hosts.length === 0) return;
1115
+ void edgeExposure(hosts, container)
1116
+ .then((why) => {
1117
+ if (!why) return;
1118
+ EDGE_EXPOSED = why;
1119
+ // The in-app log, and stderr, which is web.log even when `web up`
1120
+ // starts this --silent (stdout is not written then).
1121
+ const line = ` ⚠ ${EDGE_EXPOSED_MESSAGE} (${why})`;
1122
+ pushLog("warn", line);
1123
+ if (!outputLost()) process.stderr.write(line + "\n");
1124
+ })
1125
+ .catch(() => undefined);
1126
+ }
1127
+
1049
1128
  /**
1050
1129
  * The Set-Cookie for a session: HttpOnly, SameSite=Strict, Path=/, Secure
1051
1130
  * when the request came over HTTPS (requestIsHttps: the edge's own header
@@ -1775,6 +1854,8 @@ async function handleRequest(
1775
1854
  compose: ComposeHealthTarget | null;
1776
1855
  /** A `type: container` check: answered from the shared listing. */
1777
1856
  container: ContainerHealthTarget | null;
1857
+ /** The repo has no manifest (the row is kept, unchecked). */
1858
+ noManifest?: boolean;
1778
1859
  meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
1779
1860
  }> = [];
1780
1861
  const focusName = getFocusedWorkspaceName();
@@ -1817,6 +1898,7 @@ async function handleRequest(
1817
1898
  org: orgOf(resolved.repoName, catalog),
1818
1899
  compose: null,
1819
1900
  container: null,
1901
+ noManifest: true,
1820
1902
  meta: {
1821
1903
  runs: "none",
1822
1904
  target: null,
@@ -1944,6 +2026,7 @@ async function handleRequest(
1944
2026
  expect,
1945
2027
  compose,
1946
2028
  container,
2029
+ noManifest,
1947
2030
  }) => {
1948
2031
  if (checkType !== "none") {
1949
2032
  const started = Date.now();
@@ -2010,6 +2093,13 @@ async function handleRequest(
2010
2093
  isEntrypoint,
2011
2094
  ...meta,
2012
2095
  ok: null,
2096
+ why: notCheckedReason({
2097
+ env,
2098
+ runs: meta.runs,
2099
+ target: meta.target,
2100
+ manifest: !noManifest,
2101
+ conventional,
2102
+ }),
2013
2103
  };
2014
2104
  };
2015
2105
  // Stream each result the moment it lands. The checks run concurrently but
@@ -2176,9 +2266,10 @@ async function handleRequest(
2176
2266
  Connection: "keep-alive",
2177
2267
  "X-Accel-Buffering": "no",
2178
2268
  });
2179
- const emit = (obj: unknown): void => {
2269
+ // LK-0: the command's sudo without the password is a needs-sudo event.
2270
+ const emit = watchSudo((obj: unknown): void => {
2180
2271
  res.write(`data: ${JSON.stringify(obj)}\n\n`);
2181
- };
2272
+ }, `${repoName}/${svcName} ${cmdName}`);
2182
2273
  // Every fail here is before anything ran: a refusal or a lookup miss.
2183
2274
  // Recorded, so failed attempts show in the activity trail (F2).
2184
2275
  const fail = (msg: string): void => {
@@ -3796,8 +3887,13 @@ async function handleRequest(
3796
3887
  }
3797
3888
 
3798
3889
  // ── API: sudo password ────────────────────────────────────────────────────
3890
+ // LK-0: the admin password is for sudo, and nothing else: no route asks
3891
+ // for it as an access check. A run that is about to run sudo without it
3892
+ // streams a needs-sudo event (watchSudo); the page asks, then runs again.
3799
3893
  // POST /api/sudo-password body: { password: string, saveToVault?: boolean }
3800
- // GET /api/sudo-password/status → { set: boolean }
3894
+ // POST /api/sudo-password/forget → { ok, vaultCleared, vaultError? }
3895
+ // GET /api/sudo-password/status → { set, passwordless,
3896
+ // saved: "vault" | "session" | null, vault: { configured, label } }
3801
3897
  // GET /api/sudo-password/vault-status → { backend, label, configured,
3802
3898
  // backendAvailable, reason?, hasStored, opAvailable (deprecated) }
3803
3899
  // POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
@@ -3833,6 +3929,7 @@ async function handleRequest(
3833
3929
  try {
3834
3930
  await saveSudoToVault(password);
3835
3931
  savedToVault = true;
3932
+ sudoInVault = true;
3836
3933
  } catch (err) {
3837
3934
  vaultError = (err as Error).message;
3838
3935
  }
@@ -3844,10 +3941,51 @@ async function handleRequest(
3844
3941
  }
3845
3942
 
3846
3943
  if (pathname === "/api/sudo-password/status") {
3847
- // passwordless: NOPASSWD sudoer (or warm cache) — no admin login needed.
3944
+ // passwordless: NOPASSWD sudoer (or warm cache) — sudo needs none.
3945
+ // saved (Settings, LK-0): "vault" once this server saved it there or
3946
+ // loaded it from there, "session" when it is held in memory only.
3947
+ const set = hasSudoPassword();
3848
3948
  return sendJson(res, {
3849
- set: hasSudoPassword(),
3949
+ set,
3850
3950
  passwordless: hasPasswordlessSudo(),
3951
+ saved: sudoInVault ? "vault" : set ? "session" : null,
3952
+ vault: sudoVaultWhere(),
3953
+ });
3954
+ }
3955
+
3956
+ // Forget (Settings, LK-0): the password this server holds goes, and the
3957
+ // vault's copy when this server knows there is one (written empty, which
3958
+ // every read takes as none). sudo's own cache is dropped too.
3959
+ if (pathname === "/api/sudo-password/forget") {
3960
+ if (req.method !== "POST") {
3961
+ res.writeHead(405);
3962
+ res.end();
3963
+ return;
3964
+ }
3965
+ clearSudoPassword();
3966
+ try {
3967
+ execSync("sudo -k", {
3968
+ stdio: ["ignore", "ignore", "ignore"],
3969
+ timeout: CLI_SPAWN_TIMEOUT_MS,
3970
+ });
3971
+ } catch {
3972
+ /* best-effort */
3973
+ }
3974
+ let vaultCleared = false;
3975
+ let vaultError: string | undefined;
3976
+ if (sudoInVault) {
3977
+ try {
3978
+ await saveSudoToVault("");
3979
+ vaultCleared = true;
3980
+ sudoInVault = false;
3981
+ } catch (err) {
3982
+ vaultError = (err as Error).message;
3983
+ }
3984
+ }
3985
+ return sendJson(res, {
3986
+ ok: true,
3987
+ vaultCleared,
3988
+ ...(vaultError ? { vaultError } : {}),
3851
3989
  });
3852
3990
  }
3853
3991
 
@@ -3892,6 +4030,7 @@ async function handleRequest(
3892
4030
  return sendJson(res, { ok: false, reason: "stale" });
3893
4031
  }
3894
4032
  setSudoPassword(password);
4033
+ sudoInVault = true;
3895
4034
  return sendJson(res, { ok: true });
3896
4035
  }
3897
4036
 
@@ -4572,6 +4711,11 @@ try {
4572
4711
  } catch (e) {
4573
4712
  serverLog(` ⚠ web.secret could not be created: ${(e as Error).message}`);
4574
4713
  }
4714
+ // AG-0.1: the login shell's PATH, once, in the background (3 s at most),
4715
+ // so agents find their CLI as the person's terminal would: a launchd
4716
+ // service's PATH leaves out ~/.local/bin. Until (or unless) it answers, the
4717
+ // search is this PATH and the runtimes' install directories.
4718
+ void initLoginPath();
4575
4719
  server.listen(PORT, BIND_HOST, () => {
4576
4720
  const hosts = fs.existsSync("/etc/hosts")
4577
4721
  ? fs.readFileSync("/etc/hosts", "utf-8")
@@ -4594,6 +4738,8 @@ server.listen(PORT, BIND_HOST, () => {
4594
4738
  console.log();
4595
4739
  // Once per start: a secrets config that leaves Den's own features off.
4596
4740
  for (const w of secretsConfigWarnings()) serverLog(` ⚠ ${w}`);
4741
+ checkEdgeExposure();
4742
+ readEdgeNetworks();
4597
4743
  });
4598
4744
 
4599
4745
  // ── Graceful shutdown ─────────────────────────────────────────────────────────
@@ -4672,7 +4818,7 @@ async function stopDaemons(): Promise<void> {
4672
4818
  }
4673
4819
 
4674
4820
  async function handleShutdown(signal: string): Promise<void> {
4675
- clearSudoPassword();
4821
+ clearSudoShims();
4676
4822
  // The registry lives in the state dir, which is resolved from the
4677
4823
  // distribution root on every call: if that can no longer be found (den.yml
4678
4824
  // moved or removed while the server ran), still exit. Never product() here: