@monster-orange/den 0.4.0 → 0.4.2

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 (58) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +216 -16
  4. package/docs/reference.md +177 -0
  5. package/package.json +3 -1
  6. package/src/shared/actor-token.ts +275 -0
  7. package/src/shared/agent-actor.ts +12 -2
  8. package/src/shared/agents/den-claude.ts +99 -1
  9. package/src/shared/agents/runtime.ts +7 -0
  10. package/src/shared/agents/runtimes.ts +9 -2
  11. package/src/shared/den-config.ts +52 -3
  12. package/src/shared/environment-manager/scope.ts +22 -0
  13. package/src/shared/environment-manager/service-runner.ts +94 -2
  14. package/src/shared/profile.ts +15 -4
  15. package/src/shared/sessions/client.ts +16 -2
  16. package/src/shared/sessions/den-pty.ts +10 -2
  17. package/src/shared/sessions/env.ts +13 -3
  18. package/src/shared/sessions/host.ts +56 -8
  19. package/src/shared/sessions/types.ts +8 -0
  20. package/src/shared/web-secret.ts +181 -0
  21. package/src/shared/web-session.ts +491 -0
  22. package/src/ui/cli/agent-ops.ts +843 -0
  23. package/src/ui/cli/agents-command.ts +97 -615
  24. package/src/ui/cli/flag-parser.ts +21 -0
  25. package/src/ui/cli/index.ts +127 -11
  26. package/src/ui/cli/sessions-command.ts +49 -5
  27. package/src/ui/cli/target-workspace.ts +105 -0
  28. package/src/ui/cli/web-command.ts +542 -14
  29. package/src/ui/mcp/api.ts +7 -0
  30. package/src/ui/mcp/server.ts +74 -6
  31. package/src/ui/server/agent-routes.ts +324 -0
  32. package/src/ui/server/audit.ts +81 -27
  33. package/src/ui/server/http-errors.ts +21 -3
  34. package/src/ui/server/index.ts +821 -66
  35. package/src/ui/server/public/agents-page.js +654 -0
  36. package/src/ui/server/public/agents-section.js +299 -0
  37. package/src/ui/server/public/agents.html +439 -0
  38. package/src/ui/server/public/appearance.js +3 -0
  39. package/src/ui/server/public/error-page.js +103 -0
  40. package/src/ui/server/public/header.js +45 -11
  41. package/src/ui/server/public/i18n/es.js +90 -1
  42. package/src/ui/server/public/i18n/ja.js +86 -1
  43. package/src/ui/server/public/local-dev.html +53 -0
  44. package/src/ui/server/public/logs.html +3 -0
  45. package/src/ui/server/public/panel.js +1 -1
  46. package/src/ui/server/public/release.html +8 -0
  47. package/src/ui/server/public/settings-page.js +5 -0
  48. package/src/ui/server/public/settings-registry.js +33 -0
  49. package/src/ui/server/public/shell.js +248 -5
  50. package/src/ui/server/public/topbar.js +51 -2
  51. package/src/ui/server/request-guard.ts +670 -0
  52. package/src/ui/server/route-classes.ts +317 -0
  53. package/src/ui/server/session-routes.ts +445 -0
  54. package/src/ui/server/shutdown-guard.ts +91 -0
  55. package/src/ui/server/sudo-store.ts +29 -3
  56. package/src/ui/server/ui-assets.ts +10 -1
  57. package/src/ui/server/websocket.ts +326 -0
  58. package/src/ui/server/workspace-routes.ts +13 -7
@@ -19,6 +19,7 @@ import {
19
19
  import { healthCheckFields, type HealthCheckType } from "./health-row.js";
20
20
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
21
21
  import * as http from "http";
22
+ import * as net from "net";
22
23
  import * as fs from "fs";
23
24
  import * as os from "os";
24
25
  import * as path from "path";
@@ -58,6 +59,8 @@ import {
58
59
  import {
59
60
  parseScope,
60
61
  scopeKey,
62
+ pollView,
63
+ inViewMarker,
61
64
  scopePayload,
62
65
  scopeView,
63
66
  storedView,
@@ -127,6 +130,7 @@ import {
127
130
  engineVersion,
128
131
  getDistRoot,
129
132
  getEngineRoot,
133
+ getStateDir,
130
134
  } from "../../shared/paths.js";
131
135
  import { enginePath } from "../../shared/engine.js";
132
136
  import {
@@ -136,7 +140,10 @@ import {
136
140
  type ReleaseEvent,
137
141
  } from "./release-service.js";
138
142
  import { deploySessions } from "./release-deploy-session.js";
139
- import { spawnDetachedWebRestart } from "../cli/web-command.js";
143
+ import {
144
+ hasDenHostsEntry,
145
+ spawnDetachedWebRestart,
146
+ } from "../cli/web-command.js";
140
147
  import { recordInvocation } from "../../shared/dev-metrics/emit.js";
141
148
  import {
142
149
  validateManifest,
@@ -164,7 +171,6 @@ import {
164
171
  import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
165
172
  import { actorLabel } from "../../shared/agent-actor.js";
166
173
  import {
167
- actorFromHeaders,
168
174
  actorSuffix,
169
175
  recordAction,
170
176
  recordAgentRefusal,
@@ -173,6 +179,44 @@ import {
173
179
  setAuditSink,
174
180
  } from "./audit.js";
175
181
  import { handleReadonlyRoutes } from "./readonly-routes.js";
182
+ import {
183
+ auditActorOf,
184
+ bindRefusal,
185
+ checkRequest,
186
+ isEdgePeer,
187
+ isLoopbackAddress,
188
+ isLoopbackBind,
189
+ newCsrfToken,
190
+ principalOf,
191
+ requestIsHttps,
192
+ sessionRequestAllowed,
193
+ setPrincipal,
194
+ signinMessage,
195
+ type GuardContext,
196
+ type GuardResult,
197
+ } from "./request-guard.js";
198
+ import {
199
+ rotateWebSecret,
200
+ verifyPersonToken,
201
+ WebSecretStore,
202
+ } from "../../shared/web-secret.js";
203
+ import {
204
+ ACTOR_TOKEN_PREFIX,
205
+ checkActorToken,
206
+ revokeAllActorTokens,
207
+ } from "../../shared/actor-token.js";
208
+ import {
209
+ checkLoginCode,
210
+ ignoredConfigWarning,
211
+ localhostOrigin,
212
+ readWebAuthConfig,
213
+ safeNext,
214
+ SESSION_COOKIE_MAX_AGE_S,
215
+ sessionCookieName,
216
+ sessionIdPrefix,
217
+ WebSessionStore,
218
+ type WebAuthConfig,
219
+ } from "../../shared/web-session.js";
176
220
  import { serveUiAsset } from "./ui-assets.js";
177
221
  import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
178
222
  import { graphPageHtml } from "./graph-page.js";
@@ -239,10 +283,16 @@ interface HealthResult {
239
283
  /** From the focused workspace: the header sorts these first. */
240
284
  focused?: boolean;
241
285
  /**
242
- * The workspaces in view that define this service (focus first). More
286
+ * The active workspaces that define this service (focus first). More
243
287
  * than one means a shared service: it is listed once, with its owners.
244
288
  */
245
289
  workspaces?: string[];
290
+ /**
291
+ * WS-6.4: whether the view scope shows it (one of its workspaces is in
292
+ * view). Health covers every active workspace; the header's chip and
293
+ * drawer count only these, while the pages see every row.
294
+ */
295
+ inView?: boolean;
246
296
  }
247
297
 
248
298
  /**
@@ -274,6 +324,19 @@ function viewerRewrite(
274
324
  });
275
325
  }
276
326
 
327
+ /**
328
+ * A health row as this request sees it: its links rewritten for the
329
+ * viewer's host, and `inView` from the view scope now (WS-6.4: the rows
330
+ * are cached for every active workspace; the scope is applied per answer).
331
+ */
332
+ function healthForViewer(
333
+ host: string | undefined,
334
+ ): (r: HealthResult) => HealthResult {
335
+ const rewrite = viewerRewrite(host);
336
+ const inView = inViewMarker(scopeView());
337
+ return (r) => ({ ...rewrite(r), inView: inView(r.workspaces) });
338
+ }
339
+
277
340
  const HEALTH_CACHE_TTL_MS = 8_000;
278
341
  /** Consecutive timed-out health cycles per check (see TimeoutStreaks). */
279
342
  const healthTimeouts = new TimeoutStreaks();
@@ -303,10 +366,11 @@ enableReadCache(READ_CACHE_TTL_MS);
303
366
  const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
304
367
  /**
305
368
  * A workspace-scoped run covers a different service set than the default
306
- * one, and the default one covers whatever the view scope has in view.
369
+ * one, and the default one covers every active workspace (WS-6.4: the view
370
+ * scope filters what is displayed, never what is polled).
307
371
  */
308
372
  function healthCacheKey(env: Environment, workspace: string | null): string {
309
- return `${env}|${workspace ?? `*:${scopeKey(scopeView())}`}`;
373
+ return `${env}|${workspace ?? `*:${scopeKey(pollView())}`}`;
310
374
  }
311
375
  import {
312
376
  LOG_SWEEP_INTERVAL_MS,
@@ -435,9 +499,13 @@ function pushLog(kind: string, text: string, actor?: string): void {
435
499
  */
436
500
  function serverLog(msg: string, kind: string = "info", actor?: string): void {
437
501
  pushLog(kind, msg, actor);
438
- if (!SILENT) process.stdout.write(msg + "\n");
502
+ if (!SILENT && !outputLost()) process.stdout.write(msg + "\n");
439
503
  }
440
504
 
505
+ // Its output's reader gone (the terminal, runner or script that started it
506
+ // was killed): write nothing more, and shut down (shutdown-guard.ts).
507
+ guardStdio(() => beginShutdown("SIGPIPE"));
508
+
441
509
  // ── Theme ────────────────────────────────────────────────────────────────────
442
510
  // The active theme (user's, else the org config's `theme:`, else den). A
443
511
  // theme switch is a write of user.yml `appearance:` and a new /theme.css:
@@ -470,6 +538,17 @@ setHtmlFill((html) => {
470
538
  .join(mode)
471
539
  .split("@@DEN_DEFAULT_MODE@@")
472
540
  .join(def);
541
+ // 0.4.1: every page says when the config fails validation, so the shell
542
+ // starts none of its config polls (each would answer 422).
543
+ try {
544
+ assertDenConfigValid();
545
+ } catch (e) {
546
+ if (isConfigError(e))
547
+ out = out.replace(
548
+ '<div id="den-shell"',
549
+ () => '<div id="den-shell" data-config-invalid="1"',
550
+ );
551
+ }
473
552
  // MD-2: every page says when the engine on disk is not the one running.
474
553
  const engine = engineCheck();
475
554
  if (engine.stale)
@@ -522,10 +601,22 @@ function sendErrorPage(
522
601
  res.end();
523
602
  return;
524
603
  }
604
+ // A refusal page (421, signed out): every /api call the shell frame
605
+ // would make answers the same refusal, so the shell starts none of its
606
+ // polls and streams (data-api-off; shell.js apiOff).
607
+ const off = data.status === 421 || data.code === "signin_required";
525
608
  serveFile(
526
609
  res,
527
610
  path.join(PUBLIC_DIR, "error.html"),
528
- (html) => fillErrorPage(html, full),
611
+ (html) => {
612
+ const page = fillErrorPage(html, full);
613
+ return off
614
+ ? page.replace(
615
+ '<div id="den-shell"',
616
+ () => '<div id="den-shell" data-api-off="1"',
617
+ )
618
+ : page;
619
+ },
529
620
  data.status,
530
621
  );
531
622
  }
@@ -587,6 +678,12 @@ function allWorkspaceCheckouts(): ResolvedService[] {
587
678
  */
588
679
  const CONFIG_FREE_ROUTES = new Set([
589
680
  "/api/server-info",
681
+ // SEC-1a: the shell's CSRF token, which the Settings fix itself needs.
682
+ "/api/session",
683
+ // SEC-1b: signing out, and the CLI's `web sessions` / `web logout`.
684
+ "/api/session/logout",
685
+ "/api/web/sessions",
686
+ "/api/web/logout",
590
687
  "/api/server-log",
591
688
  "/api/changelog",
592
689
  "/api/web/restart",
@@ -610,11 +707,19 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
610
707
  import {
611
708
  setSudoPassword,
612
709
  clearSudoPassword,
710
+ closeLockForThisRequest,
711
+ inRequestLockScope,
613
712
  getSudoEnv,
614
713
  prewarmSudo,
615
714
  hasSudoPassword,
616
715
  hasPasswordlessSudo,
617
716
  } from "./sudo-store.js";
717
+ import {
718
+ guardStdio,
719
+ isLostOutputError,
720
+ outputLost,
721
+ requestShutdown,
722
+ } from "./shutdown-guard.js";
618
723
 
619
724
  // ── HTTP helpers ─────────────────────────────────────────────────────────────
620
725
  import { sweepLogs } from "../../shared/process/log-rotation.js";
@@ -658,9 +763,17 @@ import { parseChangelog } from "./changelog-parser.js";
658
763
  import { GitHubService } from "./github-service.js";
659
764
  import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
660
765
  import { handleWorkspaceRoutes } from "./workspace-routes.js";
766
+ import { handleAgentRoutes } from "./agent-routes.js";
767
+ import {
768
+ handleSessionList,
769
+ handleSessionUpgrade,
770
+ isSessionIoPath,
771
+ sendSessionHttp,
772
+ } from "./session-routes.js";
773
+ import { refuseUpgrade } from "./websocket.js";
661
774
  import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
662
775
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
663
- import { edgeHttps } from "../../shared/edge/render.js";
776
+ import { edgeDomains, edgeHttps, routeHost } from "../../shared/edge/render.js";
664
777
  import {
665
778
  linksFor,
666
779
  openUrlFor,
@@ -776,6 +889,358 @@ function mirrorLine(runId: string, text: string): void {
776
889
  process.stdout.write(` ${dim(`[${runId}]`)} ${stripAnsi(text)}\n`);
777
890
  }
778
891
 
892
+ // ── Request checks (SEC-1a, SEC-1 spec §3) ─────────────────────────────────────
893
+
894
+ /**
895
+ * The edge hostnames that route to this server: each route whose upstream
896
+ * is this server's port (or the configured `web.port`, which the edge's
897
+ * own route for Den names) on the docker host, under every edge domain.
898
+ * None without an edge block.
899
+ */
900
+ function denEdgeHosts(): string[] {
901
+ let cfg: ReturnType<typeof readEdgeConfig>;
902
+ try {
903
+ cfg = readEdgeConfig();
904
+ } catch {
905
+ return [];
906
+ }
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()),
925
+ );
926
+ }
927
+
928
+ // Bind loopback only: this UI runs privileged local actions (sudo-backed
929
+ // service commands). DEN_BIND_HOST=0.0.0.0 exposes it deliberately, and
930
+ // (SEC-1b) only with HTTPS in front or web.allow_plain_http_remote.
931
+ const BIND_HOST = process.env.DEN_BIND_HOST ?? "127.0.0.1";
932
+
933
+ /**
934
+ * SEC-1b: `web.allow_plain_http_remote`, read at start. A wrong value
935
+ * refuses to start, naming its key. The 0.4.1-dev `web.session` lifetimes
936
+ * are ignored (a session never expires), with a warning at start.
937
+ */
938
+ const WEB_AUTH: WebAuthConfig = (() => {
939
+ try {
940
+ return readWebAuthConfig(getDistRoot(), product().configFile);
941
+ } catch (e) {
942
+ console.error(`\n ✗ ${(e as Error).message}\n`);
943
+ process.exit(1);
944
+ }
945
+ })();
946
+
947
+ /**
948
+ * The loopback names, with the port: localhost (canonical), 127.0.0.1 and
949
+ * [::1]; the product's own name when /etc/hosts maps it to 127.0.0.1 (the
950
+ * URL `web up` opens then); and a loopback address DEN_BIND_HOST binds.
951
+ */
952
+ function loopbackNames(): string[] {
953
+ const names = ["localhost", "127.0.0.1", "[::1]"];
954
+ try {
955
+ if (hasDenHostsEntry(fs.readFileSync("/etc/hosts", "utf-8")))
956
+ names.push(product().cli.toLowerCase());
957
+ } catch {
958
+ /* no hosts file */
959
+ }
960
+ const bind = BIND_HOST.trim().toLowerCase();
961
+ if (net.isIP(bind) && isLoopbackBind(bind))
962
+ names.push(net.isIPv6(bind) ? `[${bind}]` : bind);
963
+ return [...new Set(names)];
964
+ }
965
+
966
+ /**
967
+ * SEC-1b: this machine's own LAN names, with the port, for a non-loopback
968
+ * bind that `web.allow_plain_http_remote` allows: the bound address, or
969
+ * (0.0.0.0, ::) every interface's address and the host name. Beyond
970
+ * loopback, so every route needs a session. An IP literal or this
971
+ * machine's own name can't be rebound; any other name stays 421.
972
+ */
973
+ function lanHosts(): string[] {
974
+ const bind = BIND_HOST.trim().toLowerCase();
975
+ if (isLoopbackBind(bind) || !WEB_AUTH.allowPlainHttpRemote) return [];
976
+ const br = (a: string) => (net.isIPv6(a) ? `[${a}]` : a);
977
+ if (bind !== "0.0.0.0" && bind !== "::")
978
+ return net.isIP(bind) ? [br(bind)] : [bind];
979
+ const out: string[] = [];
980
+ for (const list of Object.values(os.networkInterfaces()))
981
+ for (const a of list ?? [])
982
+ if (!a.internal)
983
+ out.push(br(a.address.toLowerCase().replace(/%.*$/, "")));
984
+ const host = os.hostname().toLowerCase();
985
+ if (host) out.push(host, host.endsWith(".local") ? host : `${host}.local`);
986
+ return [...new Set(out)];
987
+ }
988
+
989
+ /** HTTPS in front of this server: an edge with TLS that routes to it. */
990
+ function edgeFronted(): boolean {
991
+ try {
992
+ return (
993
+ readEdgeConfig().tls.strategy !== "none" && denEdgeHosts().length > 0
994
+ );
995
+ } catch {
996
+ return false;
997
+ }
998
+ }
999
+
1000
+ const webSecret = new WebSecretStore();
1001
+ const webSessions = new WebSessionStore({
1002
+ secret: () => webSecret.current(),
1003
+ });
1004
+ const SESSION_COOKIE = sessionCookieName(PORT);
1005
+
1006
+ /** The request's `den_session_<port>` cookie value, if any. */
1007
+ function sessionCookie(req: http.IncomingMessage): string | undefined {
1008
+ const raw = req.headers.cookie;
1009
+ if (!raw) return undefined;
1010
+ for (const part of raw.split(";")) {
1011
+ const i = part.indexOf("=");
1012
+ if (i > 0 && part.slice(0, i).trim() === SESSION_COOKIE)
1013
+ return part.slice(i + 1).trim();
1014
+ }
1015
+ return undefined;
1016
+ }
1017
+
1018
+ const LAN_HOSTS = lanHosts();
1019
+ const GUARD: GuardContext = {
1020
+ port: PORT,
1021
+ loopbackNames: loopbackNames(),
1022
+ edgeHosts: denEdgeHosts,
1023
+ lanHosts: () => LAN_HOSTS,
1024
+ verifyBearer: (token) => {
1025
+ // SEC-1c: an agent's actor token, live while its session runs.
1026
+ if (token.startsWith(ACTOR_TOKEN_PREFIX)) {
1027
+ const rec = checkActorToken(getStateDir(), token);
1028
+ if (rec === "revoked") return "revoked";
1029
+ return rec
1030
+ ? {
1031
+ kind: "agent",
1032
+ actor: rec.actor,
1033
+ workspace: rec.workspace,
1034
+ grants: rec.grants,
1035
+ session: rec.session,
1036
+ }
1037
+ : null;
1038
+ }
1039
+ const secret = webSecret.current();
1040
+ return secret ? verifyPersonToken(token, secret) : null;
1041
+ },
1042
+ csrfToken: newCsrfToken(),
1043
+ session: (req) => webSessions.lookup(sessionCookie(req)),
1044
+ peerIsLoopback: (req) => isLoopbackAddress(req.socket?.remoteAddress),
1045
+ // The edge hostname only from the edge's own peers (loopback, Docker).
1046
+ peerIsEdge: (req) => isEdgePeer(req.socket?.remoteAddress),
1047
+ };
1048
+
1049
+ /**
1050
+ * The Set-Cookie for a session: HttpOnly, SameSite=Strict, Path=/, Secure
1051
+ * when the request came over HTTPS (requestIsHttps: the edge's own header
1052
+ * only from the edge), for as long as a browser keeps a cookie: the
1053
+ * session itself never expires.
1054
+ */
1055
+ function sessionSetCookie(id: string, secure: boolean): string {
1056
+ return (
1057
+ `${SESSION_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Strict; ` +
1058
+ `Max-Age=${SESSION_COOKIE_MAX_AGE_S}${secure ? "; Secure" : ""}`
1059
+ );
1060
+ }
1061
+ function clearSessionCookie(secure: boolean): string {
1062
+ return (
1063
+ `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0` +
1064
+ (secure ? "; Secure" : "")
1065
+ );
1066
+ }
1067
+
1068
+ /** The loopback names that aren't localhost: their signed-out page links there. */
1069
+ function nonCanonicalLoopback(req: http.IncomingMessage): boolean {
1070
+ const h = String(req.headers.host ?? "")
1071
+ .toLowerCase()
1072
+ .replace(/:\d+$/, "");
1073
+ return h !== "localhost" && GUARD.loopbackNames.includes(h);
1074
+ }
1075
+
1076
+ /**
1077
+ * The signed-out page (§2): the shell frame, "Sign in to use the terminal"
1078
+ * (shell-grade on loopback) or "Sign in to use <title>", the command that
1079
+ * prints a link, and on 127.0.0.1 or [::1] a link to the same path on
1080
+ * localhost (where the cookie is). Never a form for a secret or a token.
1081
+ */
1082
+ function sendSignedOut(
1083
+ req: http.IncomingMessage,
1084
+ res: http.ServerResponse,
1085
+ opts: {
1086
+ pathname: string;
1087
+ search: string;
1088
+ scope: "terminal" | "agents" | "den";
1089
+ expired?: boolean;
1090
+ requestId: string;
1091
+ message: string;
1092
+ },
1093
+ ): void {
1094
+ const host = String(req.headers.host ?? "").toLowerCase();
1095
+ const toLocal = nonCanonicalLoopback(req);
1096
+ const via = GUARD.edgeHosts().includes(host.replace(/:\d+$/, ""))
1097
+ ? "edge"
1098
+ : "other";
1099
+ const origin = `${requestIsHttps(req, via === "edge" ? "edge" : null) ? "https" : "http"}://${host}`;
1100
+ const cmd =
1101
+ `${product().cli} web login` +
1102
+ (toLocal || host === `localhost:${PORT}` ? "" : ` --origin ${origin}`);
1103
+ res.setHeader("Cache-Control", "no-store");
1104
+ sendErrorPage(req, res, {
1105
+ status: 401,
1106
+ code: "signin_required",
1107
+ path: opts.pathname,
1108
+ message: opts.message,
1109
+ requestId: opts.requestId,
1110
+ signin: {
1111
+ scope: opts.scope,
1112
+ name: product().title,
1113
+ cmd,
1114
+ ...(opts.expired ? { expired: true } : {}),
1115
+ ...(toLocal && !opts.expired
1116
+ ? {
1117
+ localhost: `${localhostOrigin(PORT)}${opts.pathname}${opts.search}`,
1118
+ }
1119
+ : {}),
1120
+ },
1121
+ });
1122
+ }
1123
+
1124
+ const PUBLIC_ASSET_EXTS = new Set([
1125
+ ".js",
1126
+ ".css",
1127
+ ".ico",
1128
+ ".png",
1129
+ ".svg",
1130
+ ".webp",
1131
+ ".woff",
1132
+ ".woff2",
1133
+ ]);
1134
+ /** A GET of the UI's code or styling (never /api, a report or a page). */
1135
+ function isPublicAsset(req: http.IncomingMessage, pathname: string): boolean {
1136
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
1137
+ if (/^\/(api|reports?)(\/|$)/.test(pathname)) return false;
1138
+ return PUBLIC_ASSET_EXTS.has(path.extname(pathname).toLowerCase());
1139
+ }
1140
+
1141
+ /** A refused request: R26 JSON for /api, the error page for a page. */
1142
+ function sendGuardRefusal(
1143
+ req: http.IncomingMessage,
1144
+ res: http.ServerResponse,
1145
+ pathname: string,
1146
+ refusal: Extract<GuardResult, { ok: false }>,
1147
+ requestId: string,
1148
+ search = "",
1149
+ ): void {
1150
+ if (pathname === "/api" || pathname.startsWith("/api/"))
1151
+ return sendJson(
1152
+ res,
1153
+ {
1154
+ ok: false,
1155
+ error: refusal.error,
1156
+ code: refusal.code,
1157
+ // signin_required: why, so the page words it in its own language.
1158
+ ...(refusal.scope ? { scope: refusal.scope } : {}),
1159
+ requestId,
1160
+ },
1161
+ refusal.status,
1162
+ );
1163
+ if (refusal.code === "signin_required" && wantsHtmlPage(req, pathname))
1164
+ return sendSignedOut(req, res, {
1165
+ pathname,
1166
+ search,
1167
+ scope: refusal.scope ?? "den",
1168
+ requestId,
1169
+ message: refusal.error,
1170
+ });
1171
+ if (wantsHtmlPage(req, pathname))
1172
+ return sendErrorPage(req, res, {
1173
+ status: refusal.status,
1174
+ path: pathname,
1175
+ message: refusal.error,
1176
+ code: refusal.code,
1177
+ requestId,
1178
+ });
1179
+ res.writeHead(refusal.status, { "Content-Type": "text/plain" });
1180
+ res.end(`${refusal.error} (${refusal.code}, request ${requestId})`);
1181
+ }
1182
+
1183
+ /**
1184
+ * `GET /auth?c=<code>&next=<path>` (§2): a valid, unused login code (60 s,
1185
+ * constant-time mac, single use) becomes a session and its cookie, then a
1186
+ * 303 to `next` when it is a same-origin path, else `/`. Anything else gets
1187
+ * the signed-out page, which never echoes the code.
1188
+ */
1189
+ function handleAuth(
1190
+ req: http.IncomingMessage,
1191
+ res: http.ServerResponse,
1192
+ url: URL,
1193
+ requestId: string,
1194
+ via: "loopback" | "edge" | "lan" | null,
1195
+ ): void {
1196
+ const secret = webSecret.current();
1197
+ const check = secret
1198
+ ? checkLoginCode(url.searchParams.get("c") ?? "", secret)
1199
+ : null;
1200
+ if (!check?.ok || !webSessions.consumeCode(check.nonce, check.expiresAt)) {
1201
+ res.setHeader("Referrer-Policy", "no-referrer");
1202
+ return sendSignedOut(req, res, {
1203
+ pathname: "/auth",
1204
+ search: "",
1205
+ scope: "den",
1206
+ expired: true,
1207
+ requestId,
1208
+ message: `That sign-in link expired or was used. Run \`${product().cli} web login\` for a new one.`,
1209
+ });
1210
+ }
1211
+ const { id } = webSessions.create({
1212
+ userAgent: req.headers["user-agent"],
1213
+ host: req.headers.host,
1214
+ });
1215
+ res.writeHead(303, {
1216
+ Location: safeNext(url.searchParams.get("next")),
1217
+ "Set-Cookie": sessionSetCookie(id, requestIsHttps(req, via)),
1218
+ "Cache-Control": "no-store",
1219
+ "Referrer-Policy": "no-referrer",
1220
+ });
1221
+ res.end();
1222
+ }
1223
+
1224
+ /**
1225
+ * A service's declared command `cmd` for `env`, own properties only
1226
+ * (SEC-1 §6b.2): `constructor` or `__proto__` is no command, never
1227
+ * something from Object.prototype.
1228
+ */
1229
+ function declaredCommand<T>(
1230
+ commands: Record<string, Record<string, T> | undefined> | undefined,
1231
+ env: string,
1232
+ cmd: string,
1233
+ ): T | undefined {
1234
+ const own = (o: object | undefined, k: string) =>
1235
+ o !== undefined && Object.prototype.hasOwnProperty.call(o, k);
1236
+ if (!own(commands, env)) return undefined;
1237
+ const forEnv = commands![env];
1238
+ return own(forEnv, cmd) ? forEnv![cmd] : undefined;
1239
+ }
1240
+
1241
+ /** A test module's name as a run takes it (SEC-1 §6b.2): never a path out. */
1242
+ const SAFE_MODULE_RE = /^[A-Za-z0-9_@][A-Za-z0-9._@/-]{0,127}$/;
1243
+
779
1244
  // ── Server ────────────────────────────────────────────────────────────────────
780
1245
 
781
1246
  async function handleRequest(
@@ -790,15 +1255,6 @@ async function handleRequest(
790
1255
  // R26b: every /api error answers JSON, whatever the route sends.
791
1256
  if (pathname === "/api" || pathname.startsWith("/api/"))
792
1257
  jsonApiErrors(res, requestId);
793
- // A mutating request may change what /api/services would list (exec and
794
- // sync stream over GET, for EventSource).
795
- if (
796
- (req.method !== "GET" && req.method !== "HEAD") ||
797
- /\/(exec|sync)$/.test(pathname)
798
- ) {
799
- servicesCache.clear();
800
- clearReadCache();
801
- }
802
1258
 
803
1259
  // ── Request logging ────────────────────────────────────────────────────────
804
1260
  // /api/health fires on the UI's 5s polling interval forever — it dominates
@@ -823,6 +1279,69 @@ async function handleRequest(
823
1279
  }
824
1280
  }
825
1281
 
1282
+ // ── Request checks (SEC-1a): Host, credential, Origin, CSRF ────────────────
1283
+ // Before any route, and before a refused request can clear a cache.
1284
+ const guard = checkRequest(req, GUARD, product().cli, product().title);
1285
+ // The UI's own scripts and styles answer any host, and without a
1286
+ // session, so the 421 and signed-out pages (which need shell.js and the
1287
+ // theme) render; they hold no data. The request log above records the
1288
+ // refusal (status, request id; never the query, where a login code is).
1289
+ if (
1290
+ !guard.ok &&
1291
+ !(
1292
+ (guard.code === "misdirected" || guard.code === "signin_required") &&
1293
+ isPublicAsset(req, pathname)
1294
+ )
1295
+ )
1296
+ return sendGuardRefusal(req, res, pathname, guard, requestId, url.search);
1297
+ if (guard.ok) setPrincipal(req, guard.principal);
1298
+ // SEC-1c (R8): an agent's request never passes the lock.
1299
+ if (guard.ok && guard.principal?.kind === "agent") closeLockForThisRequest();
1300
+
1301
+ // ── Sign-in (SEC-1b, §2) ───────────────────────────────────────────────────
1302
+ // Before config validation: signing in never depends on the config.
1303
+ if (pathname === "/auth" && (req.method === "GET" || req.method === "HEAD"))
1304
+ return handleAuth(req, res, url, requestId, guard.ok ? guard.via : null);
1305
+ if (
1306
+ pathname === "/signin" &&
1307
+ (req.method === "GET" || req.method === "HEAD")
1308
+ ) {
1309
+ // Read-only and sign-in free (§6b.3): it checks the session itself.
1310
+ // Signed in, or on loopback (no sign-in there), it goes on to a
1311
+ // same-origin `next`; signed out beyond loopback, it is the signed-out
1312
+ // page (the `web login` hint, never a form for the secret).
1313
+ if (guard.ok && (guard.session || !guard.beyond)) {
1314
+ res.writeHead(303, {
1315
+ Location: safeNext(url.searchParams.get("next")),
1316
+ "Cache-Control": "no-store",
1317
+ });
1318
+ res.end();
1319
+ return;
1320
+ }
1321
+ return sendSignedOut(req, res, {
1322
+ pathname,
1323
+ search: url.search,
1324
+ scope: "den",
1325
+ requestId,
1326
+ message: signinMessage("den", product().cli, product().title),
1327
+ });
1328
+ }
1329
+ // AG-4a-term's session routes (`/api/sessions/<id>/io`): shell-grade, and
1330
+ // an agent's token reaches its own session's only (request-guard). The io
1331
+ // is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
1332
+ if (pathname.startsWith("/api/sessions/"))
1333
+ return sendSessionHttp(req, res, pathname);
1334
+
1335
+ // A mutating request may change what /api/services would list (exec and
1336
+ // sync stream over GET, for EventSource).
1337
+ if (
1338
+ (req.method !== "GET" && req.method !== "HEAD") ||
1339
+ /\/(exec|sync)$/.test(pathname)
1340
+ ) {
1341
+ servicesCache.clear();
1342
+ clearReadCache();
1343
+ }
1344
+
826
1345
  // ── Config validation ──────────────────────────────────────────────────────
827
1346
  // A config that fails validation answers every API route that reads it as
828
1347
  // the ConfigError the handler below maps to 422 JSON, before any route's own
@@ -882,6 +1401,11 @@ async function handleRequest(
882
1401
  );
883
1402
  }
884
1403
 
1404
+ // The Agents tab (AG-4a-term, AG spec §5d): sessions and terminal panes.
1405
+ if (pathname === "/agents" || pathname === "/agents.html") {
1406
+ return serveFile(res, path.join(PUBLIC_DIR, "agents.html"));
1407
+ }
1408
+
885
1409
  if (pathname === "/tests" || pathname === "/index.html") {
886
1410
  return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
887
1411
  }
@@ -1068,7 +1592,8 @@ async function handleRequest(
1068
1592
  // route; it reports them here so the activity trail shows failed attempts.
1069
1593
  // Agents only (the audit header), and the entry is all it can write.
1070
1594
  if (pathname === "/api/activity" && req.method === "POST") {
1071
- const actor = actorFromHeaders(req.headers);
1595
+ // An agent's token, or (unverified) an MCP server's audit headers.
1596
+ const actor = auditActorOf(req);
1072
1597
  if (!actor) return sendJson(res, { error: "agents only" }, 403);
1073
1598
  const raw = await readBody(req).catch(() => "");
1074
1599
  if (raw.length > 4096) return send400(res, "report too large");
@@ -1091,11 +1616,11 @@ async function handleRequest(
1091
1616
  }
1092
1617
  // A call into a granted workspace (AG-2a): both workspaces recorded.
1093
1618
  if (report.crossWorkspace !== undefined) {
1094
- const crossed = recordCrossWorkspace(actor, report);
1619
+ const crossed = recordCrossWorkspace(actor, report, principalOf(req));
1095
1620
  if (!crossed) return send400(res, "not a cross-workspace report");
1096
1621
  return sendJson(res, { recorded: true, ts: crossed.ts });
1097
1622
  }
1098
- const entry = recordAgentRefusal(actor, report);
1623
+ const entry = recordAgentRefusal(actor, report, principalOf(req));
1099
1624
  return sendJson(res, { recorded: true, ts: entry.ts });
1100
1625
  }
1101
1626
 
@@ -1172,11 +1697,11 @@ async function handleRequest(
1172
1697
 
1173
1698
  // ── API: health check ─────────────────────────────────────────────────────
1174
1699
  /**
1175
- * Workspaces a health run covers: one named workspace, else the view
1176
- * scope's (every active one by default, focus first). Health is
1700
+ * Workspaces a health run covers: one named workspace, else every active
1701
+ * one, focus first, whatever the view scope (WS-6.4). Health is
1177
1702
  * machine-level: a service running under a non-focused active workspace
1178
- * is still up, and its card must say so. The pages and `status` read the
1179
- * same scope (src/shared/environment-manager/scope.ts).
1703
+ * is still up, and the Workspaces page must say so for any workspace.
1704
+ * The scope filters what is displayed: each row's `inView` (forViewer).
1180
1705
  */
1181
1706
  function healthWorkspaces(
1182
1707
  workspaceName: string | null,
@@ -1185,7 +1710,7 @@ async function handleRequest(
1185
1710
  const named = getWorkspaceByName(workspaceName);
1186
1711
  return named ? [named] : [];
1187
1712
  }
1188
- return scopeView().workspaces.filter((w) => w.config);
1713
+ return pollView().workspaces.filter((w) => w.config);
1189
1714
  }
1190
1715
 
1191
1716
  /**
@@ -1543,7 +2068,7 @@ async function handleRequest(
1543
2068
  // single-service `?address=` probe always runs live: it exists to re-check one
1544
2069
  // node on demand, where staleness is the whole thing being avoided.
1545
2070
  if (pathname === "/api/health") {
1546
- const forViewer = viewerRewrite(req.headers.host);
2071
+ const forViewer = healthForViewer(req.headers.host);
1547
2072
  const env = (url.searchParams.get("env") ?? "local") as Environment;
1548
2073
  const addressFilter = url.searchParams.get("address");
1549
2074
  const wsParam = url.searchParams.get("workspace");
@@ -1572,7 +2097,7 @@ async function handleRequest(
1572
2097
  // Same checks, streamed: each result is sent the moment it lands so the fast
1573
2098
  // ones paint immediately instead of every dot waiting on the slowest.
1574
2099
  if (pathname === "/api/health/stream") {
1575
- const forViewer = viewerRewrite(req.headers.host);
2100
+ const forViewer = healthForViewer(req.headers.host);
1576
2101
  const env = (url.searchParams.get("env") ?? "local") as Environment;
1577
2102
  const wsParam = url.searchParams.get("workspace");
1578
2103
  if (wsParam && !getWorkspaceByName(wsParam))
@@ -1657,12 +2182,16 @@ async function handleRequest(
1657
2182
  // Every fail here is before anything ran: a refusal or a lookup miss.
1658
2183
  // Recorded, so failed attempts show in the activity trail (F2).
1659
2184
  const fail = (msg: string): void => {
1660
- recordAction(actorFromHeaders(req.headers), {
1661
- verb: "exec",
1662
- detail: `${repoName}/${svcName} ${cmdName} (${env})`,
1663
- outcome: "refused",
1664
- reason: msg,
1665
- });
2185
+ recordAction(
2186
+ auditActorOf(req),
2187
+ {
2188
+ verb: "exec",
2189
+ detail: `${repoName}/${svcName} ${cmdName} (${env})`,
2190
+ outcome: "refused",
2191
+ reason: msg,
2192
+ },
2193
+ principalOf(req),
2194
+ );
1666
2195
  emit({ type: "error", text: msg });
1667
2196
  emit({ type: "done", exitCode: 1, duration: 0 });
1668
2197
  res.end();
@@ -1698,8 +2227,11 @@ async function handleRequest(
1698
2227
  );
1699
2228
  if (refusal) return fail(refusal);
1700
2229
  if (manifest) {
1701
- cmdDef =
1702
- manifest.services?.[resolved.subsystem]?.commands?.[env]?.[cmdName];
2230
+ cmdDef = declaredCommand(
2231
+ manifest.services?.[resolved.subsystem]?.commands,
2232
+ env,
2233
+ cmdName,
2234
+ );
1703
2235
  }
1704
2236
  // Lifecycle commands run through the service runner, as the CLI's do:
1705
2237
  // restart without a declared restart is down + up, up on a healthy
@@ -1712,11 +2244,15 @@ async function handleRequest(
1712
2244
  );
1713
2245
  const execId = shortRunId();
1714
2246
  const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
1715
- const execActor = actorFromHeaders(req.headers);
1716
- recordAction(execActor, {
1717
- verb: "exec",
1718
- detail: `${execDesc} [${execId}]`,
1719
- });
2247
+ const execActor = auditActorOf(req);
2248
+ recordAction(
2249
+ execActor,
2250
+ {
2251
+ verb: "exec",
2252
+ detail: `${execDesc} [${execId}]`,
2253
+ },
2254
+ principalOf(req),
2255
+ );
1720
2256
  prewarmSudo();
1721
2257
  void streamLifecycleExec(
1722
2258
  {
@@ -1771,7 +2307,11 @@ async function handleRequest(
1771
2307
  )
1772
2308
  : null;
1773
2309
  if (refusal) return fail(refusal);
1774
- cmdDef = repo?.manifest.services?.[svcName]?.commands?.[env]?.[cmdName];
2310
+ cmdDef = declaredCommand(
2311
+ repo?.manifest.services?.[svcName]?.commands,
2312
+ env,
2313
+ cmdName,
2314
+ );
1775
2315
  }
1776
2316
 
1777
2317
  if (!cmdDef || !repoPath) {
@@ -1817,11 +2357,15 @@ async function handleRequest(
1817
2357
 
1818
2358
  const execId = shortRunId();
1819
2359
  const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
1820
- const execActor = actorFromHeaders(req.headers);
1821
- recordAction(execActor, {
1822
- verb: "exec",
1823
- detail: `${execDesc} [${execId}]`,
1824
- });
2360
+ const execActor = auditActorOf(req);
2361
+ recordAction(
2362
+ execActor,
2363
+ {
2364
+ verb: "exec",
2365
+ detail: `${execDesc} [${execId}]`,
2366
+ },
2367
+ principalOf(req),
2368
+ );
1825
2369
 
1826
2370
  // For detachable commands (up/restart), the service is spawned as a daemon
1827
2371
  // writing to a log file so it survives den restarts. The tail proc
@@ -2448,6 +2992,17 @@ async function handleRequest(
2448
2992
  }
2449
2993
 
2450
2994
  if (!pillar) return send400(res, "pillar required");
2995
+ // SEC-1 §6b.2: a run takes the ids of declared modules, never a path. A
2996
+ // module that climbs out (`..`), is absolute, or reads as a flag would
2997
+ // point the runner at a script of the caller's choosing.
2998
+ const badModule = modules.find(
2999
+ (m) => !SAFE_MODULE_RE.test(m) || m.split("/").includes(".."),
3000
+ );
3001
+ if (badModule !== undefined)
3002
+ return send400(
3003
+ res,
3004
+ `module ${JSON.stringify(badModule.slice(0, 80))} is not a module name`,
3005
+ );
2451
3006
 
2452
3007
  // Resolve declarative optional fields → per-module env. Only the perf pillar
2453
3008
  // declares optionalFields; for every other pillar this is an empty map.
@@ -2535,8 +3090,12 @@ async function handleRequest(
2535
3090
  const runId = shortRunId();
2536
3091
  const modDesc = modules.length ? modules.join(",") : "all";
2537
3092
  const runDesc = `${pillar}/${env} · modules=${modDesc}`;
2538
- const runActor = actorFromHeaders(req.headers);
2539
- recordAction(runActor, { verb: "run", detail: `${runDesc} [${runId}]` });
3093
+ const runActor = auditActorOf(req);
3094
+ recordAction(
3095
+ runActor,
3096
+ { verb: "run", detail: `${runDesc} [${runId}]` },
3097
+ principalOf(req),
3098
+ );
2540
3099
  if (
2541
3100
  pillar === "synthetic" ||
2542
3101
  pillar === "smoke" ||
@@ -2709,6 +3268,110 @@ async function handleRequest(
2709
3268
  }
2710
3269
 
2711
3270
  // ── API: server info + staleness (drives the header "stale server" pill) ────
3271
+ // ── API: the browser's session (SEC-1a, SEC-1b) ──────────────────────────
3272
+ // The CSRF token the shell sends on mutating calls (X-Den-CSRF), for a
3273
+ // same-origin caller only: the session's own when signed in
3274
+ // (actor "person-browser"); else, on loopback, this server start's.
3275
+ // Beyond loopback without a session there is none to hand out (every
3276
+ // route there needs a session first).
3277
+ if (pathname === "/api/session" && req.method === "GET") {
3278
+ if (!sessionRequestAllowed(req, GUARD))
3279
+ return sendJson(
3280
+ res,
3281
+ {
3282
+ ok: false,
3283
+ error: "same-origin callers only",
3284
+ code: "origin_rejected",
3285
+ },
3286
+ 403,
3287
+ );
3288
+ res.setHeader("Cache-Control", "no-store");
3289
+ const g = guard.ok ? guard : null;
3290
+ // An agent's token gets no browser CSRF token: it has its bearer.
3291
+ if (g?.principal?.kind === "agent")
3292
+ return sendJson(res, {
3293
+ csrf: null,
3294
+ actor: g.principal.actor,
3295
+ signedIn: false,
3296
+ });
3297
+ if (g?.session)
3298
+ return sendJson(res, {
3299
+ csrf: g.session.csrf,
3300
+ actor: "person-browser",
3301
+ signedIn: true,
3302
+ });
3303
+ return sendJson(res, {
3304
+ csrf: g?.beyond ? null : GUARD.csrfToken,
3305
+ actor: null,
3306
+ signedIn: false,
3307
+ });
3308
+ }
3309
+ // Sign out: this browser's session ends and its cookie is cleared.
3310
+ if (pathname === "/api/session/logout" && req.method === "POST") {
3311
+ const g = guard.ok ? guard : null;
3312
+ const ended = g?.session ? webSessions.end(g.session.key) : false;
3313
+ res.setHeader(
3314
+ "Set-Cookie",
3315
+ clearSessionCookie(requestIsHttps(req, g?.via ?? null)),
3316
+ );
3317
+ return sendJson(res, { ok: true, ended });
3318
+ }
3319
+ // `<cli> web sessions` (shell-grade: the person's bearer, or a session).
3320
+ if (pathname === "/api/web/sessions" && req.method === "GET") {
3321
+ const mine = guard.ok ? guard.session?.key : undefined;
3322
+ return sendJson(res, {
3323
+ sessions: webSessions.list().map((x) => ({
3324
+ id: sessionIdPrefix(x.key),
3325
+ createdAt: new Date(x.createdAt).toISOString(),
3326
+ lastSeenAt: new Date(x.lastSeenAt).toISOString(),
3327
+ userAgent: x.userAgent,
3328
+ host: x.host,
3329
+ ...(x.key === mine ? { current: true } : {}),
3330
+ })),
3331
+ });
3332
+ }
3333
+ // `<cli> web logout [<id>] [--all]` (shell-grade). `rotate` (--all) also
3334
+ // replaces web.secret: every session and person token derived from the
3335
+ // old one stops verifying; the CLI re-reads the file, so it keeps working.
3336
+ // And (SEC-1 §6c.2) it revokes every agent's actor token: a running
3337
+ // agent's next call gets 401 token_revoked. One browser's or every
3338
+ // browser's logout without --all leaves agent tokens alone.
3339
+ if (pathname === "/api/web/logout" && req.method === "POST") {
3340
+ let body: { id?: unknown; rotate?: unknown } = {};
3341
+ try {
3342
+ body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
3343
+ } catch {
3344
+ return send400(res, "the body must be JSON: { id?, rotate? }");
3345
+ }
3346
+ if (body.id !== undefined && typeof body.id !== "string")
3347
+ return send400(res, "id must be a session id prefix");
3348
+ try {
3349
+ const ended =
3350
+ typeof body.id === "string"
3351
+ ? [sessionIdPrefix(webSessions.endByPrefix(body.id).key)]
3352
+ : webSessions.list().map((x) => sessionIdPrefix(x.key));
3353
+ if (typeof body.id !== "string") webSessions.endAll();
3354
+ const rotated = body.rotate === true;
3355
+ let agentTokens = 0;
3356
+ if (rotated) {
3357
+ rotateWebSecret();
3358
+ agentTokens = revokeAllActorTokens(getStateDir());
3359
+ }
3360
+ return sendJson(res, {
3361
+ ok: true,
3362
+ ended,
3363
+ rotated,
3364
+ ...(rotated ? { agentTokens } : {}),
3365
+ });
3366
+ } catch (e) {
3367
+ return sendJson(
3368
+ res,
3369
+ { ok: false, error: (e as Error).message, code: "not_found" },
3370
+ 404,
3371
+ );
3372
+ }
3373
+ }
3374
+
2712
3375
  if (pathname === "/api/server-info" && req.method === "GET") {
2713
3376
  const { stale, scanned } = computeStale();
2714
3377
  const engine = engineCheck();
@@ -3034,6 +3697,20 @@ async function handleRequest(
3034
3697
  return;
3035
3698
  }
3036
3699
 
3700
+ // ── Agents on the Workspaces page (AG-4a, agent-routes.ts) ───────────────
3701
+ if (await handleAgentRoutes(req, res, pathname)) return;
3702
+ // ── The Agents tab's list (AG-4a-term, session-routes.ts) ────────────────
3703
+ // Beyond loopback a terminal needs a signed-in session (or a bearer).
3704
+ if (
3705
+ await handleSessionList(
3706
+ req,
3707
+ res,
3708
+ pathname,
3709
+ guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
3710
+ )
3711
+ )
3712
+ return;
3713
+
3037
3714
  // ── Workspace routes (extracted to workspace-routes.ts) ──────────────────
3038
3715
  await handleWorkspaceRoutes(req, res, pathname, url, editors, github);
3039
3716
  if (res.headersSent) return;
@@ -3630,7 +4307,8 @@ const server = http.createServer((req, res) => {
3630
4307
  // An async handler with no catch turns any route throw into an unhandled
3631
4308
  // rejection — which CRASHES the whole server on modern Node (freezing every
3632
4309
  // health dot) and leaves the request hanging. Fail the one request instead.
3633
- handleRequest(req, res).catch((err: unknown) => {
4310
+ // Each request in its own lock scope (SEC-1c: an agent's stays shut).
4311
+ inRequestLockScope(() => handleRequest(req, res)).catch((err: unknown) => {
3634
4312
  // R26: the log has the stack under the request id; the browser gets the
3635
4313
  // id, never the stack. The route is its template: no query values.
3636
4314
  const requestId = requestIdOf(req);
@@ -3712,6 +4390,49 @@ const server = http.createServer((req, res) => {
3712
4390
  });
3713
4391
  });
3714
4392
 
4393
+ // WebSocket upgrades pass the same checks (SEC-1 §3: Origin on every
4394
+ // upgrade; CSRF as the `den.csrf.<token>` subprotocol). One route takes
4395
+ // one: a session's io (AG-4a-term, session-routes.ts); any other passing
4396
+ // upgrade answers 404.
4397
+ server.on(
4398
+ "upgrade",
4399
+ (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
4400
+ socket.on("error", () => undefined);
4401
+ const requestId = requestIdOf(req);
4402
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
4403
+ const guard = checkRequest(req, GUARD, product().cli, product().title);
4404
+ const refuse = (status: number, error: string, code: string) =>
4405
+ refuseUpgrade(
4406
+ socket,
4407
+ status,
4408
+ { ok: false, error, code, requestId },
4409
+ http.STATUS_CODES[status] ?? "",
4410
+ );
4411
+ if (!guard.ok) {
4412
+ serverLog(
4413
+ ` ${logTimestamp()} ${bold("WS ")} ${pathname} → ${logStatus(guard.status)}` +
4414
+ dim(` req ${requestId}`),
4415
+ "request",
4416
+ );
4417
+ return refuse(guard.status, guard.error, guard.code);
4418
+ }
4419
+ setPrincipal(req, guard.principal);
4420
+ if (!isSessionIoPath(pathname))
4421
+ return refuse(404, "No WebSocket route here", "not_found");
4422
+ handleSessionUpgrade(req, socket, head, pathname, requestId).catch(
4423
+ (err: unknown) => {
4424
+ serverLog(
4425
+ ` ✗ WS ${routeTemplate(pathname)} failed (req ${requestId}): ${
4426
+ err instanceof Error ? err.message : String(err)
4427
+ }`,
4428
+ "error",
4429
+ );
4430
+ socket.destroy();
4431
+ },
4432
+ );
4433
+ },
4434
+ );
4435
+
3715
4436
  server.on("error", (err: NodeJS.ErrnoException) => {
3716
4437
  if (err.code === "EADDRINUSE") {
3717
4438
  console.error(
@@ -3801,10 +4522,14 @@ setInterval(sweepServiceLogs, LOG_SWEEP_INTERVAL_MS).unref();
3801
4522
  // timers — code the per-request catch can't see) would kill the whole
3802
4523
  // dashboard; a TDZ bug in a down-flow callback did exactly that. For a local
3803
4524
  // dev server, staying alive degraded beats dying — log loudly and continue.
4525
+ // Never log a lost-output error, or anything once the output is gone: that
4526
+ // write fails too, and looped (shutdown-guard.ts).
3804
4527
  process.on("uncaughtException", (err) => {
4528
+ if (isLostOutputError(err) || outputLost()) return;
3805
4529
  console.error("[server] uncaught exception (continuing):", err);
3806
4530
  });
3807
4531
  process.on("unhandledRejection", (reason) => {
4532
+ if (isLostOutputError(reason) || outputLost()) return;
3808
4533
  console.error("[server] unhandled rejection (continuing):", reason);
3809
4534
  });
3810
4535
 
@@ -3818,10 +4543,35 @@ try {
3818
4543
  serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
3819
4544
  }
3820
4545
 
3821
- // Bind loopback only — this UI runs privileged local actions (sudo-backed
3822
- // service commands), so it must not be reachable from the LAN. Opt out with
3823
- // DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
3824
- const BIND_HOST = process.env.DEN_BIND_HOST ?? "127.0.0.1";
4546
+ // Ignored config keys (a distribution's web.allow_plain_http_remote, the
4547
+ // old web.session lifetimes): the in-app log, and stderr, which is web.log
4548
+ // even when `web up` starts this --silent. Before the bind refusal, which
4549
+ // such a key often explains.
4550
+ {
4551
+ const ignored = ignoredConfigWarning(WEB_AUTH);
4552
+ if (ignored) {
4553
+ pushLog("warn", ` ⚠ ${ignored}`);
4554
+ if (!outputLost()) process.stderr.write(` ⚠ ${ignored}\n`);
4555
+ }
4556
+ }
4557
+ // §4: a non-loopback bind needs HTTPS in front, or the person's say-so.
4558
+ {
4559
+ const refusal = bindRefusal(BIND_HOST, {
4560
+ allowPlainHttpRemote: WEB_AUTH.allowPlainHttpRemote,
4561
+ fronted: edgeFronted(),
4562
+ });
4563
+ if (refusal) {
4564
+ console.error(`\n ✗ ${refusal}\n`);
4565
+ process.exit(1);
4566
+ }
4567
+ }
4568
+ // SEC-1a: the secret the CLI's person token is derived from (0600 in the
4569
+ // state dir). Never printed or logged; a failure leaves bearers unverifiable.
4570
+ try {
4571
+ webSecret.ensure();
4572
+ } catch (e) {
4573
+ serverLog(` ⚠ web.secret could not be created: ${(e as Error).message}`);
4574
+ }
3825
4575
  server.listen(PORT, BIND_HOST, () => {
3826
4576
  const hosts = fs.existsSync("/etc/hosts")
3827
4577
  ? fs.readFileSync("/etc/hosts", "utf-8")
@@ -3941,7 +4691,8 @@ async function handleShutdown(signal: string): Promise<void> {
3941
4691
  process.exit(0);
3942
4692
  }
3943
4693
 
3944
- const isTTY = process.stdin.isTTY;
4694
+ // Lost output (SIGPIPE): nobody can answer a prompt.
4695
+ const isTTY = process.stdin.isTTY && signal !== "SIGPIPE";
3945
4696
 
3946
4697
  if (!isTTY) {
3947
4698
  // Non-interactive shutdown usually means `den web` is being restarted.
@@ -3981,10 +4732,8 @@ async function handleShutdown(signal: string): Promise<void> {
3981
4732
  }
3982
4733
 
3983
4734
  /**
3984
- * Run the shutdown for `signal`. A failure still ends the process: the guard
3985
- * below ignores every later signal, so a shutdown that rejected would leave
3986
- * the server running and deaf to SIGTERM (a service manager then waits out
3987
- * its timeout and SIGKILLs it).
4735
+ * Run the shutdown for `signal`. A failure still ends the process, at once
4736
+ * rather than at the deadline.
3988
4737
  */
3989
4738
  function shutdownOn(signal: string): void {
3990
4739
  handleShutdown(signal).catch((e: unknown) => {
@@ -3993,16 +4742,22 @@ function shutdownOn(signal: string): void {
3993
4742
  });
3994
4743
  }
3995
4744
 
3996
- let _shuttingDown = false;
4745
+ /**
4746
+ * Start the shutdown once; a second signal exits at once, and a shutdown
4747
+ * nobody waits on (stdin isn't a TTY) is forced to exit after a deadline
4748
+ * (shutdown-guard.ts).
4749
+ */
4750
+ function beginShutdown(signal: string): void {
4751
+ requestShutdown(signal, shutdownOn, {
4752
+ interactive: Boolean(process.stdin.isTTY) && signal !== "SIGPIPE",
4753
+ });
4754
+ }
4755
+
3997
4756
  process.on("SIGINT", () => {
3998
- if (_shuttingDown) return;
3999
- _shuttingDown = true;
4000
- console.log(""); // newline after ^C
4001
- shutdownOn("SIGINT");
4757
+ if (!outputLost()) console.log(""); // newline after ^C
4758
+ beginShutdown("SIGINT");
4002
4759
  });
4003
4760
 
4004
4761
  process.on("SIGTERM", () => {
4005
- if (_shuttingDown) return;
4006
- _shuttingDown = true;
4007
- shutdownOn("SIGTERM");
4762
+ beginShutdown("SIGTERM");
4008
4763
  });