@monster-orange/den 0.3.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 (79) hide show
  1. package/CHANGELOG.md +593 -0
  2. package/docs/distributions.md +429 -18
  3. package/package.json +6 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/default-pack/be-dev.md +20 -0
  7. package/src/shared/agents/default-pack/devops.md +14 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  10. package/src/shared/agents/default-pack/planner.md +17 -0
  11. package/src/shared/agents/default-pack/reviewer.md +14 -0
  12. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  13. package/src/shared/agents/den-claude.ts +271 -0
  14. package/src/shared/agents/generic.ts +52 -0
  15. package/src/shared/agents/personas.ts +218 -0
  16. package/src/shared/agents/records.ts +118 -0
  17. package/src/shared/agents/runtime.ts +199 -0
  18. package/src/shared/agents/runtimes.ts +54 -0
  19. package/src/shared/den-config.ts +116 -0
  20. package/src/shared/engine.ts +23 -0
  21. package/src/shared/environment-manager/scope.ts +22 -0
  22. package/src/shared/environment-manager/service-runner.ts +94 -2
  23. package/src/shared/environment-manager/workspaces.ts +19 -0
  24. package/src/shared/profile.ts +15 -4
  25. package/src/shared/secret-env.ts +70 -0
  26. package/src/shared/sessions/client.ts +284 -0
  27. package/src/shared/sessions/den-pty.ts +97 -0
  28. package/src/shared/sessions/env.ts +89 -0
  29. package/src/shared/sessions/host.ts +690 -0
  30. package/src/shared/sessions/names.ts +85 -0
  31. package/src/shared/sessions/paths.ts +27 -0
  32. package/src/shared/sessions/protocol.ts +72 -0
  33. package/src/shared/sessions/pty.ts +141 -0
  34. package/src/shared/sessions/ring-buffer.ts +160 -0
  35. package/src/shared/sessions/types.ts +81 -0
  36. package/src/shared/web-secret.ts +181 -0
  37. package/src/shared/web-session.ts +491 -0
  38. package/src/ui/cli/agent-ops.ts +843 -0
  39. package/src/ui/cli/agents-command.ts +294 -0
  40. package/src/ui/cli/doctor-command.ts +138 -9
  41. package/src/ui/cli/env-probe.ts +266 -0
  42. package/src/ui/cli/flag-parser.ts +32 -3
  43. package/src/ui/cli/index.ts +128 -12
  44. package/src/ui/cli/sessions-command.ts +750 -0
  45. package/src/ui/cli/target-workspace.ts +105 -0
  46. package/src/ui/cli/web-command.ts +536 -14
  47. package/src/ui/mcp/api.ts +7 -0
  48. package/src/ui/mcp/scope.ts +123 -19
  49. package/src/ui/mcp/server.ts +100 -7
  50. package/src/ui/mcp/tools.ts +44 -5
  51. package/src/ui/server/agent-routes.ts +323 -0
  52. package/src/ui/server/audit.ts +111 -22
  53. package/src/ui/server/http-errors.ts +21 -3
  54. package/src/ui/server/index.ts +820 -65
  55. package/src/ui/server/public/agents-page.js +648 -0
  56. package/src/ui/server/public/agents-section.js +299 -0
  57. package/src/ui/server/public/agents.html +439 -0
  58. package/src/ui/server/public/appearance.js +3 -0
  59. package/src/ui/server/public/error-page.js +103 -0
  60. package/src/ui/server/public/header.js +45 -11
  61. package/src/ui/server/public/i18n/es.js +90 -1
  62. package/src/ui/server/public/i18n/ja.js +86 -1
  63. package/src/ui/server/public/local-dev.html +53 -0
  64. package/src/ui/server/public/logs.html +3 -0
  65. package/src/ui/server/public/panel.js +1 -1
  66. package/src/ui/server/public/release.html +8 -0
  67. package/src/ui/server/public/settings-page.js +5 -0
  68. package/src/ui/server/public/settings-registry.js +33 -0
  69. package/src/ui/server/public/shell.js +248 -5
  70. package/src/ui/server/public/topbar.js +51 -2
  71. package/src/ui/server/readonly-routes.ts +25 -2
  72. package/src/ui/server/request-guard.ts +670 -0
  73. package/src/ui/server/route-classes.ts +317 -0
  74. package/src/ui/server/session-routes.ts +445 -0
  75. package/src/ui/server/shutdown-guard.ts +91 -0
  76. package/src/ui/server/sudo-store.ts +29 -3
  77. package/src/ui/server/ui-assets.ts +10 -1
  78. package/src/ui/server/websocket.ts +326 -0
  79. package/src/ui/server/workspace-routes.ts +16 -22
@@ -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,14 +171,52 @@ 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,
177
+ recordCrossWorkspace,
171
178
  repoAtPath,
172
179
  setAuditSink,
173
180
  } from "./audit.js";
174
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";
175
220
  import { serveUiAsset } from "./ui-assets.js";
176
221
  import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
177
222
  import { graphPageHtml } from "./graph-page.js";
@@ -238,10 +283,16 @@ interface HealthResult {
238
283
  /** From the focused workspace: the header sorts these first. */
239
284
  focused?: boolean;
240
285
  /**
241
- * The workspaces in view that define this service (focus first). More
286
+ * The active workspaces that define this service (focus first). More
242
287
  * than one means a shared service: it is listed once, with its owners.
243
288
  */
244
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;
245
296
  }
246
297
 
247
298
  /**
@@ -273,6 +324,19 @@ function viewerRewrite(
273
324
  });
274
325
  }
275
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
+
276
340
  const HEALTH_CACHE_TTL_MS = 8_000;
277
341
  /** Consecutive timed-out health cycles per check (see TimeoutStreaks). */
278
342
  const healthTimeouts = new TimeoutStreaks();
@@ -302,10 +366,11 @@ enableReadCache(READ_CACHE_TTL_MS);
302
366
  const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
303
367
  /**
304
368
  * A workspace-scoped run covers a different service set than the default
305
- * 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).
306
371
  */
307
372
  function healthCacheKey(env: Environment, workspace: string | null): string {
308
- return `${env}|${workspace ?? `*:${scopeKey(scopeView())}`}`;
373
+ return `${env}|${workspace ?? `*:${scopeKey(pollView())}`}`;
309
374
  }
310
375
  import {
311
376
  LOG_SWEEP_INTERVAL_MS,
@@ -434,9 +499,13 @@ function pushLog(kind: string, text: string, actor?: string): void {
434
499
  */
435
500
  function serverLog(msg: string, kind: string = "info", actor?: string): void {
436
501
  pushLog(kind, msg, actor);
437
- if (!SILENT) process.stdout.write(msg + "\n");
502
+ if (!SILENT && !outputLost()) process.stdout.write(msg + "\n");
438
503
  }
439
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
+
440
509
  // ── Theme ────────────────────────────────────────────────────────────────────
441
510
  // The active theme (user's, else the org config's `theme:`, else den). A
442
511
  // theme switch is a write of user.yml `appearance:` and a new /theme.css:
@@ -469,6 +538,17 @@ setHtmlFill((html) => {
469
538
  .join(mode)
470
539
  .split("@@DEN_DEFAULT_MODE@@")
471
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
+ }
472
552
  // MD-2: every page says when the engine on disk is not the one running.
473
553
  const engine = engineCheck();
474
554
  if (engine.stale)
@@ -521,10 +601,22 @@ function sendErrorPage(
521
601
  res.end();
522
602
  return;
523
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";
524
608
  serveFile(
525
609
  res,
526
610
  path.join(PUBLIC_DIR, "error.html"),
527
- (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
+ },
528
620
  data.status,
529
621
  );
530
622
  }
@@ -586,6 +678,12 @@ function allWorkspaceCheckouts(): ResolvedService[] {
586
678
  */
587
679
  const CONFIG_FREE_ROUTES = new Set([
588
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",
589
687
  "/api/server-log",
590
688
  "/api/changelog",
591
689
  "/api/web/restart",
@@ -609,11 +707,19 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
609
707
  import {
610
708
  setSudoPassword,
611
709
  clearSudoPassword,
710
+ closeLockForThisRequest,
711
+ inRequestLockScope,
612
712
  getSudoEnv,
613
713
  prewarmSudo,
614
714
  hasSudoPassword,
615
715
  hasPasswordlessSudo,
616
716
  } from "./sudo-store.js";
717
+ import {
718
+ guardStdio,
719
+ isLostOutputError,
720
+ outputLost,
721
+ requestShutdown,
722
+ } from "./shutdown-guard.js";
617
723
 
618
724
  // ── HTTP helpers ─────────────────────────────────────────────────────────────
619
725
  import { sweepLogs } from "../../shared/process/log-rotation.js";
@@ -657,9 +763,17 @@ import { parseChangelog } from "./changelog-parser.js";
657
763
  import { GitHubService } from "./github-service.js";
658
764
  import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
659
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";
660
774
  import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
661
775
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
662
- import { edgeHttps } from "../../shared/edge/render.js";
776
+ import { edgeDomains, edgeHttps, routeHost } from "../../shared/edge/render.js";
663
777
  import {
664
778
  linksFor,
665
779
  openUrlFor,
@@ -775,6 +889,358 @@ function mirrorLine(runId: string, text: string): void {
775
889
  process.stdout.write(` ${dim(`[${runId}]`)} ${stripAnsi(text)}\n`);
776
890
  }
777
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
+
778
1244
  // ── Server ────────────────────────────────────────────────────────────────────
779
1245
 
780
1246
  async function handleRequest(
@@ -789,15 +1255,6 @@ async function handleRequest(
789
1255
  // R26b: every /api error answers JSON, whatever the route sends.
790
1256
  if (pathname === "/api" || pathname.startsWith("/api/"))
791
1257
  jsonApiErrors(res, requestId);
792
- // A mutating request may change what /api/services would list (exec and
793
- // sync stream over GET, for EventSource).
794
- if (
795
- (req.method !== "GET" && req.method !== "HEAD") ||
796
- /\/(exec|sync)$/.test(pathname)
797
- ) {
798
- servicesCache.clear();
799
- clearReadCache();
800
- }
801
1258
 
802
1259
  // ── Request logging ────────────────────────────────────────────────────────
803
1260
  // /api/health fires on the UI's 5s polling interval forever — it dominates
@@ -822,6 +1279,69 @@ async function handleRequest(
822
1279
  }
823
1280
  }
824
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
+
825
1345
  // ── Config validation ──────────────────────────────────────────────────────
826
1346
  // A config that fails validation answers every API route that reads it as
827
1347
  // the ConfigError the handler below maps to 422 JSON, before any route's own
@@ -881,6 +1401,11 @@ async function handleRequest(
881
1401
  );
882
1402
  }
883
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
+
884
1409
  if (pathname === "/tests" || pathname === "/index.html") {
885
1410
  return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
886
1411
  }
@@ -1067,7 +1592,8 @@ async function handleRequest(
1067
1592
  // route; it reports them here so the activity trail shows failed attempts.
1068
1593
  // Agents only (the audit header), and the entry is all it can write.
1069
1594
  if (pathname === "/api/activity" && req.method === "POST") {
1070
- const actor = actorFromHeaders(req.headers);
1595
+ // An agent's token, or (unverified) an MCP server's audit headers.
1596
+ const actor = auditActorOf(req);
1071
1597
  if (!actor) return sendJson(res, { error: "agents only" }, 403);
1072
1598
  const raw = await readBody(req).catch(() => "");
1073
1599
  if (raw.length > 4096) return send400(res, "report too large");
@@ -1088,7 +1614,13 @@ async function handleRequest(
1088
1614
  }
1089
1615
  if (repo) report = { ...report, repo };
1090
1616
  }
1091
- const entry = recordAgentRefusal(actor, report);
1617
+ // A call into a granted workspace (AG-2a): both workspaces recorded.
1618
+ if (report.crossWorkspace !== undefined) {
1619
+ const crossed = recordCrossWorkspace(actor, report, principalOf(req));
1620
+ if (!crossed) return send400(res, "not a cross-workspace report");
1621
+ return sendJson(res, { recorded: true, ts: crossed.ts });
1622
+ }
1623
+ const entry = recordAgentRefusal(actor, report, principalOf(req));
1092
1624
  return sendJson(res, { recorded: true, ts: entry.ts });
1093
1625
  }
1094
1626
 
@@ -1165,11 +1697,11 @@ async function handleRequest(
1165
1697
 
1166
1698
  // ── API: health check ─────────────────────────────────────────────────────
1167
1699
  /**
1168
- * Workspaces a health run covers: one named workspace, else the view
1169
- * 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
1170
1702
  * machine-level: a service running under a non-focused active workspace
1171
- * is still up, and its card must say so. The pages and `status` read the
1172
- * 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).
1173
1705
  */
1174
1706
  function healthWorkspaces(
1175
1707
  workspaceName: string | null,
@@ -1178,7 +1710,7 @@ async function handleRequest(
1178
1710
  const named = getWorkspaceByName(workspaceName);
1179
1711
  return named ? [named] : [];
1180
1712
  }
1181
- return scopeView().workspaces.filter((w) => w.config);
1713
+ return pollView().workspaces.filter((w) => w.config);
1182
1714
  }
1183
1715
 
1184
1716
  /**
@@ -1536,7 +2068,7 @@ async function handleRequest(
1536
2068
  // single-service `?address=` probe always runs live: it exists to re-check one
1537
2069
  // node on demand, where staleness is the whole thing being avoided.
1538
2070
  if (pathname === "/api/health") {
1539
- const forViewer = viewerRewrite(req.headers.host);
2071
+ const forViewer = healthForViewer(req.headers.host);
1540
2072
  const env = (url.searchParams.get("env") ?? "local") as Environment;
1541
2073
  const addressFilter = url.searchParams.get("address");
1542
2074
  const wsParam = url.searchParams.get("workspace");
@@ -1565,7 +2097,7 @@ async function handleRequest(
1565
2097
  // Same checks, streamed: each result is sent the moment it lands so the fast
1566
2098
  // ones paint immediately instead of every dot waiting on the slowest.
1567
2099
  if (pathname === "/api/health/stream") {
1568
- const forViewer = viewerRewrite(req.headers.host);
2100
+ const forViewer = healthForViewer(req.headers.host);
1569
2101
  const env = (url.searchParams.get("env") ?? "local") as Environment;
1570
2102
  const wsParam = url.searchParams.get("workspace");
1571
2103
  if (wsParam && !getWorkspaceByName(wsParam))
@@ -1650,12 +2182,16 @@ async function handleRequest(
1650
2182
  // Every fail here is before anything ran: a refusal or a lookup miss.
1651
2183
  // Recorded, so failed attempts show in the activity trail (F2).
1652
2184
  const fail = (msg: string): void => {
1653
- recordAction(actorFromHeaders(req.headers), {
1654
- verb: "exec",
1655
- detail: `${repoName}/${svcName} ${cmdName} (${env})`,
1656
- outcome: "refused",
1657
- reason: msg,
1658
- });
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
+ );
1659
2195
  emit({ type: "error", text: msg });
1660
2196
  emit({ type: "done", exitCode: 1, duration: 0 });
1661
2197
  res.end();
@@ -1691,8 +2227,11 @@ async function handleRequest(
1691
2227
  );
1692
2228
  if (refusal) return fail(refusal);
1693
2229
  if (manifest) {
1694
- cmdDef =
1695
- manifest.services?.[resolved.subsystem]?.commands?.[env]?.[cmdName];
2230
+ cmdDef = declaredCommand(
2231
+ manifest.services?.[resolved.subsystem]?.commands,
2232
+ env,
2233
+ cmdName,
2234
+ );
1696
2235
  }
1697
2236
  // Lifecycle commands run through the service runner, as the CLI's do:
1698
2237
  // restart without a declared restart is down + up, up on a healthy
@@ -1705,11 +2244,15 @@ async function handleRequest(
1705
2244
  );
1706
2245
  const execId = shortRunId();
1707
2246
  const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
1708
- const execActor = actorFromHeaders(req.headers);
1709
- recordAction(execActor, {
1710
- verb: "exec",
1711
- detail: `${execDesc} [${execId}]`,
1712
- });
2247
+ const execActor = auditActorOf(req);
2248
+ recordAction(
2249
+ execActor,
2250
+ {
2251
+ verb: "exec",
2252
+ detail: `${execDesc} [${execId}]`,
2253
+ },
2254
+ principalOf(req),
2255
+ );
1713
2256
  prewarmSudo();
1714
2257
  void streamLifecycleExec(
1715
2258
  {
@@ -1764,7 +2307,11 @@ async function handleRequest(
1764
2307
  )
1765
2308
  : null;
1766
2309
  if (refusal) return fail(refusal);
1767
- cmdDef = repo?.manifest.services?.[svcName]?.commands?.[env]?.[cmdName];
2310
+ cmdDef = declaredCommand(
2311
+ repo?.manifest.services?.[svcName]?.commands,
2312
+ env,
2313
+ cmdName,
2314
+ );
1768
2315
  }
1769
2316
 
1770
2317
  if (!cmdDef || !repoPath) {
@@ -1810,11 +2357,15 @@ async function handleRequest(
1810
2357
 
1811
2358
  const execId = shortRunId();
1812
2359
  const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
1813
- const execActor = actorFromHeaders(req.headers);
1814
- recordAction(execActor, {
1815
- verb: "exec",
1816
- detail: `${execDesc} [${execId}]`,
1817
- });
2360
+ const execActor = auditActorOf(req);
2361
+ recordAction(
2362
+ execActor,
2363
+ {
2364
+ verb: "exec",
2365
+ detail: `${execDesc} [${execId}]`,
2366
+ },
2367
+ principalOf(req),
2368
+ );
1818
2369
 
1819
2370
  // For detachable commands (up/restart), the service is spawned as a daemon
1820
2371
  // writing to a log file so it survives den restarts. The tail proc
@@ -2441,6 +2992,17 @@ async function handleRequest(
2441
2992
  }
2442
2993
 
2443
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
+ );
2444
3006
 
2445
3007
  // Resolve declarative optional fields → per-module env. Only the perf pillar
2446
3008
  // declares optionalFields; for every other pillar this is an empty map.
@@ -2528,8 +3090,12 @@ async function handleRequest(
2528
3090
  const runId = shortRunId();
2529
3091
  const modDesc = modules.length ? modules.join(",") : "all";
2530
3092
  const runDesc = `${pillar}/${env} · modules=${modDesc}`;
2531
- const runActor = actorFromHeaders(req.headers);
2532
- 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
+ );
2533
3099
  if (
2534
3100
  pillar === "synthetic" ||
2535
3101
  pillar === "smoke" ||
@@ -2702,6 +3268,110 @@ async function handleRequest(
2702
3268
  }
2703
3269
 
2704
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
+
2705
3375
  if (pathname === "/api/server-info" && req.method === "GET") {
2706
3376
  const { stale, scanned } = computeStale();
2707
3377
  const engine = engineCheck();
@@ -3027,6 +3697,20 @@ async function handleRequest(
3027
3697
  return;
3028
3698
  }
3029
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
+
3030
3714
  // ── Workspace routes (extracted to workspace-routes.ts) ──────────────────
3031
3715
  await handleWorkspaceRoutes(req, res, pathname, url, editors, github);
3032
3716
  if (res.headersSent) return;
@@ -3623,7 +4307,8 @@ const server = http.createServer((req, res) => {
3623
4307
  // An async handler with no catch turns any route throw into an unhandled
3624
4308
  // rejection — which CRASHES the whole server on modern Node (freezing every
3625
4309
  // health dot) and leaves the request hanging. Fail the one request instead.
3626
- 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) => {
3627
4312
  // R26: the log has the stack under the request id; the browser gets the
3628
4313
  // id, never the stack. The route is its template: no query values.
3629
4314
  const requestId = requestIdOf(req);
@@ -3705,6 +4390,49 @@ const server = http.createServer((req, res) => {
3705
4390
  });
3706
4391
  });
3707
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
+
3708
4436
  server.on("error", (err: NodeJS.ErrnoException) => {
3709
4437
  if (err.code === "EADDRINUSE") {
3710
4438
  console.error(
@@ -3794,10 +4522,14 @@ setInterval(sweepServiceLogs, LOG_SWEEP_INTERVAL_MS).unref();
3794
4522
  // timers — code the per-request catch can't see) would kill the whole
3795
4523
  // dashboard; a TDZ bug in a down-flow callback did exactly that. For a local
3796
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).
3797
4527
  process.on("uncaughtException", (err) => {
4528
+ if (isLostOutputError(err) || outputLost()) return;
3798
4529
  console.error("[server] uncaught exception (continuing):", err);
3799
4530
  });
3800
4531
  process.on("unhandledRejection", (reason) => {
4532
+ if (isLostOutputError(reason) || outputLost()) return;
3801
4533
  console.error("[server] unhandled rejection (continuing):", reason);
3802
4534
  });
3803
4535
 
@@ -3811,10 +4543,28 @@ try {
3811
4543
  serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
3812
4544
  }
3813
4545
 
3814
- // Bind loopback only — this UI runs privileged local actions (sudo-backed
3815
- // service commands), so it must not be reachable from the LAN. Opt out with
3816
- // DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
3817
- const BIND_HOST = process.env.DEN_BIND_HOST ?? "127.0.0.1";
4546
+ // §4: a non-loopback bind needs HTTPS in front, or the person's say-so.
4547
+ {
4548
+ const refusal = bindRefusal(BIND_HOST, {
4549
+ allowPlainHttpRemote: WEB_AUTH.allowPlainHttpRemote,
4550
+ fronted: edgeFronted(),
4551
+ });
4552
+ if (refusal) {
4553
+ console.error(`\n ✗ ${refusal}\n`);
4554
+ process.exit(1);
4555
+ }
4556
+ }
4557
+ {
4558
+ const ignored = ignoredConfigWarning(WEB_AUTH);
4559
+ if (ignored) serverLog(` ⚠ ${ignored}`, "warn");
4560
+ }
4561
+ // SEC-1a: the secret the CLI's person token is derived from (0600 in the
4562
+ // state dir). Never printed or logged; a failure leaves bearers unverifiable.
4563
+ try {
4564
+ webSecret.ensure();
4565
+ } catch (e) {
4566
+ serverLog(` ⚠ web.secret could not be created: ${(e as Error).message}`);
4567
+ }
3818
4568
  server.listen(PORT, BIND_HOST, () => {
3819
4569
  const hosts = fs.existsSync("/etc/hosts")
3820
4570
  ? fs.readFileSync("/etc/hosts", "utf-8")
@@ -3934,7 +4684,8 @@ async function handleShutdown(signal: string): Promise<void> {
3934
4684
  process.exit(0);
3935
4685
  }
3936
4686
 
3937
- const isTTY = process.stdin.isTTY;
4687
+ // Lost output (SIGPIPE): nobody can answer a prompt.
4688
+ const isTTY = process.stdin.isTTY && signal !== "SIGPIPE";
3938
4689
 
3939
4690
  if (!isTTY) {
3940
4691
  // Non-interactive shutdown usually means `den web` is being restarted.
@@ -3974,10 +4725,8 @@ async function handleShutdown(signal: string): Promise<void> {
3974
4725
  }
3975
4726
 
3976
4727
  /**
3977
- * Run the shutdown for `signal`. A failure still ends the process: the guard
3978
- * below ignores every later signal, so a shutdown that rejected would leave
3979
- * the server running and deaf to SIGTERM (a service manager then waits out
3980
- * its timeout and SIGKILLs it).
4728
+ * Run the shutdown for `signal`. A failure still ends the process, at once
4729
+ * rather than at the deadline.
3981
4730
  */
3982
4731
  function shutdownOn(signal: string): void {
3983
4732
  handleShutdown(signal).catch((e: unknown) => {
@@ -3986,16 +4735,22 @@ function shutdownOn(signal: string): void {
3986
4735
  });
3987
4736
  }
3988
4737
 
3989
- let _shuttingDown = false;
4738
+ /**
4739
+ * Start the shutdown once; a second signal exits at once, and a shutdown
4740
+ * nobody waits on (stdin isn't a TTY) is forced to exit after a deadline
4741
+ * (shutdown-guard.ts).
4742
+ */
4743
+ function beginShutdown(signal: string): void {
4744
+ requestShutdown(signal, shutdownOn, {
4745
+ interactive: Boolean(process.stdin.isTTY) && signal !== "SIGPIPE",
4746
+ });
4747
+ }
4748
+
3990
4749
  process.on("SIGINT", () => {
3991
- if (_shuttingDown) return;
3992
- _shuttingDown = true;
3993
- console.log(""); // newline after ^C
3994
- shutdownOn("SIGINT");
4750
+ if (!outputLost()) console.log(""); // newline after ^C
4751
+ beginShutdown("SIGINT");
3995
4752
  });
3996
4753
 
3997
4754
  process.on("SIGTERM", () => {
3998
- if (_shuttingDown) return;
3999
- _shuttingDown = true;
4000
- shutdownOn("SIGTERM");
4755
+ beginShutdown("SIGTERM");
4001
4756
  });