@monster-orange/den 0.4.13 → 0.4.15

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 (57) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +185 -7
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +256 -0
  7. package/src/shared/agents/den-claude.ts +19 -0
  8. package/src/shared/agents/records.ts +23 -0
  9. package/src/shared/agents/runtime.ts +14 -0
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +14 -0
  13. package/src/shared/environment-manager/service-runner.ts +31 -12
  14. package/src/shared/environment-manager/workspaces.ts +31 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +320 -18
  17. package/src/ui/cli/agents-command.ts +89 -2
  18. package/src/ui/cli/doctor-command.ts +29 -3
  19. package/src/ui/cli/doctor-onboard.ts +26 -0
  20. package/src/ui/cli/flag-parser.ts +9 -1
  21. package/src/ui/cli/index.ts +29 -3
  22. package/src/ui/cli/onboard-command.ts +180 -0
  23. package/src/ui/cli/onboard-reads.ts +613 -0
  24. package/src/ui/cli/repo-command.ts +156 -10
  25. package/src/ui/cli/web-command.ts +92 -1
  26. package/src/ui/mcp/api.ts +26 -3
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +427 -1
  29. package/src/ui/server/agent-resume-grant.ts +35 -0
  30. package/src/ui/server/agent-routes.ts +149 -1
  31. package/src/ui/server/approval-routes.ts +390 -0
  32. package/src/ui/server/approvals.ts +416 -0
  33. package/src/ui/server/github-routes.ts +12 -4
  34. package/src/ui/server/held-actions.ts +203 -0
  35. package/src/ui/server/http-helpers.ts +35 -0
  36. package/src/ui/server/index.ts +286 -16
  37. package/src/ui/server/lifecycle-exec.ts +70 -0
  38. package/src/ui/server/onboard-routes.ts +132 -0
  39. package/src/ui/server/pair-routes.ts +7 -4
  40. package/src/ui/server/public/agents-page.js +288 -8
  41. package/src/ui/server/public/agents.html +18 -0
  42. package/src/ui/server/public/error-page.js +27 -3
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +52 -0
  45. package/src/ui/server/public/i18n/ja.js +55 -0
  46. package/src/ui/server/public/index.html +1 -1
  47. package/src/ui/server/public/local-dev.html +5 -4
  48. package/src/ui/server/public/persona-ui.css +45 -0
  49. package/src/ui/server/public/release.html +0 -1
  50. package/src/ui/server/public/settings-registry.js +3 -1
  51. package/src/ui/server/public/settings.html +9 -1
  52. package/src/ui/server/public/shell.js +336 -0
  53. package/src/ui/server/request-guard.ts +19 -4
  54. package/src/ui/server/route-classes.ts +41 -1
  55. package/src/ui/server/session-routes.ts +11 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -48,6 +48,7 @@ import { readCatalog } from "../../shared/environment-manager/catalog.js";
48
48
  import {
49
49
  getActiveWorkspace,
50
50
  getActiveWorkspaces,
51
+ createWorkspace,
51
52
  getFocusedWorkspaceName,
52
53
  getWorkspaceByName,
53
54
  isWorkspaceActive,
@@ -112,6 +113,7 @@ import {
112
113
  import {
113
114
  assertDenConfigValid,
114
115
  readDenConfig,
116
+ readMcpConfig,
115
117
  isProductionEnv,
116
118
  isLocalEnabled,
117
119
  readUserConfig,
@@ -183,6 +185,11 @@ import {
183
185
  AUDIT_FILE,
184
186
  } from "./audit.js";
185
187
  import { handleReadonlyRoutes } from "./readonly-routes.js";
188
+ import {
189
+ defaultOnboardRouteDeps,
190
+ handleOnboardRoutes,
191
+ type OnboardRouteDeps,
192
+ } from "./onboard-routes.js";
186
193
  import {
187
194
  auditActorOf,
188
195
  bindRefusal,
@@ -226,9 +233,12 @@ import {
226
233
  sessionCookieName,
227
234
  sessionIdPrefix,
228
235
  WebSessionStore,
236
+ withApproveKey,
229
237
  type WebAuthConfig,
230
238
  } from "../../shared/web-session.js";
231
239
  import { serveUiAsset } from "./ui-assets.js";
240
+ import { PERSON_GRANT_ROUTES, personGrantAllowed } from "./approvals.js";
241
+ import { ClaimNonces, checkClaimCode } from "../../shared/claim-code.js";
232
242
  import {
233
243
  expiringTokenWarning,
234
244
  githubClientId,
@@ -253,7 +263,9 @@ import {
253
263
  machineLabel,
254
264
  parseMachineName,
255
265
  sshCommand,
266
+ sshLogin,
256
267
  sshTarget,
268
+ sshUser,
257
269
  } from "../../shared/cli-command.js";
258
270
  import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
259
271
  import { agentsLayout, patchAgentsLayout } from "./agents-layout-pref.js";
@@ -612,6 +624,9 @@ setHtmlFill((html) => {
612
624
 
613
625
  // ── MD-2: the engine this server loaded, against the one on disk ─────────────
614
626
  const ENGINE = engineWatch(getEngineRoot);
627
+
628
+ /** The onboarding routes' deps, made on first use. */
629
+ let onboardDeps: OnboardRouteDeps | null = null;
615
630
  function engineCheck(): EngineCheck {
616
631
  return ENGINE.check();
617
632
  }
@@ -789,6 +804,8 @@ import {
789
804
  readBody,
790
805
  sendJson,
791
806
  serveFile,
807
+ serveUntrustedFile,
808
+ UNTRUSTED_FILE_HEADERS,
792
809
  send400,
793
810
  send404,
794
811
  setHtmlFill,
@@ -824,7 +841,19 @@ import { parseChangelog } from "./changelog-parser.js";
824
841
  // ── Extracted services ───────────────────────────────────────────────────────
825
842
  import { GitHubService } from "./github-service.js";
826
843
  import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
827
- import { handleWorkspaceRoutes } from "./workspace-routes.js";
844
+ import {
845
+ handleWorkspaceRoutes,
846
+ workspaceCreateRefusal,
847
+ } from "./workspace-routes.js";
848
+ import {
849
+ createApprovalQueue,
850
+ handleApprovalRoutes,
851
+ isApprovalPath,
852
+ type HeldActions,
853
+ } from "./approval-routes.js";
854
+ import { heldActions } from "./held-actions.js";
855
+ import type { ApprovalQueue } from "./approvals.js";
856
+ import { processPathRules } from "../cli/onboard-reads.js";
828
857
  import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
829
858
  import {
830
859
  handleSessionList,
@@ -833,7 +862,11 @@ import {
833
862
  sendSessionHttp,
834
863
  } from "./session-routes.js";
835
864
  import { refuseUpgrade } from "./websocket.js";
836
- import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
865
+ import {
866
+ collectLifecycleExec,
867
+ isLifecycleExecCmd,
868
+ streamLifecycleExec,
869
+ } from "./lifecycle-exec.js";
837
870
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
838
871
  import {
839
872
  edgeHttps,
@@ -1219,6 +1252,26 @@ function loggedPath(pathname: string): string {
1219
1252
  );
1220
1253
  }
1221
1254
 
1255
+ /**
1256
+ * The account the signed-out page's ssh link logs in as (SEC-2a-UX):
1257
+ * `web.ssh_user` (read now), else the account Den runs as; "" for none.
1258
+ */
1259
+ function sshUserNow(): string {
1260
+ let set: string | undefined;
1261
+ try {
1262
+ set = readWebAuthConfig(getDistRoot(), product().configFile).sshUser;
1263
+ } catch {
1264
+ set = WEB_AUTH.sshUser;
1265
+ }
1266
+ let osUser = "";
1267
+ try {
1268
+ osUser = os.userInfo().username;
1269
+ } catch {
1270
+ /* no account name: none on the page */
1271
+ }
1272
+ return sshUser(set, osUser);
1273
+ }
1274
+
1222
1275
  /** The ssh target for this request: the Host it used, minus the port. */
1223
1276
  function sshTargetOf(req: http.IncomingMessage): string {
1224
1277
  return sshTarget(req.headers.host, os.hostname());
@@ -1245,22 +1298,28 @@ function closeSessionSockets(keys: Iterable<string>): void {
1245
1298
  }
1246
1299
  }
1247
1300
 
1248
- /** A new session for this request and its Set-Cookie line (GitHub or a link). */
1301
+ /**
1302
+ * A new session for this request: its Set-Cookie line, and its approval
1303
+ * key (web-session.ts), which only this sign-in's own answer may carry.
1304
+ */
1249
1305
  function startSession(
1250
1306
  req: http.IncomingMessage,
1251
- via: "github" | "link" | "code",
1307
+ via: "github" | "link" | "code" | "claim",
1252
1308
  login?: string,
1253
- ): string {
1309
+ ): { cookie: string; approveKey: string } {
1254
1310
  const g = checkRequest(req, GUARD, product().cli, product().title);
1255
1311
  const named = g.ok ? g.via : null;
1256
- const { id } = webSessions.create({
1312
+ const { id, approveKey } = webSessions.create({
1257
1313
  userAgent: req.headers["user-agent"],
1258
1314
  host: req.headers.host,
1259
1315
  via,
1260
1316
  ...(g.ok && g.client ? { client: g.client } : {}),
1261
1317
  ...(login ? { login } : {}),
1262
1318
  });
1263
- return sessionSetCookie(id, requestIsHttps(req, named));
1319
+ return {
1320
+ cookie: sessionSetCookie(id, requestIsHttps(req, named)),
1321
+ approveKey,
1322
+ };
1264
1323
  }
1265
1324
 
1266
1325
  /** The edge hostnames that route here, and their URLs (Pair a device). */
@@ -1366,6 +1425,7 @@ function sendSignedOut(
1366
1425
  toLocal || host === `localhost:${PORT}` ? [] : ["--origin", origin];
1367
1426
  const cmd = cliCommand(bin, "web", "login", ...loginArgs);
1368
1427
  const ssh = sshTargetOf(req);
1428
+ const user = sshUserNow();
1369
1429
  res.setHeader("Cache-Control", "no-store");
1370
1430
  sendErrorPage(req, res, {
1371
1431
  status: 401,
@@ -1381,9 +1441,11 @@ function sendSignedOut(
1381
1441
  machine: machineName(),
1382
1442
  ssh: {
1383
1443
  target: ssh,
1444
+ // SEC-2a-UX: the account to log in as ("" for none).
1445
+ user,
1384
1446
  // Open Terminal (ssh://) only for a name, never an IP literal.
1385
1447
  name: !isIpLiteral(ssh),
1386
- login: sshCommand(ssh, cmd),
1448
+ login: sshCommand(sshLogin(ssh, user), cmd),
1387
1449
  },
1388
1450
  // SEC-2a: Sign in with GitHub, and why it can't be offered yet.
1389
1451
  github: {
@@ -1475,6 +1537,8 @@ async function handleAuth(
1475
1537
  url: URL,
1476
1538
  requestId: string,
1477
1539
  ): Promise<void> {
1540
+ if (url.searchParams.has("claim"))
1541
+ return handleClaim(req, res, url, requestId);
1478
1542
  const secret = webSecret.current();
1479
1543
  const c = url.searchParams.get("c") ?? "";
1480
1544
  const nonce = loginCodeNonce(c);
@@ -1566,25 +1630,74 @@ async function handleAuth(
1566
1630
  device: deviceName(req.headers["user-agent"]),
1567
1631
  ...(g.ok && g.client ? { client: g.client } : {}),
1568
1632
  });
1569
- res.setHeader("Set-Cookie", startSession(req, "link"));
1633
+ const s = startSession(req, "link");
1634
+ res.setHeader("Set-Cookie", s.cookie);
1635
+ res.setHeader("Cache-Control", "no-store");
1570
1636
  return sendJson(res, {
1571
1637
  ok: true,
1572
1638
  next: safeNext(url.searchParams.get("next")),
1639
+ approveKey: s.approveKey,
1573
1640
  });
1574
1641
  }
1575
1642
  // A link nobody waits on (`web login --no-wait`, or minted with no
1576
1643
  // server running): it signs in when opened, once, as before.
1577
1644
  if (post || !webSessions.consumeCode(check.nonce, check.expiresAt))
1578
1645
  return gone();
1646
+ const s = startSession(req, "link");
1579
1647
  res.writeHead(303, {
1580
- Location: safeNext(url.searchParams.get("next")),
1581
- "Set-Cookie": startSession(req, "link"),
1648
+ // The approval key in the fragment: it never reaches a server.
1649
+ Location: withApproveKey(
1650
+ safeNext(url.searchParams.get("next")),
1651
+ s.approveKey,
1652
+ ),
1653
+ "Set-Cookie": s.cookie,
1582
1654
  "Cache-Control": "no-store",
1583
1655
  "Referrer-Policy": "no-referrer",
1584
1656
  });
1585
1657
  res.end();
1586
1658
  }
1587
1659
 
1660
+ /**
1661
+ * `GET /auth?claim=<code>` (0.4.15): the setup link `<cli> web claim-link`
1662
+ * prints at the end of an install. A valid, unused claim code (30 min,
1663
+ * single use across restarts) becomes a session ("claimed with a setup
1664
+ * link" in Linked devices), then a 303 to `/` with the session's approval
1665
+ * key in the fragment and no claim in the address, no referrer. Anything
1666
+ * else: the signed-out page, which says the link was used and never
1667
+ * echoes the code. An agent's token never gets here (AGENT_FORBIDDEN).
1668
+ */
1669
+ const CLAIM_NONCES = new ClaimNonces(getStateDir());
1670
+ function handleClaim(
1671
+ req: http.IncomingMessage,
1672
+ res: http.ServerResponse,
1673
+ url: URL,
1674
+ requestId: string,
1675
+ ): void {
1676
+ res.setHeader("Referrer-Policy", "no-referrer");
1677
+ res.setHeader("Cache-Control", "no-store");
1678
+ const secret = webSecret.current();
1679
+ const check =
1680
+ req.method !== "POST" && secret
1681
+ ? checkClaimCode(url.searchParams.get("claim") ?? "", secret)
1682
+ : null;
1683
+ if (!check?.ok || !CLAIM_NONCES.consume(check.nonce, check.expiresAt))
1684
+ return sendSignedOut(req, res, {
1685
+ pathname: "/auth",
1686
+ search: "",
1687
+ scope: "den",
1688
+ expired: true,
1689
+ requestId,
1690
+ message: `This setup link was used or has expired. If you didn't open it yourself, someone else may have: check Settings › Access › Linked devices. Sign in below, or run \`${product().cli} web claim-link\` in your own terminal for a new one.`,
1691
+ });
1692
+ const s = startSession(req, "claim");
1693
+ res.writeHead(303, {
1694
+ // The approval key in the fragment: it never reaches a server.
1695
+ Location: withApproveKey("/", s.approveKey),
1696
+ "Set-Cookie": s.cookie,
1697
+ });
1698
+ res.end();
1699
+ }
1700
+
1588
1701
  /** `web login`'s wait (login-waits.ts), by the CLI's id. */
1589
1702
  const LOGIN_WAIT_PATH = /^\/api\/web\/logins\/([A-Za-z0-9_-]{16,64})$/;
1590
1703
  const LOGIN_WAITS = new LoginWaits();
@@ -1606,6 +1719,109 @@ function declaredCommand<T>(
1606
1719
  return own(forEnv, cmd) ? forEnv![cmd] : undefined;
1607
1720
  }
1608
1721
 
1722
+ // ── ON-3: the writes held for the person (approval-routes.ts) ─────────────────
1723
+ let approvals: {
1724
+ queue: ApprovalQueue;
1725
+ actions: HeldActions;
1726
+ enabled: () => boolean;
1727
+ } | null = null;
1728
+ /** The held writes and their queue, made on first use. */
1729
+ function approvalDeps(): {
1730
+ queue: ApprovalQueue;
1731
+ actions: HeldActions;
1732
+ enabled: () => boolean;
1733
+ } {
1734
+ if (approvals) return approvals;
1735
+ const actions = heldActions({
1736
+ rules: () => processPathRules(getStateDir()),
1737
+ validate: (m) => validateManifest(m),
1738
+ workspaceCreateRefusal,
1739
+ createWorkspace: (name) => createWorkspace(name, { services: {} }),
1740
+ workspaceExists: (name) =>
1741
+ Object.prototype.hasOwnProperty.call(
1742
+ readWorkspacesFile().workspaces ?? {},
1743
+ name,
1744
+ ),
1745
+ defaultWorkspace: () => getFocusedWorkspaceName() ?? "default",
1746
+ // The exec route's checks for a local `up` (R13 placement, HITL).
1747
+ serviceTarget: (service, ws) => {
1748
+ if (ws && !isWorkspaceActive(ws))
1749
+ return {
1750
+ ok: false,
1751
+ error: `Workspace "${ws}" is not active — activate it first`,
1752
+ };
1753
+ const resolved = resolveService(service, ws);
1754
+ if (!resolved)
1755
+ return {
1756
+ ok: false,
1757
+ error: `Den doesn't know a service "${service}"${ws ? ` in workspace "${ws}"` : ""}; the services tool lists what it runs`,
1758
+ };
1759
+ const manifest = readManifest(resolved.repoPath);
1760
+ const refusal = placementRefusal(
1761
+ resolved.address,
1762
+ servicePlacement(manifest, resolved.subsystem, "local"),
1763
+ (e) => isProductionEnv(e),
1764
+ "up",
1765
+ );
1766
+ if (refusal) return { ok: false, error: refusal };
1767
+ const def = manifest
1768
+ ? declaredCommand(
1769
+ manifest.services?.[resolved.subsystem]?.commands,
1770
+ "local",
1771
+ "up",
1772
+ )
1773
+ : undefined;
1774
+ if (def && isInteractiveServiceCommand(def))
1775
+ return {
1776
+ ok: false,
1777
+ error: `"up" for ${resolved.address} needs an interactive terminal: the person runs \`${product().cli} up ${resolved.address}\``,
1778
+ };
1779
+ return { ok: true, address: resolved.address };
1780
+ },
1781
+ // `up` as /api/service/exec runs it, collected instead of streamed.
1782
+ tryService: async (address, ws) => {
1783
+ const resolved = resolveService(address, ws);
1784
+ if (!resolved)
1785
+ return {
1786
+ ok: false,
1787
+ exitCode: null,
1788
+ ready: false,
1789
+ lines: [`Den no longer knows a service "${address}"`],
1790
+ };
1791
+ prewarmSudo();
1792
+ return collectLifecycleExec(
1793
+ {
1794
+ resolved,
1795
+ cmd: "up",
1796
+ workspace: ws,
1797
+ env: { E2E_ENVIRONMENT: "local", ...getSudoEnv() },
1798
+ },
1799
+ {
1800
+ run: runServiceCommand,
1801
+ health: (r) => checkServiceHealth(r, "local"),
1802
+ secretEnv: (a, c, w) => prepareServiceSecretEnv(a, c, w),
1803
+ },
1804
+ );
1805
+ },
1806
+ });
1807
+ approvals = {
1808
+ actions,
1809
+ queue: createApprovalQueue(actions, () => getStateDir()),
1810
+ // Read per ask: a config edit takes effect without a restart. A bad
1811
+ // value throws, and the handler holds nothing then.
1812
+ enabled: () => readMcpConfig().approvals,
1813
+ };
1814
+ return approvals;
1815
+ }
1816
+ /** The agent ids with a held write waiting (their rows say "waiting"). */
1817
+ function waitingAgentIds(): Set<string> {
1818
+ const out = new Set<string>();
1819
+ if (!approvals) return out;
1820
+ for (const actor of approvals.queue.waitingActors())
1821
+ if (actor.startsWith("agent:")) out.add(actor.slice("agent:".length));
1822
+ return out;
1823
+ }
1824
+
1609
1825
  /** A test module's name as a run takes it (SEC-1 §6b.2): never a path out. */
1610
1826
  const SAFE_MODULE_RE = /^[A-Za-z0-9_@][A-Za-z0-9._@/-]{0,127}$/;
1611
1827
 
@@ -1742,6 +1958,40 @@ async function handleRequest(
1742
1958
  // is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
1743
1959
  if (pathname.startsWith("/api/sessions/"))
1744
1960
  return sendSessionHttp(req, res, pathname);
1961
+ // ON-3: an agent's held writes; Allow from the person's browser only.
1962
+ if (
1963
+ guard.ok &&
1964
+ isApprovalPath(pathname) &&
1965
+ (await handleApprovalRoutes(req, res, pathname, guard, approvalDeps()))
1966
+ )
1967
+ return;
1968
+ // 0.4.15: what lets another browser in needs the person, not just
1969
+ // loopback (approvals.ts: an agent with a shell is on loopback too).
1970
+ if (req.method === "POST" && PERSON_GRANT_ROUTES.includes(pathname)) {
1971
+ const g = guard.ok ? guard : null;
1972
+ const ok = g ? personGrantAllowed(req, g, printedCli()) : null;
1973
+ if (!ok?.ok) {
1974
+ recordAction(
1975
+ auditActorOf(req),
1976
+ {
1977
+ verb: "person-grant",
1978
+ detail: pathname,
1979
+ outcome: "refused" as const,
1980
+ reason: "signin_to_approve",
1981
+ },
1982
+ principalOf(req),
1983
+ );
1984
+ return sendJson(
1985
+ res,
1986
+ {
1987
+ ok: false,
1988
+ code: "signin_to_approve",
1989
+ error: ok && !ok.ok ? ok.error : "Sign in first.",
1990
+ },
1991
+ 403,
1992
+ );
1993
+ }
1994
+ }
1745
1995
  // SEC-2a: Sign in with GitHub, the owner's link, Pair a device. Before
1746
1996
  // config validation, like the sign-in above.
1747
1997
  if (
@@ -1784,7 +2034,10 @@ async function handleRequest(
1784
2034
  },
1785
2035
  commands: (r, code) => {
1786
2036
  const approve = cliCommand(printedCli(), "web", "approve", code);
1787
- return { approve, ssh: sshCommand(sshTargetOf(r), approve) };
2037
+ return {
2038
+ approve,
2039
+ ssh: sshCommand(sshLogin(sshTargetOf(r), sshUserNow()), approve),
2040
+ };
1788
2041
  },
1789
2042
  audit: (r, verb, detail, refused) =>
1790
2043
  recordAction(
@@ -1924,7 +2177,9 @@ async function handleRequest(
1924
2177
  }
1925
2178
 
1926
2179
  // ── Playwright report ──────────────────────────────────────────────────────
1927
- if (pathname.startsWith("/report")) {
2180
+ // A test run wrote it (an agent may have): sandboxed, never Den's origin.
2181
+ // `/report/…` only: `/reports/security/…` is ZAP's, below.
2182
+ if (pathname === "/report" || pathname.startsWith("/report/")) {
1928
2183
  const relPath = pathname.slice("/report".length) || "/index.html";
1929
2184
  const filePath = path.resolve(path.join(REPORT_DIR, relPath));
1930
2185
 
@@ -1945,7 +2200,7 @@ async function handleRequest(
1945
2200
  ? path.join(filePath, "index.html")
1946
2201
  : filePath;
1947
2202
 
1948
- return serveFile(res, target);
2203
+ return serveUntrustedFile(res, target);
1949
2204
  }
1950
2205
 
1951
2206
  // ── API: environments ──────────────────────────────────────────────────────
@@ -2047,9 +2302,8 @@ async function handleRequest(
2047
2302
  "Content-Type": file.endsWith(".html")
2048
2303
  ? "text/html; charset=utf-8"
2049
2304
  : "application/json",
2050
- "Cache-Control": "no-store",
2051
2305
  // ZAP's report pulls its own scripts; keep it away from Den's origin data.
2052
- "Content-Security-Policy": "sandbox allow-scripts",
2306
+ ...UNTRUSTED_FILE_HEADERS,
2053
2307
  });
2054
2308
  return res.end(body);
2055
2309
  } catch {
@@ -2097,6 +2351,21 @@ async function handleRequest(
2097
2351
  // ── API: stacks, edge, security plan (read-only; see readonly-routes.ts) ──
2098
2352
  if (await handleReadonlyRoutes(req, res, pathname, url)) return;
2099
2353
 
2354
+ // ── API: onboarding reads (ON-3; see onboard-routes.ts) ──────────────────
2355
+ if (
2356
+ await handleOnboardRoutes(
2357
+ req,
2358
+ res,
2359
+ pathname,
2360
+ url,
2361
+ (onboardDeps ??= defaultOnboardRouteDeps(
2362
+ (m) => validateManifest(m),
2363
+ ENGINE.loaded.version ?? engineVersion() ?? "unknown",
2364
+ )),
2365
+ )
2366
+ )
2367
+ return;
2368
+
2100
2369
  // ── API: test sources ─────────────────────────────────────────────────────
2101
2370
  // Returns the repos that contribute tests for a given pillar, with their
2102
2371
  // current branch + short SHA. Lets the Tests UI show "you are testing
@@ -4216,6 +4485,7 @@ async function handleRequest(
4216
4485
  res,
4217
4486
  pathname,
4218
4487
  guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
4488
+ { waitingAgents: waitingAgentIds },
4219
4489
  )
4220
4490
  )
4221
4491
  return;
@@ -209,3 +209,73 @@ export async function streamLifecycleExec(
209
209
  setTimeout(() => void tick(), pollMs);
210
210
  }
211
211
  }
212
+
213
+ /** What a collected run said: how it ended and the end of its output. */
214
+ export interface CollectedExec {
215
+ ok: boolean;
216
+ /** The command's exit code; null while a detached start still runs. */
217
+ exitCode: number | null;
218
+ /** Started and healthy (or, with no health check, still running). */
219
+ ready: boolean;
220
+ lines: string[];
221
+ error?: string;
222
+ }
223
+
224
+ /**
225
+ * Runs a lifecycle command as the route does, collecting its events
226
+ * instead of streaming them (ON-3's service_try): done at `ready`, at the
227
+ * launcher's exit (`service-exited`) or at `done`, whichever comes first,
228
+ * as service_up's stream stops. Then it lets go, as a closed stream does:
229
+ * a detached service keeps running; only the log tail stops.
230
+ */
231
+ export function collectLifecycleExec(
232
+ o: Omit<LifecycleExecOptions, "emit" | "end" | "onClientClose">,
233
+ deps: LifecycleExecDeps,
234
+ keep = 30,
235
+ ): Promise<CollectedExec> {
236
+ return new Promise((resolve) => {
237
+ const lines: string[] = [];
238
+ const errors: string[] = [];
239
+ let closer: (() => void) | null = null;
240
+ let settled = false;
241
+ const settle = (r: Omit<CollectedExec, "lines" | "error">): void => {
242
+ if (settled) return;
243
+ settled = true;
244
+ closer?.();
245
+ resolve({
246
+ ...r,
247
+ lines: lines.slice(-keep),
248
+ ...(errors.length ? { error: errors.join("\n") } : {}),
249
+ });
250
+ };
251
+ void streamLifecycleExec(
252
+ {
253
+ ...o,
254
+ emit: (e) => {
255
+ if (e.type === "line" && typeof e.text === "string")
256
+ lines.push(e.text);
257
+ else if (e.type === "error" && typeof e.text === "string")
258
+ errors.push(e.text);
259
+ else if (e.type === "ready")
260
+ settle({ ok: true, exitCode: null, ready: true });
261
+ else if (e.type === "service-exited") {
262
+ const code = Number(e.exitCode);
263
+ settle({ ok: code === 0, exitCode: code, ready: false });
264
+ } else if (e.type === "done") {
265
+ const code = Number(e.exitCode);
266
+ settle({ ok: code === 0, exitCode: code, ready: false });
267
+ }
268
+ if (lines.length > keep * 4) lines.splice(0, lines.length - keep);
269
+ },
270
+ end: () => undefined,
271
+ onClientClose: (fn) => {
272
+ closer = fn;
273
+ },
274
+ },
275
+ deps,
276
+ ).catch((e: unknown) => {
277
+ errors.push(String((e as Error)?.message ?? e));
278
+ settle({ ok: false, exitCode: 1, ready: false });
279
+ });
280
+ });
281
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * ON-3's read routes (GET only; reads in route-classes.ts):
3
+ *
4
+ * /api/onboard/status what's set up and the one next step
5
+ * (onboard-reads.ts onboardStatus)
6
+ * /api/onboard/repos ?path=<folder>[&max=<n>]: the git repos in a
7
+ * folder, how each runs, suggested workspaces
8
+ * /api/onboard/manifest ?path=<repo>[&name&run&down&health]: the side-car
9
+ * manifest `repo add` would write, validated, and
10
+ * nothing written
11
+ *
12
+ * The MCP tools onboard_status, repos_detect and manifest_draft read these,
13
+ * and a page can show them as they are. A path outside the person's home
14
+ * folder, or in Den's state folder, is a 400 with the reason.
15
+ */
16
+ import * as http from "http";
17
+ import { AUDIT_HEADERS } from "../../shared/agent-actor.js";
18
+ import { getStateDir } from "../../shared/paths.js";
19
+ import { readWebPort } from "../../shared/edge/config.js";
20
+ import { DEFAULT_PORT } from "../../shared/constants.js";
21
+ import { sendJson } from "./http-helpers.js";
22
+ import {
23
+ checkOnboardPath,
24
+ manifestDraft,
25
+ onboardStatus,
26
+ processPathRules,
27
+ reposDetect,
28
+ type OnboardStatus,
29
+ type PathRules,
30
+ type RegisteredRepo,
31
+ } from "../cli/onboard-reads.js";
32
+ import { defaultStatusDeps, registeredRepos } from "../cli/onboard-command.js";
33
+ import type { ManifestValidator } from "../cli/repo-command.js";
34
+ import { runnableCli } from "../cli/doctor-onboard.js";
35
+
36
+ export interface OnboardRouteDeps {
37
+ rules: () => PathRules;
38
+ validate: ManifestValidator;
39
+ registered: () => RegisteredRepo[];
40
+ bin: () => string;
41
+ status: (o: { viaMcp: boolean }) => Promise<OnboardStatus>;
42
+ }
43
+
44
+ /** The web server's own deps; `validate` comes from its entry (index.ts). */
45
+ export function defaultOnboardRouteDeps(
46
+ validate: ManifestValidator,
47
+ version: string,
48
+ ): OnboardRouteDeps {
49
+ return {
50
+ rules: () => processPathRules(getStateDir()),
51
+ validate,
52
+ registered: registeredRepos,
53
+ bin: runnableCli,
54
+ status: async ({ viaMcp }) => {
55
+ let port: number;
56
+ try {
57
+ port = readWebPort();
58
+ } catch {
59
+ port = DEFAULT_PORT;
60
+ }
61
+ // This server is the web server asked about: it is answering.
62
+ return onboardStatus(
63
+ await defaultStatusDeps({
64
+ webUrl: `http://localhost:${port}`,
65
+ probeWeb: async () => ({ kind: "up", version }),
66
+ viaMcp,
67
+ }),
68
+ );
69
+ },
70
+ };
71
+ }
72
+
73
+ const param = (url: URL, k: string): string | undefined =>
74
+ url.searchParams.get(k) ?? undefined;
75
+
76
+ /** Handles the routes above; false when `pathname` is not one of them. */
77
+ export async function handleOnboardRoutes(
78
+ req: http.IncomingMessage,
79
+ res: http.ServerResponse,
80
+ pathname: string,
81
+ url: URL,
82
+ deps: OnboardRouteDeps,
83
+ ): Promise<boolean> {
84
+ if (
85
+ pathname !== "/api/onboard/status" &&
86
+ pathname !== "/api/onboard/repos" &&
87
+ pathname !== "/api/onboard/manifest"
88
+ )
89
+ return false;
90
+ if (req.method !== "GET" && req.method !== "HEAD") {
91
+ res.writeHead(405, { Allow: "GET" });
92
+ res.end("read-only");
93
+ return true;
94
+ }
95
+ try {
96
+ if (pathname === "/api/onboard/status") {
97
+ const viaMcp = req.headers[AUDIT_HEADERS.actor] === "agent";
98
+ sendJson(res, await deps.status({ viaMcp }));
99
+ return true;
100
+ }
101
+ const rules = deps.rules();
102
+ const check = checkOnboardPath(param(url, "path"), rules);
103
+ if (!check.ok) {
104
+ sendJson(res, { ok: false, error: check.error }, 400);
105
+ return true;
106
+ }
107
+ if (pathname === "/api/onboard/repos") {
108
+ const max = Number(param(url, "max"));
109
+ sendJson(
110
+ res,
111
+ reposDetect(check.dir, rules, {
112
+ registered: deps.registered,
113
+ bin: deps.bin(),
114
+ ...(Number.isInteger(max) && max > 0 ? { maxRepos: max } : {}),
115
+ }),
116
+ );
117
+ return true;
118
+ }
119
+ sendJson(
120
+ res,
121
+ manifestDraft(check.dir, rules, deps.validate, {
122
+ name: param(url, "name"),
123
+ run: param(url, "run"),
124
+ down: param(url, "down"),
125
+ health: param(url, "health"),
126
+ }),
127
+ );
128
+ } catch (e) {
129
+ sendJson(res, { ok: false, error: (e as Error).message }, 500);
130
+ }
131
+ return true;
132
+ }