@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.
- package/CHANGELOG.md +273 -0
- package/README.md +9 -9
- package/docs/distributions.md +313 -50
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +53 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +543 -0
- package/src/shared/agents/runtime.ts +43 -1
- package/src/shared/agents/runtimes.ts +53 -4
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +92 -2
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/ui/cli/agent-ops.ts +298 -19
- package/src/ui/cli/agents-command.ts +129 -4
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +123 -26
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +171 -25
- package/src/ui/server/public/agent-start.js +241 -0
- package/src/ui/server/public/agents-page.js +246 -45
- package/src/ui/server/public/agents-section.js +7 -21
- package/src/ui/server/public/agents.html +55 -1
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +26 -13
- package/src/ui/server/public/i18n/ja.js +25 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +62 -22
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +3 -2
- package/src/ui/server/public/settings-registry.js +226 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +67 -22
- package/src/ui/server/public/topbar.js +217 -158
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +123 -8
- package/src/ui/server/route-classes.ts +14 -2
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- 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
|
+
}
|
package/src/ui/server/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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()),
|
|
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
|
-
|
|
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
|
-
//
|
|
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) —
|
|
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
|
|
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
|
-
|
|
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:
|