@monster-orange/den 0.4.2 → 0.4.4

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 (64) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +313 -50
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +53 -12
  8. package/src/shared/agents/generic.ts +1 -0
  9. package/src/shared/agents/runtime-search.ts +543 -0
  10. package/src/shared/agents/runtime.ts +43 -1
  11. package/src/shared/agents/runtimes.ts +53 -4
  12. package/src/shared/auth/sudo-vault.ts +16 -0
  13. package/src/shared/den-config.ts +92 -2
  14. package/src/shared/edge/render.ts +31 -0
  15. package/src/shared/edge/status.ts +134 -0
  16. package/src/shared/environment-manager/health.ts +82 -8
  17. package/src/shared/environment-manager/workspaces.ts +54 -0
  18. package/src/shared/health/not-checked.ts +42 -0
  19. package/src/shared/health/rollup.ts +4 -0
  20. package/src/shared/process/spawn-streaming.ts +8 -3
  21. package/src/shared/sessions/client.ts +5 -1
  22. package/src/shared/sessions/host.ts +29 -1
  23. package/src/shared/sessions/protocol.ts +6 -1
  24. package/src/shared/sessions/types.ts +6 -0
  25. package/src/shared/stacks/status.ts +24 -0
  26. package/src/shared/strategies/registry.ts +5 -0
  27. package/src/shared/types/index.ts +6 -0
  28. package/src/ui/cli/agent-ops.ts +298 -19
  29. package/src/ui/cli/agents-command.ts +129 -4
  30. package/src/ui/cli/edge-command.ts +16 -3
  31. package/src/ui/cli/health-display.ts +4 -1
  32. package/src/ui/cli/sessions-command.ts +9 -4
  33. package/src/ui/cli/web-command.ts +53 -8
  34. package/src/ui/mcp/scope.ts +23 -3
  35. package/src/ui/mcp/server.ts +31 -7
  36. package/src/ui/mcp/tools.ts +327 -20
  37. package/src/ui/mcp/views.ts +450 -0
  38. package/src/ui/server/agent-routes.ts +123 -26
  39. package/src/ui/server/edge-exposure.ts +143 -0
  40. package/src/ui/server/index.ts +171 -25
  41. package/src/ui/server/public/agent-start.js +241 -0
  42. package/src/ui/server/public/agents-page.js +246 -45
  43. package/src/ui/server/public/agents-section.js +7 -21
  44. package/src/ui/server/public/agents.html +55 -1
  45. package/src/ui/server/public/graph-model.js +2 -0
  46. package/src/ui/server/public/header.js +2 -3
  47. package/src/ui/server/public/health-rollup.js +20 -8
  48. package/src/ui/server/public/i18n/es.js +26 -13
  49. package/src/ui/server/public/i18n/ja.js +25 -13
  50. package/src/ui/server/public/index.html +17 -4
  51. package/src/ui/server/public/local-dev.html +62 -22
  52. package/src/ui/server/public/services.html +21 -5
  53. package/src/ui/server/public/settings-page.js +3 -2
  54. package/src/ui/server/public/settings-registry.js +226 -1
  55. package/src/ui/server/public/settings.html +90 -1
  56. package/src/ui/server/public/shell.js +67 -22
  57. package/src/ui/server/public/topbar.js +217 -158
  58. package/src/ui/server/readonly-routes.ts +29 -5
  59. package/src/ui/server/request-guard.ts +123 -8
  60. package/src/ui/server/route-classes.ts +14 -2
  61. package/src/ui/server/session-routes.ts +9 -1
  62. package/src/ui/server/sudo-store.ts +170 -6
  63. package/src/ui/server/ui-assets.ts +2 -0
  64. package/src/ui/server/workspace-routes.ts +10 -3
@@ -27,7 +27,7 @@ import { cliEntrypoint } from "../../shared/engine.js";
27
27
  import { getDistRoot, getInvokeCwd, getStateDir } from "../../shared/paths.js";
28
28
  import { product } from "../../shared/product.js";
29
29
  import { ENV_NAME_RE, isCredentialShaped } from "../../shared/secret-env.js";
30
- import { allScrubEnv } from "../../shared/agents/runtimes.js";
30
+ import { allScrubEnv, withSessionPath } from "../../shared/agents/runtimes.js";
31
31
  import {
32
32
  ensureHost,
33
33
  connectHost,
@@ -320,14 +320,19 @@ export function contractInfo(s: SessionInfo): SessionInfo {
320
320
  // ── subcommands ───────────────────────────────────────────────────────────────
321
321
 
322
322
  /**
323
- * A plain session's env (AG spec §5a-env): the allowlist, the names the
324
- * user kept, then every runtime's scrubEnv. `doctor --probe` checks it.
323
+ * A plain session's env (AG spec §5a-env): the allowlist (its PATH with
324
+ * the runtime search's directories, AG-0.1), the names the user kept, then
325
+ * every runtime's scrubEnv. `doctor --probe` checks it.
325
326
  */
326
327
  export function plainSessionEnv(
327
328
  caller: NodeJS.ProcessEnv,
328
329
  keep: readonly string[] = [],
329
330
  ): Record<string, string> {
330
- return buildSessionEnv({ caller, keep, scrub: allScrubEnv() });
331
+ return buildSessionEnv({
332
+ caller: withSessionPath(caller),
333
+ keep,
334
+ scrub: allScrubEnv(),
335
+ });
331
336
  }
332
337
 
333
338
  /** Who ran the CLI, for an audit line: a login name, printable. */
@@ -33,7 +33,8 @@ import {
33
33
  } from "../../shared/web-session.js";
34
34
  import { revokeAllActorTokens } from "../../shared/actor-token.js";
35
35
  import { engineBin, engineModuleFile } from "../../shared/engine.js";
36
- import { readWebPort } from "../../shared/edge/config.js";
36
+ import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
37
+ import { webRouteHosts } from "../../shared/edge/render.js";
37
38
  import {
38
39
  ownServer,
39
40
  processCwd,
@@ -1081,7 +1082,7 @@ export function displayWebHelp(): void {
1081
1082
  console.log(` ${product().cli} web --setup-browser-access`);
1082
1083
  console.log(` ${product().cli} web token --print`);
1083
1084
  console.log(
1084
- ` ${product().cli} web login [--next <path>] [--origin <url>] [--port <port>]`,
1085
+ ` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]`,
1085
1086
  );
1086
1087
  console.log(` ${product().cli} web sessions [--json] [--port <port>]`);
1087
1088
  console.log(
@@ -1392,20 +1393,24 @@ function takePort(args: string[]): { port: number; rest: string[] } {
1392
1393
  }
1393
1394
 
1394
1395
  /**
1395
- * `<cli> web login [--next <path>] [--origin <url>]`: a fresh sign-in link
1396
+ * `<cli> web login [--next <path>] [--host <name> | --origin <url>]`: a fresh sign-in link
1396
1397
  * on stdout, alone (the only place a login code is ever printed); a note on
1397
- * stderr. `--origin` for the edge hostname or a LAN name, where the cookie
1398
- * must be set (the signed-out page there says which).
1398
+ * stderr. `--host` for an edge hostname (its URL from the edge config),
1399
+ * `--origin` for any allowed origin (a LAN name), where the cookie must be
1400
+ * set (the signed-out page there says which).
1399
1401
  */
1400
1402
  export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1401
- const usage = `Usage: ${product().cli} web login [--next <path>] [--origin <url>] [--port <port>]\n`;
1403
+ const usage = `Usage: ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]\n`;
1402
1404
  if (isHelp(args)) {
1403
1405
  io.out(
1404
1406
  usage +
1405
1407
  " Prints a one-time sign-in link (it works once, within 60 s) for one browser.\n" +
1406
1408
  " On localhost nothing asks for a sign-in; it is needed when this server is\n" +
1407
- " reached beyond this machine (the edge, or a LAN address: pass its --origin).\n" +
1409
+ " reached from another machine (pass the edge hostname as --host, or a LAN\n" +
1410
+ " address as --origin).\n" +
1408
1411
  " --next <path> where the browser lands after signing in (same origin only)\n" +
1412
+ " --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
1413
+ " a VPN name among them): the link uses its edge URL\n" +
1409
1414
  " --origin <url> the address the browser uses, http(s)://host[:port]\n",
1410
1415
  );
1411
1416
  return 0;
@@ -1430,8 +1435,16 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1430
1435
  : undefined;
1431
1436
  const n = val("--next");
1432
1437
  const o = val("--origin");
1438
+ const h = val("--host");
1433
1439
  if (n !== undefined) next = n;
1434
- else if (o !== undefined) {
1440
+ else if (h !== undefined) {
1441
+ const found = edgeOriginFor(h, port);
1442
+ if (typeof found !== "string") {
1443
+ io.err(found.error);
1444
+ return 2;
1445
+ }
1446
+ origin = found;
1447
+ } else if (o !== undefined) {
1435
1448
  origin = parseOrigin(o);
1436
1449
  if (!origin) {
1437
1450
  io.err(
@@ -1457,6 +1470,38 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1457
1470
  return 0;
1458
1471
  }
1459
1472
 
1473
+ /**
1474
+ * `web login --host <name>`: the origin a browser uses for an edge hostname
1475
+ * that routes to this server (edge.routes under any edge domain or alias),
1476
+ * or why there is none and what to add.
1477
+ */
1478
+ export function edgeOriginFor(
1479
+ name: string,
1480
+ port: number,
1481
+ ): string | { error: string } {
1482
+ const want = name.trim().toLowerCase().replace(/\.$/, "");
1483
+ let routes: { host: string; url: string }[] = [];
1484
+ try {
1485
+ routes = webRouteHosts(readEdgeConfig(), new Set([port, readWebPort()]));
1486
+ } catch {
1487
+ routes = [];
1488
+ }
1489
+ const hit = routes.find((r) => r.host === want);
1490
+ if (hit) return hit.url;
1491
+ const known = routes.map((r) => r.host);
1492
+ return {
1493
+ error:
1494
+ `--host ${name} is not a hostname this server answers to` +
1495
+ (known.length
1496
+ ? ` (its edge hostnames: ${known.join(", ")}).\n`
1497
+ : " (no edge route reaches this server).\n") +
1498
+ " Add it to the config's edge block: its domain to edge.aliases (a VPN\n" +
1499
+ " name), or a route to this server in edge.routes, then `edge up`. For a\n" +
1500
+ " LAN name (DEN_BIND_HOST and web.allow_plain_http_remote) pass\n" +
1501
+ " --origin http://<name>:<port> instead.\n",
1502
+ };
1503
+ }
1504
+
1460
1505
  interface SessionRow {
1461
1506
  id: string;
1462
1507
  createdAt: string;
@@ -191,6 +191,20 @@ function grantsNote(scope: McpScope): string {
191
191
  : "no grants";
192
192
  }
193
193
 
194
+ /**
195
+ * How a person gives `scope`'s agent a grant to `other` (MCP-2.3, AG spec
196
+ * §5b): config in git, then a restart. An agent never grants itself one.
197
+ */
198
+ export function grantHint(scope: McpScope, other: string): string {
199
+ const id = scope.actor.slice("agent:".length);
200
+ return (
201
+ `To reach it, a person adds "${printable(other)}" to agents.${id}.grants ` +
202
+ `in the org config and restarts the agent (agents start passes --grant); ` +
203
+ "an agent can't grant itself one. For names and status counts of every " +
204
+ "workspace, call org_summary."
205
+ );
206
+ }
207
+
194
208
  /** The refusal for a call that names `other`; it names the agent's grants. */
195
209
  export function otherWorkspace(
196
210
  tool: string,
@@ -199,7 +213,8 @@ export function otherWorkspace(
199
213
  ): ScopeRefusal {
200
214
  return new ScopeRefusal(
201
215
  `${tool}: this MCP server is scoped to workspace "${scope.workspace}" ` +
202
- `(${scope.actor}; ${grantsNote(scope)}); "${printable(other)}" is another workspace, so nothing was read or run.`,
216
+ `(${scope.actor}; ${grantsNote(scope)}); "${printable(other)}" is another workspace, so nothing was read or run. ` +
217
+ grantHint(scope, other),
203
218
  );
204
219
  }
205
220
 
@@ -299,8 +314,10 @@ function focusedOf(body: unknown): string | null {
299
314
  export async function assertScopeExists(
300
315
  api: DenApi,
301
316
  scope: McpScope,
317
+ /** An /api/workspaces body already read (else it is read now). */
318
+ read?: unknown,
302
319
  ): Promise<unknown> {
303
- const body = await api.get("/api/workspaces");
320
+ const body = read ?? (await api.get("/api/workspaces"));
304
321
  if (!(scope.workspace in asRecord(asRecord(body).workspaces)))
305
322
  throw new ScopeRefusal(
306
323
  `this MCP server is scoped to workspace "${scope.workspace}", which does not exist in this distribution.`,
@@ -375,7 +392,10 @@ export async function assertServicesInScope(
375
392
  if (outside.length)
376
393
  throw new ScopeRefusal(
377
394
  `${tool}: ${outside.map(printable).join(", ")} ${outside.length === 1 ? "is" : "are"} not a service ` +
378
- `of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
395
+ `of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read. ` +
396
+ "Logs and service details stay inside each workspace (logs can hold " +
397
+ "secrets), and a grant does not widen them; org_summary has every " +
398
+ "workspace's status counts.",
379
399
  );
380
400
  }
381
401
 
@@ -49,7 +49,9 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
49
49
  const p = product();
50
50
  const session = opts.session ?? randomUUID();
51
51
  const log = opts.log ?? ((l: string) => process.stderr.write(`${l}\n`));
52
- const tools = toolsFor(opts.cfg);
52
+ // Scoped, each workspace tool is registered pinned (MCP-2.2): "this
53
+ // workspace only", and no workspace parameter unless there are grants.
54
+ const tools = toolsFor(opts.cfg, opts.scope);
53
55
  const server = new McpServer(
54
56
  { name: p.name, version: engineVersion() ?? "0.0.0" },
55
57
  {
@@ -61,8 +63,11 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
61
63
  : "Read-only: the org has enabled no actions (mcp.actions).") +
62
64
  (opts.scope
63
65
  ? ` Scoped to workspace "${opts.scope.workspace}" as ${opts.scope.actor}: ` +
64
- "a call naming another workspace is refused, and lists show this one only."
65
- : ""),
66
+ "a call naming another workspace is refused, and lists show this one only, " +
67
+ "each with a `scope` block saying how much is hidden. org_summary has " +
68
+ "every workspace and repo (names and status counts), the graph, the " +
69
+ "edge and the environments: start there for the whole picture."
70
+ : " org_summary has the whole org at a glance: start there."),
66
71
  },
67
72
  );
68
73
 
@@ -222,8 +227,10 @@ export async function runOneShot(
222
227
  opts: OneShotOptions,
223
228
  ): Promise<number> {
224
229
  const out = opts.out ?? ((t: string) => process.stdout.write(t));
230
+ // The tools exactly as the stdio server registers them: pinned when scoped.
231
+ const tools = toolsFor(opts.cfg, opts.scope);
225
232
  if (shot.kind === "list") {
226
- const names = toolsFor(opts.cfg).map((t) => t.name);
233
+ const names = tools.map((t) => t.name);
227
234
  out(names.length ? `${names.join("\n")}\n` : "");
228
235
  return 0;
229
236
  }
@@ -235,11 +242,18 @@ export async function runOneShot(
235
242
  };
236
243
  // The stdio server validates arguments against each tool's schema (the
237
244
  // SDK does it); a one-shot must too, and strictly: it is typed by hand, so
238
- // an unknown or misspelt key is an error, not silently dropped.
245
+ // an unknown or misspelt key is an error, not silently dropped. Scoped,
246
+ // the schema is the pinned one; a `workspace` it dropped (no grants) is
247
+ // still taken, so naming another workspace gets Den's refusal (callTool),
248
+ // never a silent answer about the scope's own.
239
249
  let args = shot.args;
240
- const tool = toolsFor(opts.cfg).find((t) => t.name === shot.tool);
250
+ const tool = tools.find((t) => t.name === shot.tool);
241
251
  if (tool) {
242
- const parsed = z.object(tool.shape).strict().safeParse(args);
252
+ const shape =
253
+ opts.scope && !("workspace" in tool.shape) && "workspace" in args
254
+ ? { ...tool.shape, workspace: z.string().optional() }
255
+ : tool.shape;
256
+ const parsed = z.object(shape).strict().safeParse(args);
243
257
  if (!parsed.success) {
244
258
  const err = opts.err ?? ((t: string) => process.stderr.write(t));
245
259
  const lines = parsed.error.issues.map((i) => {
@@ -384,6 +398,16 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
384
398
  ...scopeInfo(scope ?? undefined),
385
399
  }),
386
400
  });
401
+ // Say the scope on stderr (stdout is the result): a one-shot that ran
402
+ // unscoped must never pass for a scoped one.
403
+ if (scope)
404
+ process.stderr.write(
405
+ `${cli} mcp --call: scoped to ${scope.workspace} as ${scope.actor}` +
406
+ (scope.grants?.length
407
+ ? ` · grants: ${scope.grants.join(", ")}`
408
+ : "") +
409
+ "\n",
410
+ );
387
411
  process.exitCode = await runOneShot(shot, {
388
412
  cfg,
389
413
  cli,