@monster-orange/den 0.4.2 → 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 +94 -0
- package/README.md +7 -7
- package/docs/distributions.md +126 -13
- 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 +36 -0
- package/src/shared/edge/render.ts +31 -0
- package/src/ui/cli/agent-ops.ts +107 -19
- package/src/ui/cli/agents-command.ts +99 -3
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- package/src/ui/server/agent-routes.ts +9 -2
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +94 -19
- package/src/ui/server/public/agent-start.js +202 -0
- package/src/ui/server/public/agents-page.js +83 -17
- 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
|
@@ -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
|
|
@@ -4572,6 +4640,11 @@ try {
|
|
|
4572
4640
|
} catch (e) {
|
|
4573
4641
|
serverLog(` ⚠ web.secret could not be created: ${(e as Error).message}`);
|
|
4574
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();
|
|
4575
4648
|
server.listen(PORT, BIND_HOST, () => {
|
|
4576
4649
|
const hosts = fs.existsSync("/etc/hosts")
|
|
4577
4650
|
? fs.readFileSync("/etc/hosts", "utf-8")
|
|
@@ -4594,6 +4667,8 @@ server.listen(PORT, BIND_HOST, () => {
|
|
|
4594
4667
|
console.log();
|
|
4595
4668
|
// Once per start: a secrets config that leaves Den's own features off.
|
|
4596
4669
|
for (const w of secretsConfigWarnings()) serverLog(` ⚠ ${w}`);
|
|
4670
|
+
checkEdgeExposure();
|
|
4671
|
+
readEdgeNetworks();
|
|
4597
4672
|
});
|
|
4598
4673
|
|
|
4599
4674
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Start agent form's choices (AG-10.0), shared by the Agents tab's
|
|
3
|
+
* dialog and the Workspaces page's agents section. DOM-free.
|
|
4
|
+
*
|
|
5
|
+
* A persona option shows its id only: its role sentence is the help line
|
|
6
|
+
* under the select (personaHelp), so a long role can't widen the select
|
|
7
|
+
* past the dialog. The runtime's first option names what it resolves to,
|
|
8
|
+
* "Claude Code (persona default)", from the persona picked; a runtime that
|
|
9
|
+
* can't run here says why, and one that can shows its version.
|
|
10
|
+
*
|
|
11
|
+
* AG-0.2, readiness before Start: the line under the runtime names the CLI
|
|
12
|
+
* it will run (its version and path); when that runtime can't run here,
|
|
13
|
+
* Start is disabled and the line says why and how to fix it (install it,
|
|
14
|
+
* or set agents.runtimes.<id>.bin in the user.yml it names, or run
|
|
15
|
+
* `<cli> agents doctor`). Nothing fails only after Start.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function escHtml(s) {
|
|
19
|
+
return String(s ?? "").replace(
|
|
20
|
+
/[&<>"']/g,
|
|
21
|
+
(c) =>
|
|
22
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
23
|
+
c
|
|
24
|
+
],
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The persona select's options: the id only. */
|
|
29
|
+
export function personaOptions(personas) {
|
|
30
|
+
return (personas ?? [])
|
|
31
|
+
.map((p) => `<option value="${escHtml(p.id)}">${escHtml(p.id)}</option>`)
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The help line under the persona select: the picked persona's role. */
|
|
36
|
+
export function personaHelp(personas, id) {
|
|
37
|
+
return (personas ?? []).find((p) => p.id === id)?.role ?? "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A runtime's name for people ("Claude Code"); its id when it has none. */
|
|
41
|
+
export function runtimeName(runtimes, id) {
|
|
42
|
+
const r = (runtimes ?? []).find((x) => x.id === id);
|
|
43
|
+
return r?.label || r?.id || id || "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The runtime a persona runs on when the agent names none: the persona's
|
|
48
|
+
* own, else the default runtime.
|
|
49
|
+
*/
|
|
50
|
+
export function defaultRuntimeOf(personas, runtimes, personaId) {
|
|
51
|
+
const p = (personas ?? []).find((x) => x.id === personaId);
|
|
52
|
+
if (p?.runtime) return p.runtime;
|
|
53
|
+
return (runtimes ?? []).find((r) => r.default)?.id ?? "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The runtime select's options. The first ("" = the persona's) reads
|
|
58
|
+
* "<name> (persona default)"; one that can't run here is disabled and
|
|
59
|
+
* says why.
|
|
60
|
+
*/
|
|
61
|
+
export function runtimeOptions(personas, runtimes, personaId, t) {
|
|
62
|
+
const name = runtimeName(
|
|
63
|
+
runtimes,
|
|
64
|
+
defaultRuntimeOf(personas, runtimes, personaId),
|
|
65
|
+
);
|
|
66
|
+
const first = name ? t("ag.runtime.default", { name }) : t("ag.runtime.auto");
|
|
67
|
+
return (
|
|
68
|
+
`<option value="">${escHtml(first)}</option>` +
|
|
69
|
+
(runtimes ?? [])
|
|
70
|
+
.map((r) => {
|
|
71
|
+
const label = runtimeName(runtimes, r.id);
|
|
72
|
+
return r.available
|
|
73
|
+
? `<option value="${escHtml(r.id)}">${escHtml(
|
|
74
|
+
r.version ? `${label} · ${r.version}` : label,
|
|
75
|
+
)}</option>`
|
|
76
|
+
: `<option value="${escHtml(r.id)}" disabled>${escHtml(
|
|
77
|
+
`${label} — ${r.why || t("ag.unavailable")}`,
|
|
78
|
+
)}</option>`;
|
|
79
|
+
})
|
|
80
|
+
.join("")
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The most searched directories named inline; the rest are counted. */
|
|
85
|
+
const SHOWN_DIRS = 4;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether Start can run, and the line that says so (AG-0.2): for the
|
|
89
|
+
* runtime picked (`runtimeId`, "" = the persona's), { ok, tone, html }.
|
|
90
|
+
* `tone` is "" (ready), "warn" (can't start) or "none" (nothing to say).
|
|
91
|
+
*/
|
|
92
|
+
export function readiness(data, personaId, runtimeId, t) {
|
|
93
|
+
const personas = data?.personas ?? [];
|
|
94
|
+
const runtimes = data?.runtimes ?? [];
|
|
95
|
+
const id = runtimeId || defaultRuntimeOf(personas, runtimes, personaId);
|
|
96
|
+
const r = runtimes.find((x) => x.id === id);
|
|
97
|
+
if (!r)
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
tone: "warn",
|
|
101
|
+
html: escHtml(t("ag.rt.unknown", { id: id || "?" })),
|
|
102
|
+
};
|
|
103
|
+
const name = runtimeName(runtimes, r.id);
|
|
104
|
+
const mono = (x) => `<span class="mono">${escHtml(x)}</span>`;
|
|
105
|
+
if (r.available) {
|
|
106
|
+
if (!r.path) return { ok: true, tone: "none", html: "" };
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
tone: "",
|
|
110
|
+
html:
|
|
111
|
+
escHtml(
|
|
112
|
+
r.version
|
|
113
|
+
? t("ag.rt.ready", { name, version: r.version })
|
|
114
|
+
: t("ag.rt.readyNoVer", { name }),
|
|
115
|
+
) +
|
|
116
|
+
" " +
|
|
117
|
+
mono(r.path),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const file = data?.userConfig || "config/local/user.yml";
|
|
121
|
+
const fix = t("ag.rt.fix", {
|
|
122
|
+
key: "\u0000k",
|
|
123
|
+
file: "\u0000f",
|
|
124
|
+
cmd: "\u0000c",
|
|
125
|
+
});
|
|
126
|
+
const fixHtml = escHtml(fix)
|
|
127
|
+
.replace("\u0000k", mono(r.setting || `agents.runtimes.${r.id}.bin`))
|
|
128
|
+
.replace("\u0000f", mono(file))
|
|
129
|
+
.replace("\u0000c", mono("__DEN_CLI__ agents doctor"));
|
|
130
|
+
let what;
|
|
131
|
+
if (r.source === "setting" || !r.searched)
|
|
132
|
+
what = escHtml(t("ag.rt.refused", { name, why: r.why || "" }));
|
|
133
|
+
else {
|
|
134
|
+
const dirs = r.searched;
|
|
135
|
+
const shown = dirs.slice(0, SHOWN_DIRS).map(mono).join(", ");
|
|
136
|
+
const more =
|
|
137
|
+
dirs.length > SHOWN_DIRS
|
|
138
|
+
? " " + escHtml(t("ag.rt.more", { n: dirs.length - SHOWN_DIRS }))
|
|
139
|
+
: "";
|
|
140
|
+
what =
|
|
141
|
+
escHtml(t("ag.rt.notFound", { name })) +
|
|
142
|
+
" " +
|
|
143
|
+
escHtml(t("ag.rt.searched")) +
|
|
144
|
+
" " +
|
|
145
|
+
(shown || escHtml(t("ag.rt.nowhere"))) +
|
|
146
|
+
more +
|
|
147
|
+
".";
|
|
148
|
+
}
|
|
149
|
+
return { ok: false, tone: "warn", html: `${what} ${fixHtml}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fill a form's persona and runtime selects, its help line and its
|
|
154
|
+
* readiness line ([data-runtime-ready]), keep them in step when the
|
|
155
|
+
* persona or the runtime changes, and disable its submit button while
|
|
156
|
+
* the runtime can't run. Returns nothing; idempotent per form (the
|
|
157
|
+
* change listeners are bound once).
|
|
158
|
+
*/
|
|
159
|
+
export function fillStartForm(form, data, t) {
|
|
160
|
+
const personas = data?.personas ?? [];
|
|
161
|
+
const runtimes = data?.runtimes ?? [];
|
|
162
|
+
const ps = form.querySelector('select[name="persona"]');
|
|
163
|
+
const rs = form.querySelector('select[name="runtime"]');
|
|
164
|
+
const help = form.querySelector("[data-persona-help]");
|
|
165
|
+
const ready = form.querySelector("[data-runtime-ready]");
|
|
166
|
+
const go = form.querySelector('button[type="submit"]');
|
|
167
|
+
ps.innerHTML = personaOptions(personas);
|
|
168
|
+
const check = () => {
|
|
169
|
+
const r = readiness(data, ps.value, rs.value, t);
|
|
170
|
+
if (ready) {
|
|
171
|
+
ready.innerHTML = r.html;
|
|
172
|
+
ready.hidden = !r.html;
|
|
173
|
+
ready.dataset.tone = r.tone;
|
|
174
|
+
}
|
|
175
|
+
if (go) {
|
|
176
|
+
go.disabled = !r.ok;
|
|
177
|
+
go.dataset.blocked = r.ok ? "" : "runtime";
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const sync = () => {
|
|
181
|
+
const keep = rs.value;
|
|
182
|
+
rs.innerHTML = runtimeOptions(personas, runtimes, ps.value, t);
|
|
183
|
+
if ([...rs.options].some((o) => o.value === keep && !o.disabled))
|
|
184
|
+
rs.value = keep;
|
|
185
|
+
if (help) {
|
|
186
|
+
const role = personaHelp(personas, ps.value);
|
|
187
|
+
help.textContent = role;
|
|
188
|
+
help.hidden = !role;
|
|
189
|
+
}
|
|
190
|
+
check();
|
|
191
|
+
};
|
|
192
|
+
ps.onchange = sync;
|
|
193
|
+
rs.onchange = check;
|
|
194
|
+
sync();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether a form's Start is held back by its runtime (readiness). */
|
|
198
|
+
export function blockedByRuntime(form) {
|
|
199
|
+
return (
|
|
200
|
+
form.querySelector('button[type="submit"]')?.dataset.blocked === "runtime"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -15,8 +15,20 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Nothing here keeps a session's state: the list is GET /api/sessions,
|
|
17
17
|
* asked again every few seconds while the page is shown.
|
|
18
|
+
*
|
|
19
|
+
* Start agent (AG-0.2, LK-1): the lock is checked before the dialog opens
|
|
20
|
+
* (the server, then the vault: checkUnlock). Locked, the dialog asks for
|
|
21
|
+
* the password inline at its top, and Start unlocks, then starts, without
|
|
22
|
+
* closing anything. The runtime line says what will run, or why it
|
|
23
|
+
* can't, before Start (agent-start.js readiness).
|
|
18
24
|
*/
|
|
19
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
checkUnlock,
|
|
27
|
+
onLockChange,
|
|
28
|
+
requireUnlock,
|
|
29
|
+
unlockWith,
|
|
30
|
+
} from "/topbar.js";
|
|
31
|
+
import { fillStartForm } from "/agent-start.js";
|
|
20
32
|
|
|
21
33
|
const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
|
|
22
34
|
const esc = (s) =>
|
|
@@ -477,7 +489,7 @@ function closePane(id, { quiet = false } = {}) {
|
|
|
477
489
|
|
|
478
490
|
async function stopAgent(p, button) {
|
|
479
491
|
const r = rowOf(p.id);
|
|
480
|
-
if (!r?.agent || !requireUnlock()) return;
|
|
492
|
+
if (!r?.agent || !(await requireUnlock())) return;
|
|
481
493
|
button.disabled = true;
|
|
482
494
|
try {
|
|
483
495
|
await api(
|
|
@@ -515,28 +527,67 @@ async function openStart() {
|
|
|
515
527
|
window.denShell?.errorToast?.(err);
|
|
516
528
|
return;
|
|
517
529
|
}
|
|
518
|
-
st.root.querySelector("#ag-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
)
|
|
523
|
-
.join("");
|
|
524
|
-
st.root.querySelector("#ag-runtime").innerHTML =
|
|
525
|
-
`<option value="">${esc(t("ag.runtime.auto"))}</option>` +
|
|
526
|
-
(data.runtimes ?? [])
|
|
527
|
-
.map(
|
|
528
|
-
(r) =>
|
|
529
|
-
`<option value="${esc(r.id)}"${r.available ? "" : " disabled"}>${esc(r.id)}${r.available ? "" : ` — ${esc(t("ag.unavailable"))}`}</option>`,
|
|
530
|
-
)
|
|
531
|
-
.join("");
|
|
530
|
+
fillStartForm(st.root.querySelector("#ag-form"), data, t);
|
|
531
|
+
// LK-1: the lock before the modal. Locked, its password is asked here.
|
|
532
|
+
paintInlineLock(!(await checkUnlock()));
|
|
533
|
+
if (dlg.open) return;
|
|
532
534
|
if (typeof dlg.showModal === "function") dlg.showModal();
|
|
533
535
|
else dlg.setAttribute("open", "");
|
|
536
|
+
st.root.querySelector("#ag-lock:not([hidden]) input")?.focus();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Show (or hide) the dialog's inline password field (LK-1). */
|
|
540
|
+
function paintInlineLock(locked) {
|
|
541
|
+
const box = st.root?.querySelector("#ag-lock");
|
|
542
|
+
if (!box) return;
|
|
543
|
+
box.hidden = !locked;
|
|
544
|
+
const err = box.querySelector("#ag-lock-err");
|
|
545
|
+
if (!locked) {
|
|
546
|
+
box.querySelector("#ag-lock-pw").value = "";
|
|
547
|
+
err.hidden = true;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The lock, for Start: the dialog's own field when it shows one (unlock,
|
|
553
|
+
* then go on), else the shell's rule (its popover is in the top layer).
|
|
554
|
+
*/
|
|
555
|
+
async function unlockForStart(go) {
|
|
556
|
+
const box = st.root.querySelector("#ag-lock");
|
|
557
|
+
if (box.hidden) return requireUnlock();
|
|
558
|
+
if (await checkUnlock()) {
|
|
559
|
+
paintInlineLock(false);
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
const input = box.querySelector("#ag-lock-pw");
|
|
563
|
+
const err = box.querySelector("#ag-lock-err");
|
|
564
|
+
if (!input.value) {
|
|
565
|
+
err.textContent = t("lock.title");
|
|
566
|
+
err.hidden = false;
|
|
567
|
+
input.focus();
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
go.disabled = true;
|
|
571
|
+
go.textContent = t("lock.checking");
|
|
572
|
+
const r = await unlockWith(input.value);
|
|
573
|
+
go.disabled = false;
|
|
574
|
+
go.textContent = t("ag.go");
|
|
575
|
+
if (r.ok) {
|
|
576
|
+
paintInlineLock(false);
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
err.textContent = r.error;
|
|
580
|
+
err.hidden = false;
|
|
581
|
+
input.value = "";
|
|
582
|
+
input.focus();
|
|
583
|
+
return false;
|
|
534
584
|
}
|
|
535
585
|
|
|
536
586
|
async function submitStart(form) {
|
|
537
|
-
if (!requireUnlock()) return;
|
|
538
587
|
const dlg = st.root.querySelector("#ag-dlg");
|
|
539
588
|
const go = st.root.querySelector("#ag-go");
|
|
589
|
+
if (go.disabled) return;
|
|
590
|
+
if (!(await unlockForStart(go))) return;
|
|
540
591
|
const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
|
|
541
592
|
const persona = form.querySelector("#ag-persona").value;
|
|
542
593
|
const runtime = form.querySelector("#ag-runtime").value;
|
|
@@ -579,6 +630,8 @@ function translateStatic(root) {
|
|
|
579
630
|
set("#ag-r-t", "ag.runtime");
|
|
580
631
|
set("#ag-cancel", "ag.cancel");
|
|
581
632
|
set("#ag-go", "ag.go");
|
|
633
|
+
set("#ag-lock-t", "lock.pw");
|
|
634
|
+
set("#ag-lock-note", "lock.title");
|
|
582
635
|
const h = root.querySelector("#ag-dlg-h");
|
|
583
636
|
if (h) h.textContent = h.textContent.replace(/^\+\s*/, "");
|
|
584
637
|
}
|
|
@@ -614,6 +667,10 @@ export async function mountAgentsPage(root) {
|
|
|
614
667
|
root.querySelector("#ag-start").addEventListener("click", () => {
|
|
615
668
|
void openStart();
|
|
616
669
|
});
|
|
670
|
+
// Unlocked elsewhere (the chip, another tab): the field goes.
|
|
671
|
+
onLockChange((unlocked) => {
|
|
672
|
+
if (unlocked) paintInlineLock(false);
|
|
673
|
+
});
|
|
617
674
|
root.querySelector("#ag-cancel").addEventListener("click", () => {
|
|
618
675
|
root.querySelector("#ag-dlg").close?.();
|
|
619
676
|
});
|
|
@@ -621,6 +678,15 @@ export async function mountAgentsPage(root) {
|
|
|
621
678
|
e.preventDefault();
|
|
622
679
|
void submitStart(e.currentTarget);
|
|
623
680
|
});
|
|
681
|
+
// Enter starts from any field (a select doesn't submit on its own); Esc
|
|
682
|
+
// cancels (the dialog's own cancel). A button keeps its own Enter.
|
|
683
|
+
root.querySelector("#ag-form").addEventListener("keydown", (e) => {
|
|
684
|
+
if (e.key !== "Enter" || e.isComposing) return;
|
|
685
|
+
if (e.target.closest("button, textarea")) return;
|
|
686
|
+
e.preventDefault();
|
|
687
|
+
if (!st.root.querySelector("#ag-go").disabled)
|
|
688
|
+
e.currentTarget.requestSubmit();
|
|
689
|
+
});
|
|
624
690
|
// The terminals follow the mode (light, dark) and the theme.
|
|
625
691
|
new MutationObserver(() => {
|
|
626
692
|
for (const p of st.panes.values())
|