@monster-orange/den 0.4.14 → 0.4.16

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 +194 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +226 -19
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +155 -8
  7. package/src/shared/agents/den-claude.ts +25 -3
  8. package/src/shared/agents/records.ts +37 -0
  9. package/src/shared/agents/runtime.ts +11 -1
  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 +12 -0
  13. package/src/shared/environment-manager/workspaces.ts +20 -0
  14. package/src/shared/github-app.ts +7 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +372 -23
  17. package/src/ui/cli/agents-command.ts +71 -4
  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 +14 -2
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +368 -1
  29. package/src/ui/server/agent-routes.ts +91 -7
  30. package/src/ui/server/approval-routes.ts +390 -0
  31. package/src/ui/server/approvals.ts +416 -0
  32. package/src/ui/server/github-routes.ts +13 -5
  33. package/src/ui/server/held-actions.ts +203 -0
  34. package/src/ui/server/http-helpers.ts +35 -0
  35. package/src/ui/server/index.ts +308 -21
  36. package/src/ui/server/lifecycle-exec.ts +70 -0
  37. package/src/ui/server/onboard-routes.ts +132 -0
  38. package/src/ui/server/pair-routes.ts +7 -4
  39. package/src/ui/server/public/agents-page.js +378 -30
  40. package/src/ui/server/public/agents.html +1 -0
  41. package/src/ui/server/public/error-page.js +97 -18
  42. package/src/ui/server/public/error.html +18 -0
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +65 -5
  45. package/src/ui/server/public/i18n/ja.js +67 -5
  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 +32 -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 +348 -5
  53. package/src/ui/server/request-guard.ts +2 -0
  54. package/src/ui/server/route-classes.ts +34 -1
  55. package/src/ui/server/session-routes.ts +14 -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,14 +233,18 @@ 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,
235
245
  githubEndpoints,
236
246
  noClientIdMessage,
247
+ GITHUB_SIGNIN_DOCS,
237
248
  } from "../../shared/github-app.js";
238
249
  import { githubDeviceClient } from "../../shared/github-device.js";
239
250
  import { readWebOwner } from "../../shared/web-owner.js";
@@ -253,7 +264,9 @@ import {
253
264
  machineLabel,
254
265
  parseMachineName,
255
266
  sshCommand,
267
+ sshLogin,
256
268
  sshTarget,
269
+ sshUser,
257
270
  } from "../../shared/cli-command.js";
258
271
  import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
259
272
  import { agentsLayout, patchAgentsLayout } from "./agents-layout-pref.js";
@@ -612,6 +625,9 @@ setHtmlFill((html) => {
612
625
 
613
626
  // ── MD-2: the engine this server loaded, against the one on disk ─────────────
614
627
  const ENGINE = engineWatch(getEngineRoot);
628
+
629
+ /** The onboarding routes' deps, made on first use. */
630
+ let onboardDeps: OnboardRouteDeps | null = null;
615
631
  function engineCheck(): EngineCheck {
616
632
  return ENGINE.check();
617
633
  }
@@ -789,6 +805,8 @@ import {
789
805
  readBody,
790
806
  sendJson,
791
807
  serveFile,
808
+ serveUntrustedFile,
809
+ UNTRUSTED_FILE_HEADERS,
792
810
  send400,
793
811
  send404,
794
812
  setHtmlFill,
@@ -824,7 +842,19 @@ import { parseChangelog } from "./changelog-parser.js";
824
842
  // ── Extracted services ───────────────────────────────────────────────────────
825
843
  import { GitHubService } from "./github-service.js";
826
844
  import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
827
- import { handleWorkspaceRoutes } from "./workspace-routes.js";
845
+ import {
846
+ handleWorkspaceRoutes,
847
+ workspaceCreateRefusal,
848
+ } from "./workspace-routes.js";
849
+ import {
850
+ createApprovalQueue,
851
+ handleApprovalRoutes,
852
+ isApprovalPath,
853
+ type HeldActions,
854
+ } from "./approval-routes.js";
855
+ import { heldActions } from "./held-actions.js";
856
+ import type { ApprovalQueue } from "./approvals.js";
857
+ import { processPathRules } from "../cli/onboard-reads.js";
828
858
  import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
829
859
  import {
830
860
  handleSessionList,
@@ -833,7 +863,11 @@ import {
833
863
  sendSessionHttp,
834
864
  } from "./session-routes.js";
835
865
  import { refuseUpgrade } from "./websocket.js";
836
- import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
866
+ import {
867
+ collectLifecycleExec,
868
+ isLifecycleExecCmd,
869
+ streamLifecycleExec,
870
+ } from "./lifecycle-exec.js";
837
871
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
838
872
  import {
839
873
  edgeHttps,
@@ -1219,6 +1253,26 @@ function loggedPath(pathname: string): string {
1219
1253
  );
1220
1254
  }
1221
1255
 
1256
+ /**
1257
+ * The account the signed-out page's ssh link logs in as (SEC-2a-UX):
1258
+ * `web.ssh_user` (read now), else the account Den runs as; "" for none.
1259
+ */
1260
+ function sshUserNow(): string {
1261
+ let set: string | undefined;
1262
+ try {
1263
+ set = readWebAuthConfig(getDistRoot(), product().configFile).sshUser;
1264
+ } catch {
1265
+ set = WEB_AUTH.sshUser;
1266
+ }
1267
+ let osUser = "";
1268
+ try {
1269
+ osUser = os.userInfo().username;
1270
+ } catch {
1271
+ /* no account name: none on the page */
1272
+ }
1273
+ return sshUser(set, osUser);
1274
+ }
1275
+
1222
1276
  /** The ssh target for this request: the Host it used, minus the port. */
1223
1277
  function sshTargetOf(req: http.IncomingMessage): string {
1224
1278
  return sshTarget(req.headers.host, os.hostname());
@@ -1245,22 +1299,28 @@ function closeSessionSockets(keys: Iterable<string>): void {
1245
1299
  }
1246
1300
  }
1247
1301
 
1248
- /** A new session for this request and its Set-Cookie line (GitHub or a link). */
1302
+ /**
1303
+ * A new session for this request: its Set-Cookie line, and its approval
1304
+ * key (web-session.ts), which only this sign-in's own answer may carry.
1305
+ */
1249
1306
  function startSession(
1250
1307
  req: http.IncomingMessage,
1251
- via: "github" | "link" | "code",
1308
+ via: "github" | "link" | "code" | "claim",
1252
1309
  login?: string,
1253
- ): string {
1310
+ ): { cookie: string; approveKey: string } {
1254
1311
  const g = checkRequest(req, GUARD, product().cli, product().title);
1255
1312
  const named = g.ok ? g.via : null;
1256
- const { id } = webSessions.create({
1313
+ const { id, approveKey } = webSessions.create({
1257
1314
  userAgent: req.headers["user-agent"],
1258
1315
  host: req.headers.host,
1259
1316
  via,
1260
1317
  ...(g.ok && g.client ? { client: g.client } : {}),
1261
1318
  ...(login ? { login } : {}),
1262
1319
  });
1263
- return sessionSetCookie(id, requestIsHttps(req, named));
1320
+ return {
1321
+ cookie: sessionSetCookie(id, requestIsHttps(req, named)),
1322
+ approveKey,
1323
+ };
1264
1324
  }
1265
1325
 
1266
1326
  /** The edge hostnames that route here, and their URLs (Pair a device). */
@@ -1272,10 +1332,19 @@ function denEdgeRoutes(): { host: string; url: string }[] {
1272
1332
  }
1273
1333
  }
1274
1334
 
1275
- /** What the signed-out page says about Sign in with GitHub. */
1276
- function githubSigninState(): "ready" | "no_client_id" | "no_owner" {
1277
- if (!GITHUB_CLIENT_ID) return "no_client_id";
1278
- return readWebOwner(getStateDir()) ? "ready" : "no_owner";
1335
+ /**
1336
+ * What the signed-out page says about Sign in with GitHub: its state, and
1337
+ * (SEC-2a-UX) the owner's login once one is linked, for step 1's "Owner:
1338
+ * @login".
1339
+ */
1340
+ function githubSignin():
1341
+ | { state: "no_client_id" | "no_owner" }
1342
+ | { state: "ready"; ownerLogin: string } {
1343
+ if (!GITHUB_CLIENT_ID) return { state: "no_client_id" };
1344
+ const owner = readWebOwner(getStateDir());
1345
+ return owner
1346
+ ? { state: "ready", ownerLogin: owner.login }
1347
+ : { state: "no_owner" };
1279
1348
  }
1280
1349
 
1281
1350
  /**
@@ -1366,6 +1435,8 @@ function sendSignedOut(
1366
1435
  toLocal || host === `localhost:${PORT}` ? [] : ["--origin", origin];
1367
1436
  const cmd = cliCommand(bin, "web", "login", ...loginArgs);
1368
1437
  const ssh = sshTargetOf(req);
1438
+ const user = sshUserNow();
1439
+ const gh = githubSignin();
1369
1440
  res.setHeader("Cache-Control", "no-store");
1370
1441
  sendErrorPage(req, res, {
1371
1442
  status: 401,
@@ -1381,14 +1452,18 @@ function sendSignedOut(
1381
1452
  machine: machineName(),
1382
1453
  ssh: {
1383
1454
  target: ssh,
1455
+ // SEC-2a-UX: the account to log in as ("" for none).
1456
+ user,
1384
1457
  // Open Terminal (ssh://) only for a name, never an IP literal.
1385
1458
  name: !isIpLiteral(ssh),
1386
- login: sshCommand(ssh, cmd),
1459
+ login: sshCommand(sshLogin(ssh, user), cmd),
1387
1460
  },
1388
1461
  // SEC-2a: Sign in with GitHub, and why it can't be offered yet.
1389
1462
  github: {
1390
- state: githubSigninState(),
1463
+ state: gh.state,
1391
1464
  owner: cliCommand(bin, "web", "owner", "link"),
1465
+ ...(gh.state === "ready" ? { ownerLogin: gh.ownerLogin } : {}),
1466
+ learnMore: GITHUB_SIGNIN_DOCS,
1392
1467
  ...(GITHUB_CLIENT_ID
1393
1468
  ? {}
1394
1469
  : { message: noClientIdMessage(product().cli) }),
@@ -1475,6 +1550,8 @@ async function handleAuth(
1475
1550
  url: URL,
1476
1551
  requestId: string,
1477
1552
  ): Promise<void> {
1553
+ if (url.searchParams.has("claim"))
1554
+ return handleClaim(req, res, url, requestId);
1478
1555
  const secret = webSecret.current();
1479
1556
  const c = url.searchParams.get("c") ?? "";
1480
1557
  const nonce = loginCodeNonce(c);
@@ -1566,25 +1643,74 @@ async function handleAuth(
1566
1643
  device: deviceName(req.headers["user-agent"]),
1567
1644
  ...(g.ok && g.client ? { client: g.client } : {}),
1568
1645
  });
1569
- res.setHeader("Set-Cookie", startSession(req, "link"));
1646
+ const s = startSession(req, "link");
1647
+ res.setHeader("Set-Cookie", s.cookie);
1648
+ res.setHeader("Cache-Control", "no-store");
1570
1649
  return sendJson(res, {
1571
1650
  ok: true,
1572
1651
  next: safeNext(url.searchParams.get("next")),
1652
+ approveKey: s.approveKey,
1573
1653
  });
1574
1654
  }
1575
1655
  // A link nobody waits on (`web login --no-wait`, or minted with no
1576
1656
  // server running): it signs in when opened, once, as before.
1577
1657
  if (post || !webSessions.consumeCode(check.nonce, check.expiresAt))
1578
1658
  return gone();
1659
+ const s = startSession(req, "link");
1579
1660
  res.writeHead(303, {
1580
- Location: safeNext(url.searchParams.get("next")),
1581
- "Set-Cookie": startSession(req, "link"),
1661
+ // The approval key in the fragment: it never reaches a server.
1662
+ Location: withApproveKey(
1663
+ safeNext(url.searchParams.get("next")),
1664
+ s.approveKey,
1665
+ ),
1666
+ "Set-Cookie": s.cookie,
1582
1667
  "Cache-Control": "no-store",
1583
1668
  "Referrer-Policy": "no-referrer",
1584
1669
  });
1585
1670
  res.end();
1586
1671
  }
1587
1672
 
1673
+ /**
1674
+ * `GET /auth?claim=<code>` (0.4.15): the setup link `<cli> web claim-link`
1675
+ * prints at the end of an install. A valid, unused claim code (30 min,
1676
+ * single use across restarts) becomes a session ("claimed with a setup
1677
+ * link" in Linked devices), then a 303 to `/` with the session's approval
1678
+ * key in the fragment and no claim in the address, no referrer. Anything
1679
+ * else: the signed-out page, which says the link was used and never
1680
+ * echoes the code. An agent's token never gets here (AGENT_FORBIDDEN).
1681
+ */
1682
+ const CLAIM_NONCES = new ClaimNonces(getStateDir());
1683
+ function handleClaim(
1684
+ req: http.IncomingMessage,
1685
+ res: http.ServerResponse,
1686
+ url: URL,
1687
+ requestId: string,
1688
+ ): void {
1689
+ res.setHeader("Referrer-Policy", "no-referrer");
1690
+ res.setHeader("Cache-Control", "no-store");
1691
+ const secret = webSecret.current();
1692
+ const check =
1693
+ req.method !== "POST" && secret
1694
+ ? checkClaimCode(url.searchParams.get("claim") ?? "", secret)
1695
+ : null;
1696
+ if (!check?.ok || !CLAIM_NONCES.consume(check.nonce, check.expiresAt))
1697
+ return sendSignedOut(req, res, {
1698
+ pathname: "/auth",
1699
+ search: "",
1700
+ scope: "den",
1701
+ expired: true,
1702
+ requestId,
1703
+ 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.`,
1704
+ });
1705
+ const s = startSession(req, "claim");
1706
+ res.writeHead(303, {
1707
+ // The approval key in the fragment: it never reaches a server.
1708
+ Location: withApproveKey("/", s.approveKey),
1709
+ "Set-Cookie": s.cookie,
1710
+ });
1711
+ res.end();
1712
+ }
1713
+
1588
1714
  /** `web login`'s wait (login-waits.ts), by the CLI's id. */
1589
1715
  const LOGIN_WAIT_PATH = /^\/api\/web\/logins\/([A-Za-z0-9_-]{16,64})$/;
1590
1716
  const LOGIN_WAITS = new LoginWaits();
@@ -1606,6 +1732,109 @@ function declaredCommand<T>(
1606
1732
  return own(forEnv, cmd) ? forEnv![cmd] : undefined;
1607
1733
  }
1608
1734
 
1735
+ // ── ON-3: the writes held for the person (approval-routes.ts) ─────────────────
1736
+ let approvals: {
1737
+ queue: ApprovalQueue;
1738
+ actions: HeldActions;
1739
+ enabled: () => boolean;
1740
+ } | null = null;
1741
+ /** The held writes and their queue, made on first use. */
1742
+ function approvalDeps(): {
1743
+ queue: ApprovalQueue;
1744
+ actions: HeldActions;
1745
+ enabled: () => boolean;
1746
+ } {
1747
+ if (approvals) return approvals;
1748
+ const actions = heldActions({
1749
+ rules: () => processPathRules(getStateDir()),
1750
+ validate: (m) => validateManifest(m),
1751
+ workspaceCreateRefusal,
1752
+ createWorkspace: (name) => createWorkspace(name, { services: {} }),
1753
+ workspaceExists: (name) =>
1754
+ Object.prototype.hasOwnProperty.call(
1755
+ readWorkspacesFile().workspaces ?? {},
1756
+ name,
1757
+ ),
1758
+ defaultWorkspace: () => getFocusedWorkspaceName() ?? "default",
1759
+ // The exec route's checks for a local `up` (R13 placement, HITL).
1760
+ serviceTarget: (service, ws) => {
1761
+ if (ws && !isWorkspaceActive(ws))
1762
+ return {
1763
+ ok: false,
1764
+ error: `Workspace "${ws}" is not active — activate it first`,
1765
+ };
1766
+ const resolved = resolveService(service, ws);
1767
+ if (!resolved)
1768
+ return {
1769
+ ok: false,
1770
+ error: `Den doesn't know a service "${service}"${ws ? ` in workspace "${ws}"` : ""}; the services tool lists what it runs`,
1771
+ };
1772
+ const manifest = readManifest(resolved.repoPath);
1773
+ const refusal = placementRefusal(
1774
+ resolved.address,
1775
+ servicePlacement(manifest, resolved.subsystem, "local"),
1776
+ (e) => isProductionEnv(e),
1777
+ "up",
1778
+ );
1779
+ if (refusal) return { ok: false, error: refusal };
1780
+ const def = manifest
1781
+ ? declaredCommand(
1782
+ manifest.services?.[resolved.subsystem]?.commands,
1783
+ "local",
1784
+ "up",
1785
+ )
1786
+ : undefined;
1787
+ if (def && isInteractiveServiceCommand(def))
1788
+ return {
1789
+ ok: false,
1790
+ error: `"up" for ${resolved.address} needs an interactive terminal: the person runs \`${product().cli} up ${resolved.address}\``,
1791
+ };
1792
+ return { ok: true, address: resolved.address };
1793
+ },
1794
+ // `up` as /api/service/exec runs it, collected instead of streamed.
1795
+ tryService: async (address, ws) => {
1796
+ const resolved = resolveService(address, ws);
1797
+ if (!resolved)
1798
+ return {
1799
+ ok: false,
1800
+ exitCode: null,
1801
+ ready: false,
1802
+ lines: [`Den no longer knows a service "${address}"`],
1803
+ };
1804
+ prewarmSudo();
1805
+ return collectLifecycleExec(
1806
+ {
1807
+ resolved,
1808
+ cmd: "up",
1809
+ workspace: ws,
1810
+ env: { E2E_ENVIRONMENT: "local", ...getSudoEnv() },
1811
+ },
1812
+ {
1813
+ run: runServiceCommand,
1814
+ health: (r) => checkServiceHealth(r, "local"),
1815
+ secretEnv: (a, c, w) => prepareServiceSecretEnv(a, c, w),
1816
+ },
1817
+ );
1818
+ },
1819
+ });
1820
+ approvals = {
1821
+ actions,
1822
+ queue: createApprovalQueue(actions, () => getStateDir()),
1823
+ // Read per ask: a config edit takes effect without a restart. A bad
1824
+ // value throws, and the handler holds nothing then.
1825
+ enabled: () => readMcpConfig().approvals,
1826
+ };
1827
+ return approvals;
1828
+ }
1829
+ /** The agent ids with a held write waiting (their rows say "waiting"). */
1830
+ function waitingAgentIds(): Set<string> {
1831
+ const out = new Set<string>();
1832
+ if (!approvals) return out;
1833
+ for (const actor of approvals.queue.waitingActors())
1834
+ if (actor.startsWith("agent:")) out.add(actor.slice("agent:".length));
1835
+ return out;
1836
+ }
1837
+
1609
1838
  /** A test module's name as a run takes it (SEC-1 §6b.2): never a path out. */
1610
1839
  const SAFE_MODULE_RE = /^[A-Za-z0-9_@][A-Za-z0-9._@/-]{0,127}$/;
1611
1840
 
@@ -1742,6 +1971,40 @@ async function handleRequest(
1742
1971
  // is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
1743
1972
  if (pathname.startsWith("/api/sessions/"))
1744
1973
  return sendSessionHttp(req, res, pathname);
1974
+ // ON-3: an agent's held writes; Allow from the person's browser only.
1975
+ if (
1976
+ guard.ok &&
1977
+ isApprovalPath(pathname) &&
1978
+ (await handleApprovalRoutes(req, res, pathname, guard, approvalDeps()))
1979
+ )
1980
+ return;
1981
+ // 0.4.15: what lets another browser in needs the person, not just
1982
+ // loopback (approvals.ts: an agent with a shell is on loopback too).
1983
+ if (req.method === "POST" && PERSON_GRANT_ROUTES.includes(pathname)) {
1984
+ const g = guard.ok ? guard : null;
1985
+ const ok = g ? personGrantAllowed(req, g, printedCli()) : null;
1986
+ if (!ok?.ok) {
1987
+ recordAction(
1988
+ auditActorOf(req),
1989
+ {
1990
+ verb: "person-grant",
1991
+ detail: pathname,
1992
+ outcome: "refused" as const,
1993
+ reason: "signin_to_approve",
1994
+ },
1995
+ principalOf(req),
1996
+ );
1997
+ return sendJson(
1998
+ res,
1999
+ {
2000
+ ok: false,
2001
+ code: "signin_to_approve",
2002
+ error: ok && !ok.ok ? ok.error : "Sign in first.",
2003
+ },
2004
+ 403,
2005
+ );
2006
+ }
2007
+ }
1745
2008
  // SEC-2a: Sign in with GitHub, the owner's link, Pair a device. Before
1746
2009
  // config validation, like the sign-in above.
1747
2010
  if (
@@ -1784,7 +2047,10 @@ async function handleRequest(
1784
2047
  },
1785
2048
  commands: (r, code) => {
1786
2049
  const approve = cliCommand(printedCli(), "web", "approve", code);
1787
- return { approve, ssh: sshCommand(sshTargetOf(r), approve) };
2050
+ return {
2051
+ approve,
2052
+ ssh: sshCommand(sshLogin(sshTargetOf(r), sshUserNow()), approve),
2053
+ };
1788
2054
  },
1789
2055
  audit: (r, verb, detail, refused) =>
1790
2056
  recordAction(
@@ -1924,7 +2190,9 @@ async function handleRequest(
1924
2190
  }
1925
2191
 
1926
2192
  // ── Playwright report ──────────────────────────────────────────────────────
1927
- if (pathname.startsWith("/report")) {
2193
+ // A test run wrote it (an agent may have): sandboxed, never Den's origin.
2194
+ // `/report/…` only: `/reports/security/…` is ZAP's, below.
2195
+ if (pathname === "/report" || pathname.startsWith("/report/")) {
1928
2196
  const relPath = pathname.slice("/report".length) || "/index.html";
1929
2197
  const filePath = path.resolve(path.join(REPORT_DIR, relPath));
1930
2198
 
@@ -1945,7 +2213,7 @@ async function handleRequest(
1945
2213
  ? path.join(filePath, "index.html")
1946
2214
  : filePath;
1947
2215
 
1948
- return serveFile(res, target);
2216
+ return serveUntrustedFile(res, target);
1949
2217
  }
1950
2218
 
1951
2219
  // ── API: environments ──────────────────────────────────────────────────────
@@ -2047,9 +2315,8 @@ async function handleRequest(
2047
2315
  "Content-Type": file.endsWith(".html")
2048
2316
  ? "text/html; charset=utf-8"
2049
2317
  : "application/json",
2050
- "Cache-Control": "no-store",
2051
2318
  // ZAP's report pulls its own scripts; keep it away from Den's origin data.
2052
- "Content-Security-Policy": "sandbox allow-scripts",
2319
+ ...UNTRUSTED_FILE_HEADERS,
2053
2320
  });
2054
2321
  return res.end(body);
2055
2322
  } catch {
@@ -2097,6 +2364,21 @@ async function handleRequest(
2097
2364
  // ── API: stacks, edge, security plan (read-only; see readonly-routes.ts) ──
2098
2365
  if (await handleReadonlyRoutes(req, res, pathname, url)) return;
2099
2366
 
2367
+ // ── API: onboarding reads (ON-3; see onboard-routes.ts) ──────────────────
2368
+ if (
2369
+ await handleOnboardRoutes(
2370
+ req,
2371
+ res,
2372
+ pathname,
2373
+ url,
2374
+ (onboardDeps ??= defaultOnboardRouteDeps(
2375
+ (m) => validateManifest(m),
2376
+ ENGINE.loaded.version ?? engineVersion() ?? "unknown",
2377
+ )),
2378
+ )
2379
+ )
2380
+ return;
2381
+
2100
2382
  // ── API: test sources ─────────────────────────────────────────────────────
2101
2383
  // Returns the repos that contribute tests for a given pillar, with their
2102
2384
  // current branch + short SHA. Lets the Tests UI show "you are testing
@@ -3804,6 +4086,10 @@ async function handleRequest(
3804
4086
  actor: null,
3805
4087
  signedIn: false,
3806
4088
  ...(g && !g.beyond ? { identity: { kind: "local" } } : {}),
4089
+ // SEC-2a-UX: the signed-out page watches this, so Sign in with
4090
+ // GitHub appears once an owner is linked, with no reload. The
4091
+ // owner's login is what that page shows anyway.
4092
+ ...(g?.beyond || !g ? { github: githubSignin() } : {}),
3807
4093
  });
3808
4094
  }
3809
4095
  // Sign out: this browser's session ends and its cookie is cleared.
@@ -4216,6 +4502,7 @@ async function handleRequest(
4216
4502
  res,
4217
4503
  pathname,
4218
4504
  guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
4505
+ { waitingAgents: waitingAgentIds },
4219
4506
  )
4220
4507
  )
4221
4508
  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
+ }