@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
@@ -13,17 +13,21 @@
13
13
  *
14
14
  * The R8 lock is the web UI's (an in-memory admin password in the web
15
15
  * server); CLI service actions (up, down) do not consult it, so neither do
16
- * these. agents.max_concurrent caps every start.
16
+ * these. sessions.max_concurrent caps the plain sessions running at once;
17
+ * agents count against agents.max_concurrent instead, never this one.
17
18
  */
18
19
  import { spawn } from "child_process";
19
20
  import * as fs from "fs";
20
21
  import * as path from "path";
21
- import { readAgentsConfig } from "../../shared/den-config.js";
22
+ import {
23
+ readAgentsConfig,
24
+ readSessionsConfig,
25
+ } from "../../shared/den-config.js";
22
26
  import { cliEntrypoint } from "../../shared/engine.js";
23
27
  import { getDistRoot, getInvokeCwd, getStateDir } from "../../shared/paths.js";
24
28
  import { product } from "../../shared/product.js";
25
29
  import { ENV_NAME_RE, isCredentialShaped } from "../../shared/secret-env.js";
26
- import { allScrubEnv } from "../../shared/agents/runtimes.js";
30
+ import { allScrubEnv, withSessionPath } from "../../shared/agents/runtimes.js";
27
31
  import {
28
32
  ensureHost,
29
33
  connectHost,
@@ -64,7 +68,7 @@ export const SESSIONS_HELP = {
64
68
  "session host (one per distribution, started on demand).",
65
69
  "",
66
70
  "{cli} sessions start --workspace <ws> --name <n> [--cwd <dir>] [--keep-env NAME…] -- <argv…>",
67
- " run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by agents.max_concurrent",
71
+ " run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by sessions.max_concurrent",
68
72
  " env: PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG, LC_*, TZ,",
69
73
  " TMPDIR only; --keep-env NAME (exact, repeatable; audited) adds one",
70
74
  " (never a person's credential: DEN_PERSON_TOKEN, a web.secret path)",
@@ -97,7 +101,10 @@ export interface SessionsDeps {
97
101
  connectExisting?: () => Promise<HostClient | null>;
98
102
  pty?: () => PtyLoad;
99
103
  workspaceExists?: (ws: string) => boolean;
104
+ /** agents.max_concurrent: the agents running at once. */
100
105
  maxConcurrent?: () => number;
106
+ /** sessions.max_concurrent: the plain sessions running at once. */
107
+ maxSessions?: () => number;
101
108
  invokeCwd?: () => string;
102
109
  env?: NodeJS.ProcessEnv;
103
110
  out?: (line: string) => void;
@@ -114,7 +121,10 @@ export interface Resolved {
114
121
  connectExisting: () => Promise<HostClient | null>;
115
122
  pty: () => PtyLoad;
116
123
  workspaceExists: (ws: string) => boolean;
124
+ /** agents.max_concurrent: the agents running at once. */
117
125
  maxConcurrent: () => number;
126
+ /** sessions.max_concurrent: the plain sessions running at once. */
127
+ maxSessions: () => number;
118
128
  invokeCwd: () => string;
119
129
  env: NodeJS.ProcessEnv;
120
130
  out: (line: string) => void;
@@ -180,6 +190,7 @@ export function resolveDeps(d: SessionsDeps): Resolved {
180
190
  pty: d.pty ?? (() => loadPty()),
181
191
  workspaceExists: d.workspaceExists ?? defaultWorkspaceExists,
182
192
  maxConcurrent: d.maxConcurrent ?? (() => readAgentsConfig().maxConcurrent),
193
+ maxSessions: d.maxSessions ?? (() => readSessionsConfig().maxConcurrent),
183
194
  invokeCwd: d.invokeCwd ?? getInvokeCwd,
184
195
  env: d.env ?? process.env,
185
196
  out: d.out ?? ((l) => console.log(l)),
@@ -309,14 +320,19 @@ export function contractInfo(s: SessionInfo): SessionInfo {
309
320
  // ── subcommands ───────────────────────────────────────────────────────────────
310
321
 
311
322
  /**
312
- * A plain session's env (AG spec §5a-env): the allowlist, the names the
313
- * user kept, then every runtime's scrubEnv. `doctor --probe` checks it.
323
+ * A plain session's env (AG spec §5a-env): the allowlist (its PATH with
324
+ * the runtime search's directories, AG-0.1), the names the user kept, then
325
+ * every runtime's scrubEnv. `doctor --probe` checks it.
314
326
  */
315
327
  export function plainSessionEnv(
316
328
  caller: NodeJS.ProcessEnv,
317
329
  keep: readonly string[] = [],
318
330
  ): Record<string, string> {
319
- return buildSessionEnv({ caller, keep, scrub: allScrubEnv() });
331
+ return buildSessionEnv({
332
+ caller: withSessionPath(caller),
333
+ keep,
334
+ scrub: allScrubEnv(),
335
+ });
320
336
  }
321
337
 
322
338
  /** Who ran the CLI, for an audit line: a login name, printable. */
@@ -437,7 +453,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
437
453
  d.err(` \`${d.cli} doctor\` shows the session backend's state.`);
438
454
  return 1;
439
455
  }
440
- const limit = d.maxConcurrent();
456
+ const limit = d.maxSessions();
441
457
  const full = sessionName(d.dist, ws, name);
442
458
  const tty = d.stdout.isTTY;
443
459
  return withClient(d, async (c) => {
@@ -453,6 +469,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
453
469
  },
454
470
  {
455
471
  limit,
472
+ pool: "sessions",
456
473
  // Audited by the host: each name, and whether it is
457
474
  // credential-shaped; never a value.
458
475
  ...(kept.length
@@ -480,7 +497,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
480
497
  d.err(`✗ ${e.message}`);
481
498
  if (e.code === "cap")
482
499
  d.err(
483
- ` Stop one first (\`${d.cli} sessions kill <name>\`), or raise agents.max_concurrent.`,
500
+ ` Stop one first (\`${d.cli} sessions kill <name>\`), or raise sessions.max_concurrent.`,
484
501
  );
485
502
  if (e.code === "exists")
486
503
  d.err(
@@ -21,6 +21,7 @@ import {
21
21
  rotateWebSecret,
22
22
  } from "../../shared/web-secret.js";
23
23
  import {
24
+ ignoredConfigWarning,
24
25
  localhostOrigin,
25
26
  loginLink,
26
27
  mintLoginCode,
@@ -32,7 +33,8 @@ import {
32
33
  } from "../../shared/web-session.js";
33
34
  import { revokeAllActorTokens } from "../../shared/actor-token.js";
34
35
  import { engineBin, engineModuleFile } from "../../shared/engine.js";
35
- import { readWebPort } from "../../shared/edge/config.js";
36
+ import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
37
+ import { webRouteHosts } from "../../shared/edge/render.js";
36
38
  import {
37
39
  ownServer,
38
40
  processCwd,
@@ -1080,7 +1082,7 @@ export function displayWebHelp(): void {
1080
1082
  console.log(` ${product().cli} web --setup-browser-access`);
1081
1083
  console.log(` ${product().cli} web token --print`);
1082
1084
  console.log(
1083
- ` ${product().cli} web login [--next <path>] [--origin <url>] [--port <port>]`,
1085
+ ` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]`,
1084
1086
  );
1085
1087
  console.log(` ${product().cli} web sessions [--json] [--port <port>]`);
1086
1088
  console.log(
@@ -1242,7 +1244,12 @@ export async function cmdWeb(args: string[]): Promise<void> {
1242
1244
 
1243
1245
  // SEC-1b: a wrong web.allow_plain_http_remote would stop the server at
1244
1246
  // start; say so here, naming the key, instead of "did not become ready".
1245
- readWebAuthConfig(getDistRoot(), product().configFile);
1247
+ // A key read and ignored (the distribution's web.allow_plain_http_remote:
1248
+ // only user.yml counts) is said here too, not only in the server's log.
1249
+ const ignored = ignoredConfigWarning(
1250
+ readWebAuthConfig(getDistRoot(), product().configFile),
1251
+ );
1252
+ if (ignored) console.log(chalk.yellow(`\n ⚠ ${ignored}`));
1246
1253
 
1247
1254
  const apiRunning = await isDenWebApiRunning(options.port);
1248
1255
  let running = apiRunning && (await isDenWebUiReady(options.port));
@@ -1386,20 +1393,24 @@ function takePort(args: string[]): { port: number; rest: string[] } {
1386
1393
  }
1387
1394
 
1388
1395
  /**
1389
- * `<cli> web login [--next <path>] [--origin <url>]`: a fresh sign-in link
1396
+ * `<cli> web login [--next <path>] [--host <name> | --origin <url>]`: a fresh sign-in link
1390
1397
  * on stdout, alone (the only place a login code is ever printed); a note on
1391
- * stderr. `--origin` for the edge hostname or a LAN name, where the cookie
1392
- * must be set (the signed-out page there says which).
1398
+ * stderr. `--host` for an edge hostname (its URL from the edge config),
1399
+ * `--origin` for any allowed origin (a LAN name), where the cookie must be
1400
+ * set (the signed-out page there says which).
1393
1401
  */
1394
1402
  export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1395
- const usage = `Usage: ${product().cli} web login [--next <path>] [--origin <url>] [--port <port>]\n`;
1403
+ const usage = `Usage: ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]\n`;
1396
1404
  if (isHelp(args)) {
1397
1405
  io.out(
1398
1406
  usage +
1399
1407
  " Prints a one-time sign-in link (it works once, within 60 s) for one browser.\n" +
1400
1408
  " On localhost nothing asks for a sign-in; it is needed when this server is\n" +
1401
- " reached beyond this machine (the edge, or a LAN address: pass its --origin).\n" +
1409
+ " reached from another machine (pass the edge hostname as --host, or a LAN\n" +
1410
+ " address as --origin).\n" +
1402
1411
  " --next <path> where the browser lands after signing in (same origin only)\n" +
1412
+ " --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
1413
+ " a VPN name among them): the link uses its edge URL\n" +
1403
1414
  " --origin <url> the address the browser uses, http(s)://host[:port]\n",
1404
1415
  );
1405
1416
  return 0;
@@ -1424,8 +1435,16 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1424
1435
  : undefined;
1425
1436
  const n = val("--next");
1426
1437
  const o = val("--origin");
1438
+ const h = val("--host");
1427
1439
  if (n !== undefined) next = n;
1428
- else if (o !== undefined) {
1440
+ else if (h !== undefined) {
1441
+ const found = edgeOriginFor(h, port);
1442
+ if (typeof found !== "string") {
1443
+ io.err(found.error);
1444
+ return 2;
1445
+ }
1446
+ origin = found;
1447
+ } else if (o !== undefined) {
1429
1448
  origin = parseOrigin(o);
1430
1449
  if (!origin) {
1431
1450
  io.err(
@@ -1451,6 +1470,38 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1451
1470
  return 0;
1452
1471
  }
1453
1472
 
1473
+ /**
1474
+ * `web login --host <name>`: the origin a browser uses for an edge hostname
1475
+ * that routes to this server (edge.routes under any edge domain or alias),
1476
+ * or why there is none and what to add.
1477
+ */
1478
+ export function edgeOriginFor(
1479
+ name: string,
1480
+ port: number,
1481
+ ): string | { error: string } {
1482
+ const want = name.trim().toLowerCase().replace(/\.$/, "");
1483
+ let routes: { host: string; url: string }[] = [];
1484
+ try {
1485
+ routes = webRouteHosts(readEdgeConfig(), new Set([port, readWebPort()]));
1486
+ } catch {
1487
+ routes = [];
1488
+ }
1489
+ const hit = routes.find((r) => r.host === want);
1490
+ if (hit) return hit.url;
1491
+ const known = routes.map((r) => r.host);
1492
+ return {
1493
+ error:
1494
+ `--host ${name} is not a hostname this server answers to` +
1495
+ (known.length
1496
+ ? ` (its edge hostnames: ${known.join(", ")}).\n`
1497
+ : " (no edge route reaches this server).\n") +
1498
+ " Add it to the config's edge block: its domain to edge.aliases (a VPN\n" +
1499
+ " name), or a route to this server in edge.routes, then `edge up`. For a\n" +
1500
+ " LAN name (DEN_BIND_HOST and web.allow_plain_http_remote) pass\n" +
1501
+ " --origin http://<name>:<port> instead.\n",
1502
+ };
1503
+ }
1504
+
1454
1505
  interface SessionRow {
1455
1506
  id: string;
1456
1507
  createdAt: string;
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * The Workspaces page's Agents section (AG-4a, AG spec §5c), its routes:
3
3
  *
4
- * GET /api/personas personas and runtimes
4
+ * GET /api/personas personas and runtimes (each
5
+ * with its path, where it came
6
+ * from and its version: AG-0.2)
5
7
  * GET /api/workspaces/<ws>/agents the workspace's agents
6
8
  * POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
7
9
  * POST /api/workspaces/<ws>/agents/<id>/stop
@@ -18,7 +20,8 @@
18
20
  * - a person only: an agent (its actor token, SEC-1c) can't start or
19
21
  * stop agents from the page (403; agents use MCP agent_spawn, §5,
20
22
  * under its own guards);
21
- * - agents.max_concurrent (the host's cap: 429 cap).
23
+ * - agents.max_concurrent (the host's cap on agents, never plain
24
+ * sessions: 429 cap).
22
25
  * The env is the CLI's (§5a-env): the allowlist and the runtime's, built
23
26
  * from this server's environment, never all of it. Every action, refused
24
27
  * or done, is audited (recordAction) with the person as its actor.
@@ -203,7 +206,12 @@ export async function handleAgentRoutes(
203
206
  if (!m) {
204
207
  if (method !== "GET") return methodNotAllowed(res, "GET");
205
208
  try {
206
- sendJson(res, { personas: listPersonas(d), runtimes: listRuntimes(d) });
209
+ sendJson(res, {
210
+ personas: listPersonas(d),
211
+ runtimes: await listRuntimes(d),
212
+ // AG-0.2: the file a person edits to set a runtime's path.
213
+ userConfig: d.userConfigFile(),
214
+ });
207
215
  } catch (e) {
208
216
  sendError(res, e);
209
217
  }
@@ -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
+ }
@@ -184,8 +184,10 @@ import {
184
184
  bindRefusal,
185
185
  checkRequest,
186
186
  isEdgePeer,
187
+ isDockerBridgeAddress,
187
188
  isLoopbackAddress,
188
189
  isLoopbackBind,
190
+ localNet,
189
191
  newCsrfToken,
190
192
  principalOf,
191
193
  requestIsHttps,
@@ -549,6 +551,13 @@ setHtmlFill((html) => {
549
551
  () => '<div id="den-shell" data-config-invalid="1"',
550
552
  );
551
553
  }
554
+ // 0.4.3: every page says when the edge is reachable from other machines.
555
+ if (EDGE_EXPOSED)
556
+ out = out.replace(
557
+ '<div id="den-shell"',
558
+ () =>
559
+ `<div id="den-shell" data-edge-exposed="${escAttr(EDGE_EXPOSED_MESSAGE)}"`,
560
+ );
552
561
  // MD-2: every page says when the engine on disk is not the one running.
553
562
  const engine = engineCheck();
554
563
  if (engine.stale)
@@ -773,7 +782,17 @@ import {
773
782
  import { refuseUpgrade } from "./websocket.js";
774
783
  import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
775
784
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
776
- import { edgeDomains, edgeHttps, routeHost } from "../../shared/edge/render.js";
785
+ import {
786
+ edgeHttps,
787
+ edgeNames,
788
+ webRouteHosts,
789
+ } from "../../shared/edge/render.js";
790
+ import {
791
+ EDGE_EXPOSED_MESSAGE,
792
+ edgeExposure,
793
+ edgeNetworkSubnets,
794
+ } from "./edge-exposure.js";
795
+ import { initLoginPath } from "../../shared/agents/runtime-search.js";
777
796
  import {
778
797
  linksFor,
779
798
  openUrlFor,
@@ -904,24 +923,8 @@ function denEdgeHosts(): string[] {
904
923
  } catch {
905
924
  return [];
906
925
  }
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()),
926
+ return webRouteHosts(cfg, new Set([PORT, readWebPortSafe()])).map(
927
+ (r) => r.host,
925
928
  );
926
929
  }
927
930
 
@@ -1016,6 +1019,36 @@ function sessionCookie(req: http.IncomingMessage): string | undefined {
1016
1019
  }
1017
1020
 
1018
1021
  const LAN_HOSTS = lanHosts();
1022
+ /** This machine's addresses (localNet), re-read at most every 10 s. */
1023
+ let LOCAL_NET = { at: 0, net: localNet() };
1024
+ /** The edge's Docker networks' subnets (edgeNetworkSubnets), once at start. */
1025
+ let EDGE_NETS: { address: string; netmask: string }[] = [];
1026
+ function currentLocalNet(): ReturnType<typeof localNet> {
1027
+ if (Date.now() - LOCAL_NET.at > 10_000) {
1028
+ const here = localNet();
1029
+ LOCAL_NET = {
1030
+ at: Date.now(),
1031
+ net: { own: here.own, bridges: [...here.bridges, ...EDGE_NETS] },
1032
+ };
1033
+ }
1034
+ return LOCAL_NET.net;
1035
+ }
1036
+ /** Read the edge's Docker networks' subnets in the background (no edge: none). */
1037
+ function readEdgeNetworks(): void {
1038
+ let names: string[];
1039
+ try {
1040
+ const cfg = readEdgeConfig();
1041
+ names = [edgeNames(cfg.org).network, ...cfg.networks];
1042
+ } catch {
1043
+ return;
1044
+ }
1045
+ void edgeNetworkSubnets(names)
1046
+ .then((nets) => {
1047
+ EDGE_NETS = nets;
1048
+ LOCAL_NET = { at: 0, net: LOCAL_NET.net }; // re-read on the next request
1049
+ })
1050
+ .catch(() => undefined);
1051
+ }
1019
1052
  const GUARD: GuardContext = {
1020
1053
  port: PORT,
1021
1054
  loopbackNames: loopbackNames(),
@@ -1044,8 +1077,43 @@ const GUARD: GuardContext = {
1044
1077
  peerIsLoopback: (req) => isLoopbackAddress(req.socket?.remoteAddress),
1045
1078
  // The edge hostname only from the edge's own peers (loopback, Docker).
1046
1079
  peerIsEdge: (req) => isEdgePeer(req.socket?.remoteAddress),
1080
+ // 0.4.3: the edge from this machine counts as local (edgeClientIsLocal).
1081
+ localNet: currentLocalNet,
1082
+ // A deliberate exposure (DEN_BIND_HOST beyond loopback, other than the
1083
+ // Linux edge's Docker bridge): the edge needs a sign-in too.
1084
+ exposed: !isLoopbackBind(BIND_HOST) && !isDockerBridgeAddress(BIND_HOST),
1047
1085
  };
1048
1086
 
1087
+ /**
1088
+ * 0.4.3: why the edge is reachable beyond this machine (edge-exposure.ts),
1089
+ * once at start; null until known, or when Den can't tell.
1090
+ */
1091
+ let EDGE_EXPOSED: string | null = null;
1092
+ function checkEdgeExposure(): void {
1093
+ // `docker inspect` only when the edge publishes host ports itself
1094
+ // (edge.publish); behind another proxy, only the hostnames tell.
1095
+ let container: string | null = null;
1096
+ try {
1097
+ const cfg = readEdgeConfig();
1098
+ container = cfg.ports ? edgeNames(cfg.org).container : null;
1099
+ } catch {
1100
+ return; // no edge
1101
+ }
1102
+ const hosts = denEdgeHosts();
1103
+ if (hosts.length === 0) return;
1104
+ void edgeExposure(hosts, container)
1105
+ .then((why) => {
1106
+ if (!why) return;
1107
+ EDGE_EXPOSED = why;
1108
+ // The in-app log, and stderr, which is web.log even when `web up`
1109
+ // starts this --silent (stdout is not written then).
1110
+ const line = ` ⚠ ${EDGE_EXPOSED_MESSAGE} (${why})`;
1111
+ pushLog("warn", line);
1112
+ if (!outputLost()) process.stderr.write(line + "\n");
1113
+ })
1114
+ .catch(() => undefined);
1115
+ }
1116
+
1049
1117
  /**
1050
1118
  * The Set-Cookie for a session: HttpOnly, SameSite=Strict, Path=/, Secure
1051
1119
  * when the request came over HTTPS (requestIsHttps: the edge's own header
@@ -4543,6 +4611,17 @@ try {
4543
4611
  serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
4544
4612
  }
4545
4613
 
4614
+ // Ignored config keys (a distribution's web.allow_plain_http_remote, the
4615
+ // old web.session lifetimes): the in-app log, and stderr, which is web.log
4616
+ // even when `web up` starts this --silent. Before the bind refusal, which
4617
+ // such a key often explains.
4618
+ {
4619
+ const ignored = ignoredConfigWarning(WEB_AUTH);
4620
+ if (ignored) {
4621
+ pushLog("warn", ` ⚠ ${ignored}`);
4622
+ if (!outputLost()) process.stderr.write(` ⚠ ${ignored}\n`);
4623
+ }
4624
+ }
4546
4625
  // §4: a non-loopback bind needs HTTPS in front, or the person's say-so.
4547
4626
  {
4548
4627
  const refusal = bindRefusal(BIND_HOST, {
@@ -4554,10 +4633,6 @@ try {
4554
4633
  process.exit(1);
4555
4634
  }
4556
4635
  }
4557
- {
4558
- const ignored = ignoredConfigWarning(WEB_AUTH);
4559
- if (ignored) serverLog(` ⚠ ${ignored}`, "warn");
4560
- }
4561
4636
  // SEC-1a: the secret the CLI's person token is derived from (0600 in the
4562
4637
  // state dir). Never printed or logged; a failure leaves bearers unverifiable.
4563
4638
  try {
@@ -4565,6 +4640,11 @@ try {
4565
4640
  } catch (e) {
4566
4641
  serverLog(` ⚠ web.secret could not be created: ${(e as Error).message}`);
4567
4642
  }
4643
+ // AG-0.1: the login shell's PATH, once, in the background (3 s at most),
4644
+ // so agents find their CLI as the person's terminal would: a launchd
4645
+ // service's PATH leaves out ~/.local/bin. Until (or unless) it answers, the
4646
+ // search is this PATH and the runtimes' install directories.
4647
+ void initLoginPath();
4568
4648
  server.listen(PORT, BIND_HOST, () => {
4569
4649
  const hosts = fs.existsSync("/etc/hosts")
4570
4650
  ? fs.readFileSync("/etc/hosts", "utf-8")
@@ -4587,6 +4667,8 @@ server.listen(PORT, BIND_HOST, () => {
4587
4667
  console.log();
4588
4668
  // Once per start: a secrets config that leaves Den's own features off.
4589
4669
  for (const w of secretsConfigWarnings()) serverLog(` ⚠ ${w}`);
4670
+ checkEdgeExposure();
4671
+ readEdgeNetworks();
4590
4672
  });
4591
4673
 
4592
4674
  // ── Graceful shutdown ─────────────────────────────────────────────────────────