@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.
@@ -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
@@ -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
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
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 { requireUnlock } from "/topbar.js";
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-persona").innerHTML = (data.personas ?? [])
519
- .map(
520
- (p) =>
521
- `<option value="${esc(p.id)}">${esc(p.id)}${p.role ? ` — ${esc(p.role)}` : ""}</option>`,
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())