@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
|
@@ -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.
|
|
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 {
|
|
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
|
|
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
|
|
313
|
-
*
|
|
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({
|
|
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.
|
|
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
|
|
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
|
-
|
|
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. `--
|
|
1392
|
-
*
|
|
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
|
|
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 (
|
|
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
|
|
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, {
|
|
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
|
+
}
|
package/src/ui/server/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
908
|
-
|
|
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 ─────────────────────────────────────────────────────────
|