@monster-orange/den 0.0.0 → 0.2.0

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 (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +101 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +403 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +124 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +757 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The MCP server's client for the running web server's HTTP API. The MCP
3
+ * server holds no state of its own: agents and the web UI share the server's
4
+ * state and its guards. This client can reach only the routes below, and it
5
+ * sends the agent's audit headers (see shared/agent-actor.ts) on every call.
6
+ */
7
+ import { product } from "../../shared/product.js";
8
+ import { LIFECYCLE_CMDS, Refusal } from "./guards.js";
9
+
10
+ export type Query = Record<string, string | string[] | undefined>;
11
+
12
+ /** The web server isn't answering; the message names the start command. */
13
+ export class DenWebDown extends Error {
14
+ constructor(baseUrl: string, cli: string) {
15
+ super(
16
+ `The ${product().title} web server is not running at ${baseUrl}. ` +
17
+ `Start it with \`${cli} web up\` (the MCP server never starts it for you).`,
18
+ );
19
+ this.name = "DenWebDown";
20
+ }
21
+ }
22
+
23
+ /** The web server answered with an error status. */
24
+ export class DenHttpError extends Error {
25
+ constructor(
26
+ readonly method: string,
27
+ readonly path: string,
28
+ readonly status: number,
29
+ readonly body: string,
30
+ ) {
31
+ super(`${method} ${path} → ${status}: ${body}`);
32
+ this.name = "DenHttpError";
33
+ }
34
+ }
35
+
36
+ /** Read routes: GET only. */
37
+ const READ_ROUTES = new Set([
38
+ "/api/health",
39
+ "/api/services",
40
+ "/api/applications",
41
+ "/api/graph",
42
+ "/api/workspaces",
43
+ "/api/environments",
44
+ "/api/logs/search",
45
+ "/api/service/taillog",
46
+ "/api/service/logs",
47
+ "/api/modules",
48
+ "/api/test-sources",
49
+ "/api/security/latest",
50
+ "/api/security/plan",
51
+ "/api/release/status",
52
+ "/api/release/runs",
53
+ "/api/manifest/validate",
54
+ "/api/stacks",
55
+ "/api/edge",
56
+ "/api/server-info",
57
+ ]);
58
+
59
+ /** Action routes and their method. Anything else is never reached. */
60
+ const ACTION_ROUTES: Record<string, string> = {
61
+ "/api/service/exec": "GET",
62
+ "/api/run": "GET",
63
+ "/api/workspace/sync": "POST",
64
+ };
65
+
66
+ /**
67
+ * Throws a Refusal unless (method, path) is an allowed route. Never reached:
68
+ * sudo-password, aws/login, terminal/open, editor/open, workspace/exec,
69
+ * settings writes, workspace create/rename/mode, server-log, config, deploys.
70
+ * service/exec is allowed only for up/down/restart with env=local.
71
+ */
72
+ export function assertAllowedRequest(
73
+ method: string,
74
+ path: string,
75
+ query: Query,
76
+ ): void {
77
+ const ok =
78
+ (READ_ROUTES.has(path) &&
79
+ (method === "GET" ||
80
+ (method === "POST" && path === "/api/manifest/validate"))) ||
81
+ ACTION_ROUTES[path] === method;
82
+ if (!ok) throw new Refusal(`${method} ${path} is not exposed to agents.`);
83
+ if (path === "/api/service/exec") {
84
+ const cmd = String(query.cmd ?? "");
85
+ if (!(LIFECYCLE_CMDS as readonly string[]).includes(cmd))
86
+ throw new Refusal(
87
+ `service/exec "${cmd}" refused: only ${LIFECYCLE_CMDS.join(", ")}.`,
88
+ );
89
+ if (query.env !== "local")
90
+ throw new Refusal("service/exec refused: env=local only.");
91
+ }
92
+ }
93
+
94
+ export interface SseEvent {
95
+ type?: string;
96
+ text?: string;
97
+ exitCode?: number;
98
+ [k: string]: unknown;
99
+ }
100
+
101
+ export interface StreamOpts {
102
+ method?: "GET" | "POST";
103
+ /** Stop after this many output lines (a follow stream never ends itself). */
104
+ maxLines: number;
105
+ /** Stop after this long, whatever is happening. */
106
+ timeoutMs: number;
107
+ /** Stop once nothing has arrived for this long (for log tails). */
108
+ idleMs?: number;
109
+ /** Stop when this event arrives (e.g. a detached `up` handing off). */
110
+ stopOn?: (e: SseEvent) => boolean;
111
+ }
112
+
113
+ export interface StreamResult {
114
+ events: SseEvent[];
115
+ lines: string[];
116
+ ended: "done" | "closed" | "limit" | "timeout" | "idle" | "stopped";
117
+ exitCode?: number;
118
+ }
119
+
120
+ export interface DenApi {
121
+ baseUrl: string;
122
+ /** A JSON route. */
123
+ get(
124
+ path: string,
125
+ query?: Query,
126
+ method?: "GET" | "POST",
127
+ body?: unknown,
128
+ ): Promise<unknown>;
129
+ /** An SSE route, read to a bound; the connection is closed after. */
130
+ stream(path: string, query: Query, opts: StreamOpts): Promise<StreamResult>;
131
+ }
132
+
133
+ function buildUrl(base: string, path: string, query: Query = {}): string {
134
+ const u = new URL(path, base);
135
+ for (const [k, v] of Object.entries(query)) {
136
+ if (v === undefined) continue;
137
+ for (const one of Array.isArray(v) ? v : [v]) u.searchParams.append(k, one);
138
+ }
139
+ return u.toString();
140
+ }
141
+
142
+ function isConnectionError(e: unknown): boolean {
143
+ const err = e as { name?: string; cause?: { code?: string } };
144
+ if (err?.name === "AbortError") return false;
145
+ const code = err?.cause?.code ?? "";
146
+ return (
147
+ err?.name === "TypeError" ||
148
+ ["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "EHOSTUNREACH"].includes(code)
149
+ );
150
+ }
151
+
152
+ const OUTPUT_TYPES = new Set(["line", "cmd", "error"]);
153
+
154
+ export function createDenApi(opts: {
155
+ baseUrl: string;
156
+ cli: string;
157
+ headers: () => Record<string, string>;
158
+ }): DenApi {
159
+ const { baseUrl, cli } = opts;
160
+
161
+ async function request(
162
+ method: string,
163
+ path: string,
164
+ query: Query,
165
+ signal?: AbortSignal,
166
+ body?: unknown,
167
+ ): Promise<Response> {
168
+ assertAllowedRequest(method, path, query);
169
+ try {
170
+ const res = await fetch(buildUrl(baseUrl, path, query), {
171
+ method,
172
+ headers: {
173
+ ...opts.headers(),
174
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
175
+ },
176
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
177
+ signal,
178
+ });
179
+ if (!res.ok) {
180
+ const text = (await res.text()).slice(0, 2000);
181
+ throw new DenHttpError(method, path, res.status, text);
182
+ }
183
+ return res;
184
+ } catch (e) {
185
+ if (isConnectionError(e)) throw new DenWebDown(baseUrl, cli);
186
+ throw e;
187
+ }
188
+ }
189
+
190
+ return {
191
+ baseUrl,
192
+
193
+ async get(path, query = {}, method = "GET", body) {
194
+ const res = await request(method, path, query, undefined, body);
195
+ const text = await res.text();
196
+ try {
197
+ return JSON.parse(text) as unknown;
198
+ } catch {
199
+ return text;
200
+ }
201
+ },
202
+
203
+ async stream(path, query, s) {
204
+ const ac = new AbortController();
205
+ const res = await request(s.method ?? "GET", path, query, ac.signal);
206
+ const events: SseEvent[] = [];
207
+ const lines: string[] = [];
208
+ let exitCode: number | undefined;
209
+ let ended: StreamResult["ended"] = "closed";
210
+ let stop = false;
211
+
212
+ const finish = (why: StreamResult["ended"]): void => {
213
+ if (stop) return;
214
+ stop = true;
215
+ ended = why;
216
+ ac.abort();
217
+ };
218
+ const overall = setTimeout(() => finish("timeout"), s.timeoutMs);
219
+ let idle: ReturnType<typeof setTimeout> | undefined;
220
+ const bumpIdle = (): void => {
221
+ if (!s.idleMs) return;
222
+ if (idle) clearTimeout(idle);
223
+ idle = setTimeout(() => finish("idle"), s.idleMs);
224
+ };
225
+ bumpIdle();
226
+
227
+ const onEvent = (e: SseEvent): void => {
228
+ events.push(e);
229
+ if (OUTPUT_TYPES.has(String(e.type)) && typeof e.text === "string") {
230
+ for (const l of e.text.replace(/\n+$/, "").split("\n")) {
231
+ if (lines.length >= s.maxLines) return finish("limit");
232
+ lines.push(l);
233
+ }
234
+ if (lines.length >= s.maxLines) return finish("limit");
235
+ }
236
+ if (e.type === "done") {
237
+ if (typeof e.exitCode === "number") exitCode = e.exitCode;
238
+ return finish("done");
239
+ }
240
+ if (s.stopOn?.(e)) finish("stopped");
241
+ };
242
+
243
+ try {
244
+ const reader = res.body?.getReader();
245
+ const decoder = new TextDecoder();
246
+ let buf = "";
247
+ while (reader && !stop) {
248
+ const { value, done } = await reader.read();
249
+ if (done) break;
250
+ bumpIdle();
251
+ buf += decoder.decode(value, { stream: true });
252
+ let i: number;
253
+ while (!stop && (i = buf.indexOf("\n\n")) !== -1) {
254
+ const chunk = buf.slice(0, i);
255
+ buf = buf.slice(i + 2);
256
+ const data = chunk
257
+ .split("\n")
258
+ .filter((l) => l.startsWith("data:"))
259
+ .map((l) => l.slice(5).trimStart())
260
+ .join("\n");
261
+ if (!data) continue;
262
+ try {
263
+ onEvent(JSON.parse(data) as SseEvent);
264
+ } catch {
265
+ onEvent({ type: "line", text: data });
266
+ }
267
+ }
268
+ }
269
+ } catch (e) {
270
+ if (!stop) throw e;
271
+ } finally {
272
+ clearTimeout(overall);
273
+ if (idle) clearTimeout(idle);
274
+ if (!stop) ac.abort();
275
+ }
276
+ return {
277
+ events,
278
+ lines,
279
+ ended,
280
+ ...(exitCode !== undefined ? { exitCode } : {}),
281
+ };
282
+ },
283
+ };
284
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The MCP server's guards. Each one throws a Refusal (the tool then returns
3
+ * an error naming the rule) before anything reaches the web API. They sit on
4
+ * top of the web server's own checks, which still apply.
5
+ */
6
+ import type { Environment } from "../../shared/types/index.js";
7
+ import {
8
+ isProductionEnv,
9
+ type ResolvedMcpConfig,
10
+ } from "../../shared/den-config.js";
11
+ import {
12
+ placementRefusal,
13
+ type Placement,
14
+ } from "../../shared/manifest/placement.js";
15
+
16
+ /** A guard said no. The message says which rule and how to change it. */
17
+ export class Refusal extends Error {
18
+ constructor(message: string) {
19
+ super(message);
20
+ this.name = "Refusal";
21
+ }
22
+ }
23
+
24
+ /** The only manifest commands an agent may run. */
25
+ export const LIFECYCLE_CMDS = ["up", "down", "restart"] as const;
26
+ export type LifecycleCmd = (typeof LIFECYCLE_CMDS)[number];
27
+
28
+ export function assertLifecycleCmd(cmd: string): asserts cmd is LifecycleCmd {
29
+ if (!(LIFECYCLE_CMDS as readonly string[]).includes(cmd))
30
+ throw new Refusal(
31
+ `"${cmd}" is not a lifecycle command: agents may run only ${LIFECYCLE_CMDS.join(", ")}.`,
32
+ );
33
+ }
34
+
35
+ /** Service lifecycle actions run on this machine only. */
36
+ export function assertLocalEnv(env: string): void {
37
+ if (env !== "local")
38
+ throw new Refusal(
39
+ `service actions are local only (env "${env}" refused); nothing remote is ever started or stopped.`,
40
+ );
41
+ }
42
+
43
+ /** The fields of an /api/services entry the guards read. */
44
+ export interface ServiceEntryLike {
45
+ repo?: string;
46
+ service?: string;
47
+ commands: string[];
48
+ mode?: string;
49
+ /** R13: where the service runs (absent from an older web server). */
50
+ runs?: Placement["runs"];
51
+ target?: Placement["target"];
52
+ }
53
+
54
+ /**
55
+ * Whether Den runs this service on this machine: the served `runs` is
56
+ * "local" and it has a local `up` command. A web server that does not serve
57
+ * `runs` yet is judged by the `up` command alone.
58
+ */
59
+ export function runsLocally(entry: ServiceEntryLike): boolean {
60
+ if (entry.runs !== undefined && entry.runs !== "local") return false;
61
+ return entry.commands.includes("up");
62
+ }
63
+
64
+ export function assertRunsLocally(entry: ServiceEntryLike): void {
65
+ const address = `${entry.repo ?? "?"}/${entry.service ?? "?"}`;
66
+ if (entry.runs === "remote")
67
+ throw new Refusal(
68
+ placementRefusal(
69
+ address,
70
+ { runs: "remote", target: entry.target ?? null },
71
+ (e) => isProductionEnv(e),
72
+ ) ?? `${address} runs remotely; nothing to start here`,
73
+ );
74
+ if (!runsLocally(entry))
75
+ throw new Refusal(
76
+ `${address} is not run locally (its manifest has no local "up" command), so agents may not start, stop or restart it.`,
77
+ );
78
+ }
79
+
80
+ const PRODUCTION: readonly string[] = ["production", "prod"];
81
+
82
+ /**
83
+ * run_tests: production never; otherwise only the envs the org listed in
84
+ * `mcp.run_tests.envs` (default [local]). Security `full` (active attacks) is
85
+ * local only.
86
+ */
87
+ export function assertRunTestsAllowed(
88
+ args: { pillar: string; env: string; mode?: string },
89
+ cfg: ResolvedMcpConfig,
90
+ ): void {
91
+ if (PRODUCTION.includes(args.env))
92
+ throw new Refusal("run_tests never targets production.");
93
+ if (!cfg.runTestsEnvs.includes(args.env as Environment))
94
+ throw new Refusal(
95
+ `env "${args.env}" is not in mcp.run_tests.envs (allowed: ${cfg.runTestsEnvs.join(", ")}).`,
96
+ );
97
+ if (
98
+ args.pillar === "security" &&
99
+ args.mode === "full" &&
100
+ args.env !== "local"
101
+ )
102
+ throw new Refusal(
103
+ `security mode "full" (active attacks) is local only (env "${args.env}" refused).`,
104
+ );
105
+ }
106
+
107
+ /** The security CLI's dry-run verdict, from /api/security/plan. */
108
+ export interface SecurityPlanLike {
109
+ ok: boolean;
110
+ errors?: string[];
111
+ }
112
+
113
+ /** A security run must pass the CLI's own plan: owned hosts, mode ceiling. */
114
+ export function assertSecurityPlanOk(plan: SecurityPlanLike): void {
115
+ if (!plan.ok)
116
+ throw new Refusal(
117
+ `the security plan refused this run: ${(plan.errors ?? []).join(" ") || "no reason given"}`,
118
+ );
119
+ }
120
+
121
+ /** workspace_sync <repo>: the repo must be a managed clone in the workspace. */
122
+ export function assertSyncableRepo(
123
+ repo: string,
124
+ services: readonly ServiceEntryLike[],
125
+ ): void {
126
+ const entries = services.filter((s) => s.repo === repo);
127
+ if (entries.length === 0)
128
+ throw new Refusal(`no repo named "${repo}" in the workspace.`);
129
+ if (!entries.some((s) => s.mode === "managed"))
130
+ throw new Refusal(
131
+ `${repo} is dev-managed (your own checkout): sync only updates managed clones, never a dev-managed one.`,
132
+ );
133
+ }
134
+
135
+ /** Log tools read local sources unless the org set `mcp.logs.remote: true`. */
136
+ export function assertLogsEnv(env: string, cfg: ResolvedMcpConfig): void {
137
+ if (env !== "local" && !cfg.logsRemote)
138
+ throw new Refusal(
139
+ `remote log sources (env "${env}") are off: set mcp.logs.remote: true to allow them.`,
140
+ );
141
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `<cli> mcp`: an MCP server over stdio, a thin client of the running web
3
+ * server (http://localhost:<web.port>). Agents and the web UI share one
4
+ * state and one set of guards; nothing is started implicitly.
5
+ *
6
+ * stdout carries the protocol only: diagnostics go to stderr.
7
+ */
8
+ import { randomUUID } from "crypto";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { agentAuditHeaders } from "../../shared/agent-actor.js";
12
+ import {
13
+ readMcpConfig,
14
+ type ResolvedMcpConfig,
15
+ } from "../../shared/den-config.js";
16
+ import { readWebPort } from "../../shared/edge/config.js";
17
+ import { engineVersion } from "../../shared/paths.js";
18
+ import { product } from "../../shared/product.js";
19
+ import { DEFAULT_PORT } from "../../shared/constants.js";
20
+ import { createDenApi, type DenApi } from "./api.js";
21
+ import { callTool, toolsFor, type ToolContext } from "./tools.js";
22
+
23
+ export interface DenMcpOptions {
24
+ cfg: ResolvedMcpConfig;
25
+ baseUrl: string;
26
+ cli: string;
27
+ /** One id per MCP server process, recorded with every action. */
28
+ session?: string;
29
+ pid?: number;
30
+ /** For tests: an API in place of the HTTP client. */
31
+ api?: DenApi;
32
+ log?: (line: string) => void;
33
+ }
34
+
35
+ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
36
+ const p = product();
37
+ const session = opts.session ?? randomUUID();
38
+ const log = opts.log ?? ((l: string) => process.stderr.write(`${l}\n`));
39
+ const tools = toolsFor(opts.cfg);
40
+ const server = new McpServer(
41
+ { name: p.name, version: engineVersion() ?? "0.0.0" },
42
+ {
43
+ instructions:
44
+ `${p.title}: local services, workspaces, logs and tests for this org, ` +
45
+ `through the running ${p.title} web server (\`${opts.cli} web up\`). ` +
46
+ (opts.cfg.actions.length
47
+ ? `Actions enabled: ${opts.cfg.actions.join(", ")}.`
48
+ : "Read-only: the org has enabled no actions (mcp.actions)."),
49
+ },
50
+ );
51
+
52
+ // The client's self-reported name/version, known once it has initialized.
53
+ const client = () => server.server.getClientVersion();
54
+ const api =
55
+ opts.api ??
56
+ createDenApi({
57
+ baseUrl: opts.baseUrl,
58
+ cli: opts.cli,
59
+ headers: () =>
60
+ agentAuditHeaders({
61
+ client: client()?.name ?? "unknown",
62
+ clientVersion: client()?.version ?? "unknown",
63
+ mcpPid: opts.pid ?? process.pid,
64
+ session,
65
+ }),
66
+ });
67
+ const ctx: ToolContext = { api, cfg: opts.cfg, cli: opts.cli };
68
+
69
+ for (const t of tools) {
70
+ server.registerTool(
71
+ t.name,
72
+ {
73
+ title: t.title,
74
+ description: t.description,
75
+ inputSchema: t.shape,
76
+ annotations: {
77
+ readOnlyHint: !t.action,
78
+ destructiveHint: t.action,
79
+ openWorldHint: false,
80
+ },
81
+ },
82
+ async (args: Record<string, unknown>) => {
83
+ const r = await callTool(t.name, args ?? {}, ctx);
84
+ if (t.action || r.isError) {
85
+ const who = client();
86
+ log(
87
+ `[mcp] ${t.name} ${r.isError ? "refused/failed" : "ok"} · ` +
88
+ `${who?.name ?? "unknown"} ${who?.version ?? ""} · session ${session}` +
89
+ (r.isError ? ` · ${r.text.split("\n")[0]}` : ""),
90
+ );
91
+ }
92
+ return {
93
+ content: [{ type: "text" as const, text: r.text }],
94
+ isError: r.isError,
95
+ };
96
+ },
97
+ );
98
+ }
99
+ return server;
100
+ }
101
+
102
+ function parsePort(argv: string[]): number | undefined {
103
+ const i = argv.indexOf("--port");
104
+ const raw =
105
+ i !== -1
106
+ ? argv[i + 1]
107
+ : argv.find((a) => a.startsWith("--port="))?.slice(7);
108
+ if (raw === undefined) return undefined;
109
+ const n = Number(raw);
110
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
111
+ throw new Error(`--port must be a port number, got "${raw}"`);
112
+ return n;
113
+ }
114
+
115
+ export const MCP_USAGE = (cli: string): string =>
116
+ [
117
+ `Usage: ${cli} mcp [--port <web port>]`,
118
+ "",
119
+ `An MCP server over stdio for agents, a client of the running web server`,
120
+ `(\`${cli} web up\`; default port: config web.port). Read tools always;`,
121
+ "actions only as named in the org config's mcp.actions. See docs/distributions.md.",
122
+ ].join("\n");
123
+
124
+ /** Run `<cli> mcp` until stdin closes. */
125
+ export async function runMcp(argv: string[]): Promise<void> {
126
+ const cli = product().cli;
127
+ if (argv.includes("-h") || argv.includes("--help")) {
128
+ process.stderr.write(`${MCP_USAGE(cli)}\n`);
129
+ return;
130
+ }
131
+ let port = parsePort(argv);
132
+ if (port === undefined) {
133
+ try {
134
+ port = readWebPort();
135
+ } catch {
136
+ port = DEFAULT_PORT;
137
+ }
138
+ }
139
+ const cfg = readMcpConfig();
140
+ const baseUrl = `http://localhost:${port}`;
141
+ const server = createDenMcpServer({ cfg, baseUrl, cli });
142
+ await server.connect(new StdioServerTransport());
143
+ process.stderr.write(
144
+ `${cli} mcp: ${toolsFor(cfg).length} tools ` +
145
+ `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}\n`,
146
+ );
147
+ }