@monster-orange/den 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/docs/distributions.md +204 -14
  3. package/package.json +3 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/den-claude.ts +99 -1
  7. package/src/shared/agents/runtime.ts +7 -0
  8. package/src/shared/agents/runtimes.ts +9 -2
  9. package/src/shared/environment-manager/scope.ts +22 -0
  10. package/src/shared/environment-manager/service-runner.ts +94 -2
  11. package/src/shared/profile.ts +15 -4
  12. package/src/shared/sessions/client.ts +9 -2
  13. package/src/shared/sessions/env.ts +13 -3
  14. package/src/shared/sessions/host.ts +23 -0
  15. package/src/shared/web-secret.ts +181 -0
  16. package/src/shared/web-session.ts +491 -0
  17. package/src/ui/cli/agent-ops.ts +843 -0
  18. package/src/ui/cli/agents-command.ts +96 -614
  19. package/src/ui/cli/flag-parser.ts +21 -0
  20. package/src/ui/cli/index.ts +94 -10
  21. package/src/ui/cli/sessions-command.ts +32 -0
  22. package/src/ui/cli/target-workspace.ts +105 -0
  23. package/src/ui/cli/web-command.ts +536 -14
  24. package/src/ui/mcp/api.ts +7 -0
  25. package/src/ui/mcp/server.ts +74 -6
  26. package/src/ui/server/agent-routes.ts +323 -0
  27. package/src/ui/server/audit.ts +81 -27
  28. package/src/ui/server/http-errors.ts +21 -3
  29. package/src/ui/server/index.ts +814 -66
  30. package/src/ui/server/public/agents-page.js +648 -0
  31. package/src/ui/server/public/agents-section.js +299 -0
  32. package/src/ui/server/public/agents.html +439 -0
  33. package/src/ui/server/public/appearance.js +3 -0
  34. package/src/ui/server/public/error-page.js +103 -0
  35. package/src/ui/server/public/header.js +45 -11
  36. package/src/ui/server/public/i18n/es.js +90 -1
  37. package/src/ui/server/public/i18n/ja.js +86 -1
  38. package/src/ui/server/public/local-dev.html +53 -0
  39. package/src/ui/server/public/logs.html +3 -0
  40. package/src/ui/server/public/panel.js +1 -1
  41. package/src/ui/server/public/release.html +8 -0
  42. package/src/ui/server/public/settings-page.js +5 -0
  43. package/src/ui/server/public/settings-registry.js +33 -0
  44. package/src/ui/server/public/shell.js +248 -5
  45. package/src/ui/server/public/topbar.js +51 -2
  46. package/src/ui/server/request-guard.ts +670 -0
  47. package/src/ui/server/route-classes.ts +317 -0
  48. package/src/ui/server/session-routes.ts +445 -0
  49. package/src/ui/server/shutdown-guard.ts +91 -0
  50. package/src/ui/server/sudo-store.ts +29 -3
  51. package/src/ui/server/ui-assets.ts +10 -1
  52. package/src/ui/server/websocket.ts +326 -0
  53. package/src/ui/server/workspace-routes.ts +13 -7
@@ -0,0 +1,445 @@
1
+ /**
2
+ * The Agents tab's routes (AG-4a-term, AG spec §5d):
3
+ *
4
+ * GET /api/sessions every session and agent in the active
5
+ * workspaces (read-only class)
6
+ * WS /api/sessions/<id>/io a session's terminal (shell-grade)
7
+ *
8
+ * The list joins the session host's sessions to the agent records, as
9
+ * `<cli> agents list` does (agent-ops.ts agentRows), and adds the plain
10
+ * sessions (`sessions start`), labelled kind "session". An agent whose
11
+ * actor token `web logout --all` revoked is marked (tokenRevoked). The
12
+ * host knows nothing of approvals yet, so `waiting` is never set here
13
+ * (AG-6a adds it). Asking never starts a session host.
14
+ *
15
+ * The io route bridges one WebSocket to one attachment on the session host
16
+ * (its own connection to the host, so a browser and the CLI, or two
17
+ * browsers, attach at once and each gets every byte):
18
+ * - on attach, the scrollback replay, then live output: binary frames;
19
+ * - a text frame `{"type":"session",…}` first (what is attached), and
20
+ * `{"type":"exit","exitCode":n}` when the session ends;
21
+ * - in: binary frames are input; a text frame is JSON control:
22
+ * `{"type":"resize","cols":c,"rows":r}` (or `{"type":"input","data":s}`).
23
+ * Closing the socket detaches. Nothing here ever signals a session: leaving
24
+ * the tab never kills it.
25
+ *
26
+ * The request checks ran before (index.ts, request-guard.ts): Host, Origin
27
+ * on the upgrade, CSRF (the `den.csrf.<token>` subprotocol: a browser's
28
+ * WebSocket can't send a header), a signed-in session beyond loopback, and
29
+ * an agent token's own session only. The attach and the detach are audited
30
+ * with the actor from the credential.
31
+ */
32
+ import type * as http from "http";
33
+ import type { Duplex } from "stream";
34
+ import { getActiveWorkspaces } from "../../shared/environment-manager/workspaces.js";
35
+ import { getStateDir } from "../../shared/paths.js";
36
+ import { product } from "../../shared/product.js";
37
+ import { revokedAgentIds } from "../../shared/actor-token.js";
38
+ import { listRecords } from "../../shared/agents/records.js";
39
+ import {
40
+ connectHost,
41
+ noHost,
42
+ type HostClient,
43
+ } from "../../shared/sessions/client.js";
44
+ import { parseSessionName } from "../../shared/sessions/names.js";
45
+ import { sessionPaths } from "../../shared/sessions/paths.js";
46
+ import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
47
+ import type { SessionInfo } from "../../shared/sessions/types.js";
48
+ import { agentRows } from "../cli/agent-ops.js";
49
+ import { state as sessionState } from "../cli/sessions-command.js";
50
+ import { recordAction } from "./audit.js";
51
+ import { sendJson } from "./http-helpers.js";
52
+ import { auditActorOf, principalOf } from "./request-guard.js";
53
+ import {
54
+ acceptWebSocket,
55
+ handshakeRefusal,
56
+ refuseUpgrade,
57
+ WS_CLOSE,
58
+ } from "./websocket.js";
59
+
60
+ export interface SessionRouteDeps {
61
+ stateDir?: () => string;
62
+ /** The distribution's name: the `<dist>` of a session name. */
63
+ dist?: () => string;
64
+ /** A client of the running host, or null (never starts one). */
65
+ connectExisting?: () => Promise<HostClient | null>;
66
+ activeWorkspaces?: () => string[];
67
+ pty?: () => PtyLoad;
68
+ }
69
+
70
+ interface Resolved {
71
+ stateDir: () => string;
72
+ dist: () => string;
73
+ connectExisting: () => Promise<HostClient | null>;
74
+ activeWorkspaces: () => string[];
75
+ pty: () => PtyLoad;
76
+ }
77
+
78
+ function resolve(d: SessionRouteDeps = {}): Resolved {
79
+ const stateDir = d.stateDir ?? (() => getStateDir());
80
+ return {
81
+ stateDir,
82
+ dist: d.dist ?? (() => product().name),
83
+ connectExisting:
84
+ d.connectExisting ??
85
+ (async () => {
86
+ try {
87
+ return await connectHost(sessionPaths(stateDir()).socket);
88
+ } catch (e) {
89
+ if (noHost(e)) return null;
90
+ throw e;
91
+ }
92
+ }),
93
+ activeWorkspaces: d.activeWorkspaces ?? (() => getActiveWorkspaces()),
94
+ pty: d.pty ?? (() => loadPty()),
95
+ };
96
+ }
97
+
98
+ /** The subprotocol the io route answers with. */
99
+ export const IO_PROTOCOL = "den.io";
100
+
101
+ const IO_PATH = /^\/api\/sessions\/([^/]+)\/io$/;
102
+
103
+ /** One row of the Agents tab's list. */
104
+ export interface ListedSession {
105
+ /** The session host's id; for an agent with no session, its last one. */
106
+ id: string;
107
+ /** `<dist>-<ws>:<name>`. */
108
+ name: string;
109
+ workspace: string;
110
+ kind: "agent" | "session";
111
+ /** The `<name>` part: an agent's id, a plain session's name. */
112
+ label: string;
113
+ agent?: string;
114
+ persona?: string;
115
+ runtime?: string;
116
+ model?: string;
117
+ cwd?: string;
118
+ /** running, stopping, exited (<code>), stopped, ended. */
119
+ state: string;
120
+ exited: boolean;
121
+ exitCode?: number;
122
+ startedAt?: string;
123
+ /** The host has it (running, or ended with its scrollback): a pane can attach. */
124
+ attachable: boolean;
125
+ resumable?: boolean;
126
+ /** Its actor token was revoked (`web logout --all`): restart or resume it. */
127
+ tokenRevoked?: boolean;
128
+ /** An approval is pending (AG-6a; only when the host knows). */
129
+ waiting?: boolean;
130
+ }
131
+
132
+ /** The list, from the host's sessions and the agent records. */
133
+ export function listedSessions(
134
+ sessions: SessionInfo[],
135
+ d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces">,
136
+ ): ListedSession[] {
137
+ const dist = d.dist();
138
+ const active = new Set(d.activeWorkspaces());
139
+ const mine = sessions.filter((s) => {
140
+ const p = parseSessionName(s.name, dist);
141
+ return p !== null && active.has(p.workspace);
142
+ });
143
+ const records = listRecords(d.stateDir()).filter((r) =>
144
+ active.has(r.workspace),
145
+ );
146
+ const revoked = revokedAgentIds(d.stateDir());
147
+ const out: ListedSession[] = [];
148
+ const agentSessions = new Set<string>();
149
+ for (const r of agentRows(mine, records)) {
150
+ const rec = records.find((x) => x.id === r.agent)!;
151
+ const s = r.session;
152
+ if (s) agentSessions.add(s.ref.id);
153
+ out.push({
154
+ id: s?.ref.id ?? rec.session.id,
155
+ name: s?.name ?? rec.sessionName,
156
+ workspace: rec.workspace,
157
+ kind: "agent",
158
+ label: rec.id,
159
+ agent: rec.id,
160
+ persona: rec.persona,
161
+ runtime: rec.runtime,
162
+ ...(rec.model ? { model: rec.model } : {}),
163
+ cwd: rec.cwd,
164
+ state: r.state,
165
+ exited: !s || s.exited === true,
166
+ ...(s?.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
167
+ ...(r.startedAt ? { startedAt: r.startedAt } : {}),
168
+ attachable: Boolean(s),
169
+ resumable: r.resumable,
170
+ ...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
171
+ });
172
+ }
173
+ for (const s of mine) {
174
+ if (agentSessions.has(s.ref.id)) continue;
175
+ const p = parseSessionName(s.name, dist)!;
176
+ out.push({
177
+ id: s.ref.id,
178
+ name: s.name,
179
+ workspace: p.workspace,
180
+ kind: "session",
181
+ label: p.name,
182
+ state: sessionState(s),
183
+ exited: s.exited === true,
184
+ ...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
185
+ startedAt: s.startedAt,
186
+ attachable: true,
187
+ });
188
+ }
189
+ const order = [...active];
190
+ return out.sort(
191
+ (a, b) =>
192
+ order.indexOf(a.workspace) - order.indexOf(b.workspace) ||
193
+ (a.startedAt ?? "").localeCompare(b.startedAt ?? "") ||
194
+ a.label.localeCompare(b.label),
195
+ );
196
+ }
197
+
198
+ /**
199
+ * `GET /api/sessions`; false when `pathname` isn't it. `ioAllowed` is what
200
+ * the request checks say about this browser: beyond loopback without a
201
+ * session, the page lists but can't open a terminal.
202
+ */
203
+ export async function handleSessionList(
204
+ req: http.IncomingMessage,
205
+ res: http.ServerResponse,
206
+ pathname: string,
207
+ ioAllowed: boolean,
208
+ deps: SessionRouteDeps = {},
209
+ ): Promise<boolean> {
210
+ if (pathname !== "/api/sessions") return false;
211
+ if (req.method !== "GET" && req.method !== "HEAD") {
212
+ res.setHeader("Allow", "GET");
213
+ sendJson(res, { ok: false, error: "method not allowed" }, 405);
214
+ return true;
215
+ }
216
+ const d = resolve(deps);
217
+ const pty = d.pty();
218
+ let sessions: SessionInfo[] = [];
219
+ let host = false;
220
+ let hostError: string | undefined;
221
+ try {
222
+ const c = await d.connectExisting();
223
+ if (c) {
224
+ host = true;
225
+ try {
226
+ sessions = await c.list();
227
+ } finally {
228
+ c.close();
229
+ }
230
+ }
231
+ } catch (e) {
232
+ hostError = (e as Error).message;
233
+ }
234
+ sendJson(res, {
235
+ sessions: listedSessions(sessions, d),
236
+ workspaces: d.activeWorkspaces(),
237
+ host,
238
+ ...(hostError ? { hostError } : {}),
239
+ backend: pty.available
240
+ ? { id: "den-pty", available: true }
241
+ : { id: "den-pty", available: false, reason: pty.reason },
242
+ io: { allowed: ioAllowed },
243
+ });
244
+ return true;
245
+ }
246
+
247
+ /** Whether `pathname` is a session's io route. */
248
+ export function isSessionIoPath(pathname: string): boolean {
249
+ return IO_PATH.test(pathname);
250
+ }
251
+
252
+ const TEXT = (code: number) =>
253
+ code === 400
254
+ ? "Bad Request"
255
+ : code === 404
256
+ ? "Not Found"
257
+ : code === 426
258
+ ? "Upgrade Required"
259
+ : "Service Unavailable";
260
+
261
+ /**
262
+ * A plain HTTP request to an io route: it is a WebSocket. 426 for a GET,
263
+ * 404 for anything else under /api/sessions/.
264
+ */
265
+ export function sendSessionHttp(
266
+ req: http.IncomingMessage,
267
+ res: http.ServerResponse,
268
+ pathname: string,
269
+ ): void {
270
+ if (
271
+ IO_PATH.test(pathname) &&
272
+ (req.method === "GET" || req.method === "HEAD")
273
+ ) {
274
+ res.setHeader("Upgrade", "websocket");
275
+ res.setHeader("Connection", "Upgrade");
276
+ sendJson(
277
+ res,
278
+ {
279
+ ok: false,
280
+ error: "This is a WebSocket: open it with an upgrade",
281
+ code: "upgrade_required",
282
+ },
283
+ 426,
284
+ );
285
+ return;
286
+ }
287
+ sendJson(
288
+ res,
289
+ { ok: false, error: "No such session route", code: "not_found" },
290
+ 404,
291
+ );
292
+ }
293
+
294
+ /**
295
+ * `GET /api/sessions/<id>/io` with an upgrade, after the request checks.
296
+ * `<id>` is the host's id or the full session name. Refusals answer as
297
+ * HTTP on the raw socket; past them, the WebSocket bridge above.
298
+ */
299
+ export async function handleSessionUpgrade(
300
+ req: http.IncomingMessage,
301
+ socket: Duplex,
302
+ head: Buffer,
303
+ pathname: string,
304
+ requestId: string,
305
+ deps: SessionRouteDeps = {},
306
+ ): Promise<void> {
307
+ const refuse = (status: number, error: string, code: string) =>
308
+ refuseUpgrade(
309
+ socket,
310
+ status,
311
+ { ok: false, error, code, requestId },
312
+ TEXT(status),
313
+ );
314
+ const m = IO_PATH.exec(pathname);
315
+ if (!m) return refuse(404, "No WebSocket route here", "not_found");
316
+ const bad = handshakeRefusal(req);
317
+ if (bad) return refuse(400, bad, "bad_handshake");
318
+ let target = m[1];
319
+ try {
320
+ target = decodeURIComponent(target);
321
+ } catch {
322
+ /* as given */
323
+ }
324
+ const d = resolve(deps);
325
+ let c: HostClient | null;
326
+ try {
327
+ c = await d.connectExisting();
328
+ } catch (e) {
329
+ return refuse(
330
+ 503,
331
+ `the session host: ${(e as Error).message}`,
332
+ "session_host",
333
+ );
334
+ }
335
+ if (!c)
336
+ return refuse(
337
+ 404,
338
+ `no session "${target}" (no session host is running)`,
339
+ "not_found",
340
+ );
341
+ const client = c;
342
+ let info: SessionInfo | undefined;
343
+ try {
344
+ const dist = d.dist();
345
+ const active = new Set(d.activeWorkspaces());
346
+ info = (await client.list()).find((s) => {
347
+ const p = parseSessionName(s.name, dist);
348
+ return (
349
+ p !== null &&
350
+ active.has(p.workspace) &&
351
+ (s.ref.id === target || s.name === target)
352
+ );
353
+ });
354
+ } catch (e) {
355
+ client.close();
356
+ return refuse(
357
+ 503,
358
+ `the session host: ${(e as Error).message}`,
359
+ "session_host",
360
+ );
361
+ }
362
+ if (!info) {
363
+ client.close();
364
+ return refuse(404, `no session "${target}" here`, "not_found");
365
+ }
366
+ if (socket.destroyed) {
367
+ client.close();
368
+ return;
369
+ }
370
+
371
+ const s = info;
372
+ const actor = auditActorOf(req);
373
+ const principal = principalOf(req);
374
+ const audit = (detail: string) =>
375
+ recordAction(actor, { verb: "sessions", detail }, principal);
376
+ const ws = acceptWebSocket(req, socket, head, { protocol: IO_PROTOCOL });
377
+ const since = Date.now();
378
+ audit(`attach ${s.name} (browser)`);
379
+ const p = parseSessionName(s.name, d.dist())!;
380
+ ws.send(
381
+ JSON.stringify({
382
+ type: "session",
383
+ id: s.ref.id,
384
+ name: s.name,
385
+ workspace: p.workspace,
386
+ label: p.name,
387
+ exited: s.exited === true,
388
+ ...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
389
+ }),
390
+ );
391
+ let exited = s.exited === true;
392
+ const att = client.attach(s.ref.id, {
393
+ onData: (data) => ws.send(data),
394
+ onExit: (exitCode) => {
395
+ exited = true;
396
+ ws.send(
397
+ JSON.stringify({
398
+ type: "exit",
399
+ ...(exitCode !== undefined ? { exitCode } : {}),
400
+ }),
401
+ );
402
+ },
403
+ });
404
+ att.ready.catch((e: Error) => {
405
+ ws.send(JSON.stringify({ type: "error", error: e.message }));
406
+ ws.close(WS_CLOSE.internal, "attach failed");
407
+ });
408
+ ws.on("message", (data: Buffer, binary: boolean) => {
409
+ if (binary) {
410
+ if (!exited && data.length) client.write(s.ref.id, data);
411
+ return;
412
+ }
413
+ let msg: { type?: unknown; cols?: unknown; rows?: unknown; data?: unknown };
414
+ try {
415
+ msg = JSON.parse(data.toString("utf-8")) as typeof msg;
416
+ } catch {
417
+ return;
418
+ }
419
+ if (msg?.type === "resize") {
420
+ const ok = (n: unknown): n is number =>
421
+ typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 1000;
422
+ if (!exited && ok(msg.cols) && ok(msg.rows))
423
+ client.resize(s.ref.id, msg.cols, msg.rows);
424
+ } else if (msg?.type === "input" && typeof msg.data === "string") {
425
+ if (!exited && msg.data)
426
+ client.write(s.ref.id, Buffer.from(msg.data, "utf-8"));
427
+ }
428
+ });
429
+ let done = false;
430
+ const detach = () => {
431
+ if (done) return;
432
+ done = true;
433
+ // Detach only: the session keeps running in the host.
434
+ att.detach();
435
+ client.close();
436
+ audit(
437
+ `detach ${s.name} (browser, ${Math.round((Date.now() - since) / 1000)}s)`,
438
+ );
439
+ };
440
+ ws.on("close", detach);
441
+ client.once("close", () => {
442
+ if (ws.open) ws.close(WS_CLOSE.goingAway, "the session host went away");
443
+ detach();
444
+ });
445
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The web server's exit, bounded whatever happens.
3
+ *
4
+ * 1. **Lost output.** When the reader of the server's stdout or stderr goes
5
+ * away (the terminal, test runner or script that started it was killed),
6
+ * every write fails with EPIPE. Unhandled, that error reached the
7
+ * "continue" uncaughtException guard, which logged it with console.error
8
+ * to the equally dead stderr, which failed again: a tight loop that pinned
9
+ * a core and stopped the server answering requests, for as long as the
10
+ * orphan lived. Now the streams' errors are handled here, nothing is
11
+ * written once the output is gone, and the server shuts down (its launcher
12
+ * is gone; nobody can see it).
13
+ * 2. **A deadline.** The first SIGTERM or SIGINT starts the shutdown; when
14
+ * nothing waits on the person (stdin isn't a TTY) a timer forces the exit
15
+ * after `DEN_SHUTDOWN_DEADLINE_MS` (10 s), whatever the shutdown is stuck
16
+ * on. A second signal exits at once (it was ignored, which left a stuck
17
+ * shutdown deaf to every later SIGTERM).
18
+ */
19
+
20
+ const DEFAULT_DEADLINE_MS = 10_000;
21
+
22
+ let lost = false;
23
+ let started = false;
24
+
25
+ /** An error that says the stream's reader is gone. */
26
+ export function isLostOutputError(e: unknown): boolean {
27
+ const code = (e as { code?: unknown } | null)?.code;
28
+ return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
29
+ }
30
+
31
+ /** True once stdout or stderr has lost its reader: write nothing more. */
32
+ export function outputLost(): boolean {
33
+ return lost;
34
+ }
35
+
36
+ /**
37
+ * Handle stdout's and stderr's errors (so none is ever uncaught); on the
38
+ * first lost-output error, call `onLost` once.
39
+ */
40
+ export function guardStdio(onLost: () => void): void {
41
+ for (const s of [process.stdout, process.stderr]) {
42
+ s.on("error", (e: unknown) => {
43
+ if (!isLostOutputError(e) || lost) return;
44
+ lost = true;
45
+ onLost();
46
+ });
47
+ }
48
+ }
49
+
50
+ /** The deadline, from DEN_SHUTDOWN_DEADLINE_MS (ms, > 0), else 10 s. */
51
+ export function shutdownDeadlineMs(
52
+ env: NodeJS.ProcessEnv = process.env,
53
+ ): number {
54
+ const n = Number(env.DEN_SHUTDOWN_DEADLINE_MS);
55
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_DEADLINE_MS;
56
+ }
57
+
58
+ function say(msg: string): void {
59
+ if (lost) return;
60
+ try {
61
+ process.stderr.write(msg + "\n");
62
+ } catch {
63
+ /* nowhere left to say it */
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Start the shutdown for `signal` (`run`), once. A later call exits at once;
69
+ * a shutdown nobody waits on is forced to exit after the deadline.
70
+ */
71
+ export function requestShutdown(
72
+ signal: string,
73
+ run: (signal: string) => void,
74
+ opts: { interactive: boolean; deadlineMs?: number },
75
+ ): void {
76
+ if (started) {
77
+ say(`[server] ${signal} again: exiting now.`);
78
+ process.exit(1);
79
+ }
80
+ started = true;
81
+ if (!opts.interactive) {
82
+ const ms = opts.deadlineMs ?? shutdownDeadlineMs();
83
+ setTimeout(() => {
84
+ say(
85
+ `[server] ${signal}: the shutdown did not finish within ${ms} ms; exiting.`,
86
+ );
87
+ process.exit(1);
88
+ }, ms).unref();
89
+ }
90
+ run(signal);
91
+ }
@@ -9,7 +9,15 @@
9
9
  * because intermediate launchers (opctl, make, etc.) may reset the environment
10
10
  * before sudo is invoked. The cache is valid for ~5 min by default, which is
11
11
  * more than enough for a service to finish its startup sudo calls.
12
+ *
13
+ * SEC-1c (R8, SEC-1 §5): an agent's request never passes the lock. The
14
+ * server runs every request in its own lock scope (inRequestLockScope) and
15
+ * closes it for an agent's actor token (closeLockForThisRequest): inside a
16
+ * closed scope, and in everything the request starts (its spawned
17
+ * commands' callbacks included), the store reads as locked and hands out
18
+ * no sudo wrapper and warms no sudo cache.
12
19
  */
20
+ import { AsyncLocalStorage } from "async_hooks";
13
21
 
14
22
  import * as fs from "fs";
15
23
  import * as os from "os";
@@ -19,6 +27,23 @@ import { CLI_SPAWN_TIMEOUT_MS } from "../../shared/constants.js";
19
27
 
20
28
  import { product } from "../../shared/product.js";
21
29
  let _sudoPassword: string | null = null;
30
+ const lockScope = new AsyncLocalStorage<{ closed: boolean }>();
31
+
32
+ /** Run one request in its own lock scope (open unless closed below). */
33
+ export function inRequestLockScope<T>(fn: () => T): T {
34
+ return lockScope.run({ closed: false }, fn);
35
+ }
36
+
37
+ /** This request is an agent's: the lock stays shut for all it starts. */
38
+ export function closeLockForThisRequest(): void {
39
+ const s = lockScope.getStore();
40
+ if (s) s.closed = true;
41
+ }
42
+
43
+ /** Whether the current request's lock scope is closed (an agent's). */
44
+ export function lockClosedHere(): boolean {
45
+ return lockScope.getStore()?.closed === true;
46
+ }
22
47
  let _sudoDir: string | null = null;
23
48
 
24
49
  export function setSudoPassword(password: string): void {
@@ -53,7 +78,7 @@ export function clearSudoPassword(): void {
53
78
  * 'sudo' call in the spawned process tree uses the stored password.
54
79
  */
55
80
  export function getSudoEnv(): NodeJS.ProcessEnv {
56
- if (!_sudoDir) return {};
81
+ if (!_sudoDir || lockClosedHere()) return {};
57
82
  const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
58
83
  return { PATH: `${_sudoDir}:${currentPath}` };
59
84
  }
@@ -63,7 +88,7 @@ export function getSudoEnv(): NodeJS.ProcessEnv {
63
88
  * belt-and-suspenders fallback alongside the PATH wrapper.
64
89
  */
65
90
  export function prewarmSudo(): void {
66
- if (!_sudoPassword) return;
91
+ if (!_sudoPassword || lockClosedHere()) return;
67
92
  try {
68
93
  execSync("sudo -S -v", {
69
94
  input: _sudoPassword + "\n",
@@ -77,7 +102,7 @@ export function prewarmSudo(): void {
77
102
 
78
103
  /** True when a sudo password has been set. */
79
104
  export function hasSudoPassword(): boolean {
80
- return _sudoDir !== null;
105
+ return _sudoDir !== null && !lockClosedHere();
81
106
  }
82
107
 
83
108
  let _passwordlessCache: { value: boolean; at: number } | null = null;
@@ -92,6 +117,7 @@ const PASSWORDLESS_TTL_MS = 60_000;
92
117
  * status endpoint doesn't spawn sudo on every check.
93
118
  */
94
119
  export function hasPasswordlessSudo(): boolean {
120
+ if (lockClosedHere()) return false;
95
121
  const now = Date.now();
96
122
  if (_passwordlessCache && now - _passwordlessCache.at < PASSWORDLESS_TTL_MS) {
97
123
  return _passwordlessCache.value;
@@ -32,6 +32,8 @@ export const UI_MODULES: readonly string[] = [
32
32
  "/topbar.js",
33
33
  "/edge-page.js",
34
34
  "/error-page.js",
35
+ "/agents-section.js",
36
+ "/agents-page.js",
35
37
  // The UI's catalogs other than English (R19; en is in shell.js).
36
38
  "/i18n/ja.js",
37
39
  "/i18n/es.js",
@@ -40,6 +42,11 @@ export const UI_MODULES: readonly string[] = [
40
42
  /** URL path → file inside node_modules. */
41
43
  export const VENDOR_ASSETS: Readonly<Record<string, string>> = {
42
44
  "/vendor/elk.bundled.js": "elkjs/lib/elk.bundled.js",
45
+ // The Agents tab's terminal panes (AG-4a-term): xterm.js and its fit
46
+ // addon, ES modules, MIT.
47
+ "/vendor/xterm.mjs": "@xterm/xterm/lib/xterm.mjs",
48
+ "/vendor/xterm.css": "@xterm/xterm/css/xterm.css",
49
+ "/vendor/addon-fit.mjs": "@xterm/addon-fit/lib/addon-fit.mjs",
43
50
  };
44
51
 
45
52
  /** The installed file for a vendored URL path, or null. */
@@ -81,7 +88,9 @@ export function serveUiAsset(
81
88
  // Pinned versions: the bytes only change with Den itself. Not run through
82
89
  // the product-token fill the UI's own scripts get.
83
90
  res.writeHead(200, {
84
- "Content-Type": "application/javascript",
91
+ "Content-Type": file.endsWith(".css")
92
+ ? "text/css"
93
+ : "application/javascript",
85
94
  "Cache-Control": "public, max-age=86400",
86
95
  });
87
96
  res.end(fs.readFileSync(file));