@monster-orange/den 0.2.8 → 0.4.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 (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +12 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -4,11 +4,18 @@
4
4
  * filters lists to it, and stamps the actor on every web API call (the
5
5
  * x-den-mcp-agent / x-den-mcp-workspace headers, see shared/agent-actor.ts).
6
6
  *
7
+ * `--grant <ws>` (AG-2a, AG spec §5b; repeatable) widens the scope to another
8
+ * workspace, and only as far as the org config's `agents.<id>.grants` says
9
+ * (checkGrants): an agent never grants itself one. A call that names a
10
+ * granted workspace is served, and reported to the audit trail with both
11
+ * workspaces named. Lists show the scoped workspace and its grants.
12
+ *
7
13
  * It binds cooperating agents only: the scope lives in this process, and the
8
14
  * web server's HTTP API does not check it yet (WS-3a).
9
15
  */
10
16
  import * as path from "path";
11
17
  import { AGENT_ACTOR_RE, WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
18
+ import type { ResolvedAgentsConfig } from "../../shared/den-config.js";
12
19
  import type { DenApi } from "./api.js";
13
20
  import { Refusal } from "./guards.js";
14
21
 
@@ -17,6 +24,16 @@ export interface McpScope {
17
24
  workspace: string;
18
25
  /** Who acts: `agent:<id>`. */
19
26
  actor: string;
27
+ /**
28
+ * Other workspaces it may name (`--grant`, from agents.<id>.grants);
29
+ * absent when there are none.
30
+ */
31
+ grants?: string[];
32
+ }
33
+
34
+ /** The workspaces a scope serves: its own first, then its grants. */
35
+ export function scopeWorkspaces(scope: McpScope): string[] {
36
+ return [scope.workspace, ...(scope.grants ?? [])];
20
37
  }
21
38
 
22
39
  /** A call reached outside the scoped workspace. Always reported (F2). */
@@ -35,10 +52,11 @@ export class ScopeUsageError extends Error {
35
52
  }
36
53
  }
37
54
 
38
- const FLAGS: Record<string, "workspace" | "actor"> = {
55
+ const FLAGS: Record<string, "workspace" | "actor" | "grant"> = {
39
56
  "--workspace": "workspace",
40
57
  "-w": "workspace",
41
58
  "--actor": "actor",
59
+ "--grant": "grant",
42
60
  };
43
61
 
44
62
  /**
@@ -53,6 +71,7 @@ export function parseScopeFlags(argv: readonly string[]): {
53
71
  rest: string[];
54
72
  } {
55
73
  const got: Partial<Record<"workspace" | "actor", string>> = {};
74
+ const grants: string[] = [];
56
75
  const rest: string[] = [];
57
76
  for (let i = 0; i < argv.length; i++) {
58
77
  const a = argv[i];
@@ -71,12 +90,25 @@ export function parseScopeFlags(argv: readonly string[]): {
71
90
  throw new ScopeUsageError(`${name} needs a value`);
72
91
  i++;
73
92
  }
93
+ if (key === "grant") {
94
+ if (!WORKSPACE_NAME_RE.test(value))
95
+ throw new ScopeUsageError(
96
+ `--grant "${printable(value)}" is not a workspace name`,
97
+ );
98
+ if (!grants.includes(value)) grants.push(value);
99
+ continue;
100
+ }
74
101
  if (got[key] !== undefined && got[key] !== value)
75
102
  throw new ScopeUsageError(`--${key} given twice`);
76
103
  got[key] = value;
77
104
  }
78
- if (got.workspace === undefined && got.actor === undefined)
105
+ if (got.workspace === undefined && got.actor === undefined) {
106
+ if (grants.length)
107
+ throw new ScopeUsageError(
108
+ "--grant needs --workspace and --actor: a grant widens a scoped server",
109
+ );
79
110
  return { scope: null, rest };
111
+ }
80
112
  if (got.workspace === undefined)
81
113
  throw new ScopeUsageError(
82
114
  "--actor needs --workspace: a scoped server serves one workspace",
@@ -95,7 +127,35 @@ export function parseScopeFlags(argv: readonly string[]): {
95
127
  `--actor "${printable(got.actor)}" is not agent:<id> ` +
96
128
  "(the id: letters, digits, dots, dashes, underscores; at most 64)",
97
129
  );
98
- return { scope: { workspace: got.workspace, actor: got.actor }, rest };
130
+ const widened = grants.filter((g) => g !== got.workspace);
131
+ return {
132
+ scope: {
133
+ workspace: got.workspace,
134
+ actor: got.actor,
135
+ ...(widened.length ? { grants: widened } : {}),
136
+ },
137
+ rest,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Null when every `--grant` is one the org config gives this agent
143
+ * (`agents.<id>.grants`), else the refusal. A grant is config in git,
144
+ * reviewed like code: a flag alone never widens a scope.
145
+ */
146
+ export function checkGrants(
147
+ scope: McpScope,
148
+ agents: Pick<ResolvedAgentsConfig, "agents">,
149
+ ): string | null {
150
+ const id = scope.actor.slice("agent:".length);
151
+ const allowed = agents.agents[id]?.grants ?? [];
152
+ for (const g of scope.grants ?? [])
153
+ if (!allowed.includes(g))
154
+ return (
155
+ `--grant ${g}: ${scope.actor} has no such grant in agents.${id}.grants ` +
156
+ "of the org config; a grant is config, never a flag alone"
157
+ );
158
+ return null;
99
159
  }
100
160
 
101
161
  /** A value echoed back in an error: one printable line, bounded. */
@@ -103,7 +163,35 @@ function printable(v: string): string {
103
163
  return v.replace(/[^\x20-\x7e]/g, "?").slice(0, 80);
104
164
  }
105
165
 
106
- /** The refusal for a call that names `other`. */
166
+ /**
167
+ * A path echoed back in an error: one printable line, never cut mid-name
168
+ * (0.3.0-dev.40, a consumer's 0.2.8 nit: printable's 80-character cap cut
169
+ * the filename). Up to `max` characters it is whole; a longer one keeps its
170
+ * start and its last segment whole, eliding the middle with "…".
171
+ */
172
+ export function printablePath(v: string, max = 160): string {
173
+ const s = v.replace(/[^\x20-\x7e]/g, "?");
174
+ if (s.length <= max) return s;
175
+ const cut = s.lastIndexOf("/");
176
+ const tail = cut >= 0 ? s.slice(cut) : s;
177
+ // The last segment is kept whole whatever its length; the room left is
178
+ // shared between the path's start and the directories just before it.
179
+ const room = max - tail.length - 1;
180
+ if (room <= 0) return `…${tail}`;
181
+ const head = s.slice(0, Math.ceil(room / 2));
182
+ const end = s.length - tail.length;
183
+ const more = s.slice(end - (room - head.length), end);
184
+ return `${head}…${more}${tail}`;
185
+ }
186
+
187
+ /** "grants: a, b" or "no grants": what else the scope reaches. */
188
+ function grantsNote(scope: McpScope): string {
189
+ return scope.grants?.length
190
+ ? `grants: ${scope.grants.join(", ")}`
191
+ : "no grants";
192
+ }
193
+
194
+ /** The refusal for a call that names `other`; it names the agent's grants. */
107
195
  export function otherWorkspace(
108
196
  tool: string,
109
197
  scope: McpScope,
@@ -111,7 +199,7 @@ export function otherWorkspace(
111
199
  ): ScopeRefusal {
112
200
  return new ScopeRefusal(
113
201
  `${tool}: this MCP server is scoped to workspace "${scope.workspace}" ` +
114
- `(${scope.actor}); "${printable(other)}" is another workspace, so nothing was read or run.`,
202
+ `(${scope.actor}; ${grantsNote(scope)}); "${printable(other)}" is another workspace, so nothing was read or run.`,
115
203
  );
116
204
  }
117
205
 
@@ -125,9 +213,20 @@ export function pinWorkspace(
125
213
  scope: McpScope,
126
214
  ): Record<string, unknown> {
127
215
  const ws = args.workspace;
128
- if (ws !== undefined && ws !== null && ws !== "" && ws !== scope.workspace)
216
+ if (ws === undefined || ws === null || ws === "")
217
+ return { ...args, workspace: scope.workspace };
218
+ if (!scopeWorkspaces(scope).includes(String(ws)))
129
219
  throw otherWorkspace(tool, scope, String(ws));
130
- return { ...args, workspace: scope.workspace };
220
+ return { ...args, workspace: String(ws) };
221
+ }
222
+
223
+ /** The granted workspace `args` names, or null for the scope's own. */
224
+ export function crossedWorkspace(
225
+ args: Record<string, unknown>,
226
+ scope: McpScope,
227
+ ): string | null {
228
+ const ws = args.workspace;
229
+ return typeof ws === "string" && ws && ws !== scope.workspace ? ws : null;
131
230
  }
132
231
 
133
232
  /** What /api/workspaces answers (the fields the scope reads). */
@@ -154,13 +253,20 @@ export function filterWorkspacesBody(
154
253
  ): Record<string, unknown> {
155
254
  const b = asRecord(body) as WorkspacesBody;
156
255
  const ws = scope.workspace;
256
+ const served = scopeWorkspaces(scope);
157
257
  const only = (v: unknown): Record<string, unknown> => {
158
258
  const r = asRecord(v);
159
- return ws in r ? { [ws]: r[ws] } : {};
259
+ return Object.fromEntries(
260
+ served.filter((n) => n in r).map((n) => [n, r[n]]),
261
+ );
160
262
  };
161
263
  return {
162
- active: Array.isArray(b.active) ? b.active.filter((n) => n === ws) : [],
163
- ...(b.focus === ws ? { focus: ws } : {}),
264
+ active: Array.isArray(b.active)
265
+ ? b.active.filter((n) => served.includes(n as string))
266
+ : [],
267
+ ...(typeof b.focus === "string" && served.includes(b.focus)
268
+ ? { focus: b.focus }
269
+ : {}),
164
270
  workspaces: only(b.workspaces),
165
271
  ...(b.resolvedDirs !== undefined
166
272
  ? { resolvedDirs: only(b.resolvedDirs) }
@@ -168,7 +274,11 @@ export function filterWorkspacesBody(
168
274
  ...(b.codeWorkspacePaths !== undefined
169
275
  ? { codeWorkspacePaths: only(b.codeWorkspacePaths) }
170
276
  : {}),
171
- scope: { workspace: ws, actor: scope.actor },
277
+ scope: {
278
+ workspace: ws,
279
+ actor: scope.actor,
280
+ ...(scope.grants?.length ? { grants: scope.grants } : {}),
281
+ },
172
282
  };
173
283
  }
174
284
 
@@ -198,23 +308,38 @@ export async function assertScopeExists(
198
308
  return body;
199
309
  }
200
310
 
311
+ /** Throws unless the granted workspace `ws` exists in this distribution. */
312
+ export async function assertGrantExists(
313
+ tool: string,
314
+ api: DenApi,
315
+ scope: McpScope,
316
+ ws: string,
317
+ ): Promise<void> {
318
+ const body = await api.get("/api/workspaces");
319
+ if (!(ws in asRecord(asRecord(body).workspaces)))
320
+ throw new ScopeRefusal(
321
+ `${tool}: ${scope.actor} has a grant for workspace "${printable(ws)}", which does not exist in this distribution; nothing was read or run.`,
322
+ );
323
+ }
324
+
201
325
  /**
202
326
  * Tests run against the focused workspace (the web server has no workspace
203
327
  * argument for them): a scoped server runs or lists them only when that is
204
- * its own.
328
+ * its own or a granted one. Returns the focused workspace.
205
329
  */
206
330
  export async function assertScopeFocused(
207
331
  tool: string,
208
332
  api: DenApi,
209
333
  scope: McpScope,
210
- ): Promise<void> {
334
+ ): Promise<string> {
211
335
  const focused = focusedOf(await api.get("/api/workspaces"));
212
- if (focused !== scope.workspace)
213
- throw new ScopeRefusal(
214
- `${tool}: tests run against the focused workspace` +
215
- `${focused ? ` ("${focused}")` : ""}, and this MCP server is scoped to ` +
216
- `"${scope.workspace}". Focus "${scope.workspace}" first; nothing was read or run.`,
217
- );
336
+ // A granted workspace that is focused is served too (and audited).
337
+ if (focused && scopeWorkspaces(scope).includes(focused)) return focused;
338
+ throw new ScopeRefusal(
339
+ `${tool}: tests run against the focused workspace` +
340
+ `${focused ? ` ("${focused}")` : ""}, and this MCP server is scoped to ` +
341
+ `"${scope.workspace}". Focus "${scope.workspace}" first; nothing was read or run.`,
342
+ );
218
343
  }
219
344
 
220
345
  /** The fields of an /api/services entry the scope checks read. */
@@ -272,6 +397,6 @@ export async function assertPathInScope(
272
397
  .map((r) => path.resolve(r));
273
398
  if (!roots.some((r) => target === r || target.startsWith(r + path.sep)))
274
399
  throw new ScopeRefusal(
275
- `${tool}: ${printable(p)} is not inside a repo of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
400
+ `${tool}: ${printablePath(p)} is not inside a repo of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
276
401
  );
277
402
  }
@@ -11,6 +11,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
12
  import { agentAuditHeaders } from "../../shared/agent-actor.js";
13
13
  import {
14
+ readAgentsConfig,
14
15
  readMcpConfig,
15
16
  type ResolvedMcpConfig,
16
17
  } from "../../shared/den-config.js";
@@ -22,7 +23,12 @@ import { createDenApi, type DenApi } from "./api.js";
22
23
  import { ownServer } from "../cli/server-identity.js";
23
24
  import { callTool, toolsFor, type ToolContext } from "./tools.js";
24
25
  import { plural } from "../../shared/plural.js";
25
- import { parseScopeFlags, ScopeUsageError, type McpScope } from "./scope.js";
26
+ import {
27
+ checkGrants,
28
+ parseScopeFlags,
29
+ ScopeUsageError,
30
+ type McpScope,
31
+ } from "./scope.js";
26
32
 
27
33
  export interface DenMcpOptions {
28
34
  cfg: ResolvedMcpConfig;
@@ -146,7 +152,7 @@ export const MCP_USAGE = (cli: string): string =>
146
152
  `Usage: ${cli} mcp [--port <web port>]`,
147
153
  ` ${cli} mcp --list`,
148
154
  ` ${cli} mcp --call <tool> ['<json args>']`,
149
- ` ${cli} mcp --workspace <ws> --actor agent:<id> [...]`,
155
+ ` ${cli} mcp --workspace <ws> --actor agent:<id> [--grant <ws>…] [...]`,
150
156
  "",
151
157
  `An MCP server over stdio for agents, a client of the running web server`,
152
158
  `(\`${cli} web up\`; default port: config web.port). Read tools always;`,
@@ -160,6 +166,10 @@ export const MCP_USAGE = (cli: string): string =>
160
166
  "is recorded as agent:<id>. It binds cooperating agents only: the web",
161
167
  "server's HTTP API does not check the scope yet.",
162
168
  "",
169
+ "--grant <ws> (repeatable) also serves another workspace, only when the org",
170
+ "config's agents.<id>.grants lists it; each such call is recorded with both",
171
+ "workspaces. `agents start` passes an agent's grants for you.",
172
+ "",
163
173
  `Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
164
174
  ].join("\n");
165
175
 
@@ -269,6 +279,20 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
269
279
  process.exitCode = 2;
270
280
  return;
271
281
  }
282
+ if (scope?.grants?.length) {
283
+ // A grant is config: a flag alone never widens a scope.
284
+ let refused: string | null;
285
+ try {
286
+ refused = checkGrants(scope, readAgentsConfig());
287
+ } catch (e) {
288
+ refused = (e as Error).message;
289
+ }
290
+ if (refused) {
291
+ process.stderr.write(`${cli} mcp: ${refused}\n`);
292
+ process.exitCode = 2;
293
+ return;
294
+ }
295
+ }
272
296
  let port = parsePort(argv);
273
297
  if (port === undefined) {
274
298
  try {
@@ -315,6 +339,7 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
315
339
  `${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
316
340
  `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}` +
317
341
  (scope ? ` · scoped to ${scope.workspace} as ${scope.actor}` : "") +
342
+ (scope?.grants?.length ? ` · grants: ${scope.grants.join(", ")}` : "") +
318
343
  "\n",
319
344
  );
320
345
  }
@@ -35,10 +35,12 @@ import {
35
35
  } from "./guards.js";
36
36
  import {
37
37
  ScopeRefusal,
38
+ assertGrantExists,
38
39
  assertPathInScope,
39
40
  assertScopeExists,
40
41
  assertScopeFocused,
41
42
  assertServicesInScope,
43
+ crossedWorkspace,
42
44
  filterWorkspacesBody,
43
45
  pinWorkspace,
44
46
  type McpScope,
@@ -303,7 +305,12 @@ const READ_TOOLS: DenTool[] = [
303
305
  "The test modules of a pillar, and which repos (branch + SHA) they come from.",
304
306
  { pillar: z.enum(PILLARS) },
305
307
  async (a, { api, scope }) => {
306
- if (scope) await assertScopeFocused("tests_available", api, scope);
308
+ if (scope)
309
+ await crossIfGranted(
310
+ { api, scope },
311
+ "tests_available",
312
+ await assertScopeFocused("tests_available", api, scope),
313
+ );
307
314
  return {
308
315
  modules: await api.get("/api/modules", { pillar: String(a.pillar) }),
309
316
  sources: await api.get("/api/test-sources", {
@@ -538,7 +545,12 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
538
545
  const mode =
539
546
  pillar === "security" ? (str(a.mode) ?? "baseline") : undefined;
540
547
  assertRunTestsAllowed({ pillar, env, mode }, cfg);
541
- if (scope) await assertScopeFocused("run_tests", api, scope);
548
+ if (scope)
549
+ await crossIfGranted(
550
+ { api, scope },
551
+ "run_tests",
552
+ await assertScopeFocused("run_tests", api, scope),
553
+ );
542
554
  if (pillar === "security") {
543
555
  for (const service of modules.length ? modules : [undefined]) {
544
556
  const plan = (await api.get("/api/security/plan", {
@@ -601,13 +613,17 @@ export function versionBefore(a: string, b: string): boolean {
601
613
  return false;
602
614
  }
603
615
 
604
- /** The running web server's version, or undefined when it reports none. */
605
- async function webServerVersion(api: DenApi): Promise<string | undefined> {
616
+ /** The running web server's /api/server-info, or null when it has none. */
617
+ async function readServerInfo(
618
+ api: DenApi,
619
+ ): Promise<Record<string, unknown> | null> {
606
620
  try {
607
- const info = (await api.get("/api/server-info")) as { version?: unknown };
608
- return typeof info?.version === "string" ? info.version : undefined;
621
+ const info = (await api.get("/api/server-info")) as unknown;
622
+ return info && typeof info === "object"
623
+ ? (info as Record<string, unknown>)
624
+ : null;
609
625
  } catch (e) {
610
- if (e instanceof DenHttpError) return undefined;
626
+ if (e instanceof DenHttpError) return null;
611
627
  throw e;
612
628
  }
613
629
  }
@@ -621,11 +637,13 @@ async function notFoundMessage(
621
637
  e: DenHttpError,
622
638
  tool: string,
623
639
  ctx: ToolContext,
640
+ info: () => Promise<Record<string, unknown> | null>,
624
641
  ): Promise<string> {
625
642
  const plain = `Error: ${tool}: the web server answered 404 for ${e.method} ${e.path}: ${e.body}`;
626
643
  const since = ROUTE_SINCE[e.path];
627
644
  if (!since) return plain;
628
- const version = await webServerVersion(ctx.api);
645
+ const v = (await info())?.version;
646
+ const version = typeof v === "string" ? v : undefined;
629
647
  if (version !== undefined && !versionBefore(version, since)) return plain;
630
648
  return (
631
649
  `web server is Den ${version ?? `< ${since}`}; ` +
@@ -664,6 +682,30 @@ async function reportRefusal(
664
682
  }
665
683
  }
666
684
 
685
+ /**
686
+ * MD-2: when the web server runs an older engine than the one on disk (a
687
+ * pin upgraded Den under it), every failed call says so, naming both
688
+ * versions and the restart. Null when the engines agree or it can't tell.
689
+ */
690
+ function staleEngineMessage(
691
+ tool: string,
692
+ ctx: ToolContext,
693
+ info: Record<string, unknown> | null,
694
+ ): string | null {
695
+ if (info?.engineStale !== true) return null;
696
+ const ver = (x: unknown) =>
697
+ x &&
698
+ typeof x === "object" &&
699
+ typeof (x as { version?: unknown }).version === "string"
700
+ ? (x as { version: string }).version
701
+ : "unknown";
702
+ return (
703
+ `Error: ${tool}: Den was updated to ${ver(info.onDisk)}, but the web server ` +
704
+ `is still running ${ver(info.loaded)}. Restart it: ${ctx.cli} web restart ` +
705
+ `(or your service restart).`
706
+ );
707
+ }
708
+
667
709
  /**
668
710
  * A scoped server's arguments: the workspace exists (checked once per
669
711
  * server), and a tool that takes a workspace gets the scoped one; naming
@@ -680,9 +722,36 @@ async function scopeArgs(
680
722
  await assertScopeExists(ctx.api, scope);
681
723
  verifiedScopes.add(scope);
682
724
  }
683
- return "workspace" in tool.shape
684
- ? pinWorkspace(tool.name, args, scope)
685
- : args;
725
+ if (!("workspace" in tool.shape)) return args;
726
+ const pinned = pinWorkspace(tool.name, args, scope);
727
+ const crossed = crossedWorkspace(pinned, scope);
728
+ if (crossed) {
729
+ await assertGrantExists(tool.name, ctx.api, scope, crossed);
730
+ await reportCrossWorkspace(ctx.api, tool.name, crossed);
731
+ }
732
+ return pinned;
733
+ }
734
+
735
+ /**
736
+ * A call into a granted workspace (AG-2a, §5b) is recorded before it runs,
737
+ * with both workspaces named: the server's entry takes the scoped one from
738
+ * the actor's headers and the target from this report.
739
+ */
740
+ async function reportCrossWorkspace(
741
+ api: DenApi,
742
+ tool: string,
743
+ target: string,
744
+ ): Promise<void> {
745
+ await api.get("/api/activity", {}, "POST", { tool, crossWorkspace: target });
746
+ }
747
+
748
+ /** Report `ws` when it is a granted workspace, not the scope's own. */
749
+ async function crossIfGranted(
750
+ ctx: { api: DenApi; scope: McpScope },
751
+ tool: string,
752
+ ws: string,
753
+ ): Promise<void> {
754
+ if (ws !== ctx.scope.workspace) await reportCrossWorkspace(ctx.api, tool, ws);
686
755
  }
687
756
 
688
757
  export async function callTool(
@@ -709,8 +778,21 @@ export async function callTool(
709
778
  text: redactSecrets(JSON.stringify(e.out, null, 2)),
710
779
  isError: true,
711
780
  };
781
+ // server-info is read lazily, once, and only after a call failed.
782
+ let infoRead: Promise<Record<string, unknown> | null> | null = null;
783
+ const info = () => (infoRead ??= readServerInfo(ctx.api).catch(() => null));
784
+ if (e instanceof DenHttpError) {
785
+ const stale = staleEngineMessage(name, ctx, await info());
786
+ if (stale)
787
+ return {
788
+ text: redactSecrets(
789
+ `${stale} (${e.method} ${e.path} answered ${e.status}: ${e.body})`,
790
+ ),
791
+ isError: true,
792
+ };
793
+ }
712
794
  if (e instanceof DenHttpError && e.status === 404) {
713
- const text = await notFoundMessage(e, name, ctx).catch(
795
+ const text = await notFoundMessage(e, name, ctx, info).catch(
714
796
  (inner: Error) => `Error: ${inner.message}`,
715
797
  );
716
798
  return { text: redactSecrets(text), isError: true };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Appearance preferences: the theme and mode this engineer chose. Stored in
3
+ * the user config as `appearance:` and served by /api/settings, like the
4
+ * graph prefs. A pure UI preference: changing it writes this block of
5
+ * config/local/user.yml and nothing else (no edge, services or restart).
6
+ */
7
+
8
+ export type ModePref = "system" | "dark" | "light";
9
+
10
+ export interface AppearancePrefs {
11
+ /**
12
+ * A theme id, or null to follow the org default (the org config's
13
+ * `brand.theme`, else den). A named theme is pinned even when it is the
14
+ * org default today: you keep it when the org changes `brand.theme`.
15
+ */
16
+ theme: string | null;
17
+ /** system follows prefers-color-scheme. */
18
+ mode: ModePref;
19
+ }
20
+
21
+ export const APPEARANCE_DEFAULTS: AppearancePrefs = {
22
+ theme: null,
23
+ mode: "system",
24
+ };
25
+
26
+ export const MODES: readonly ModePref[] = ["system", "dark", "light"];
27
+ const THEME_ID = /^[a-z][a-z0-9-]{1,39}$/;
28
+
29
+ const isObject = (v: unknown): v is Record<string, unknown> =>
30
+ typeof v === "object" && v !== null && !Array.isArray(v);
31
+
32
+ /**
33
+ * The stored block, normalised. A stored theme id is kept even when that
34
+ * theme is not installed now (resolution warns and falls back), so a theme
35
+ * that is briefly missing does not lose the preference.
36
+ */
37
+ export function appearancePrefs(stored: unknown): AppearancePrefs {
38
+ const out: AppearancePrefs = { ...APPEARANCE_DEFAULTS };
39
+ if (!isObject(stored)) return out;
40
+ if (typeof stored.theme === "string" && THEME_ID.test(stored.theme))
41
+ out.theme = stored.theme;
42
+ if (MODES.includes(stored.mode as ModePref))
43
+ out.mode = stored.mode as ModePref;
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Merge a POSTed (partial) block. `theme` must name an installed theme that
49
+ * validates; null or "" clears it ("Org default": follow the org); any bad
50
+ * field fails the whole patch.
51
+ *
52
+ * A theme picked by name is stored by name, even one that is the org
53
+ * default now (0.3.0-dev.11): that is a pin, and the menu shows it as one.
54
+ * Only "Org default" follows the org when it changes `brand.theme`.
55
+ */
56
+ export function mergeAppearancePrefs(
57
+ current: AppearancePrefs,
58
+ patch: unknown,
59
+ isUsableTheme: (id: string) => boolean,
60
+ ): { ok: true; value: AppearancePrefs } | { ok: false; error: string } {
61
+ if (!isObject(patch))
62
+ return { ok: false, error: "appearance must be an object" };
63
+ const out: AppearancePrefs = { ...current };
64
+ for (const [key, v] of Object.entries(patch)) {
65
+ if (key === "mode") {
66
+ // null clears it to the default (0.3.0-dev.32), as theme's does.
67
+ if (v === null) out.mode = APPEARANCE_DEFAULTS.mode;
68
+ else if (!MODES.includes(v as ModePref))
69
+ return {
70
+ ok: false,
71
+ error: `appearance.mode must be one of ${MODES.map((m) => `"${m}"`).join(", ")}`,
72
+ };
73
+ else out.mode = v as ModePref;
74
+ } else if (key === "theme") {
75
+ if (v === null || v === "") out.theme = null;
76
+ else if (typeof v !== "string" || !THEME_ID.test(v) || !isUsableTheme(v))
77
+ return {
78
+ ok: false,
79
+ error: `appearance.theme: no valid installed theme "${String(v)}"`,
80
+ };
81
+ else out.theme = v;
82
+ } else return { ok: false, error: `unknown appearance setting "${key}"` };
83
+ }
84
+ return { ok: true, value: out };
85
+ }
86
+
87
+ /** What user.yml stores: only what differs from the defaults. */
88
+ export function storedAppearance(
89
+ p: AppearancePrefs,
90
+ ): { theme?: string; mode?: ModePref } | undefined {
91
+ const out: { theme?: string; mode?: ModePref } = {};
92
+ if (p.theme) out.theme = p.theme;
93
+ if (p.mode !== APPEARANCE_DEFAULTS.mode) out.mode = p.mode;
94
+ return Object.keys(out).length ? out : undefined;
95
+ }
@@ -8,6 +8,7 @@
8
8
  import * as path from "path";
9
9
  import {
10
10
  actorLabel,
11
+ WORKSPACE_NAME_RE,
11
12
  type Actor,
12
13
  type AgentActor,
13
14
  } from "../../shared/agent-actor.js";
@@ -25,6 +26,11 @@ export interface AuditEntry {
25
26
  agent?: string;
26
27
  /** The workspace that scoped server is pinned to. */
27
28
  workspace?: string;
29
+ /**
30
+ * The other workspace a call reached under a grant (`agents.<id>.grants`,
31
+ * AG-2a); `workspace` is the agent's own.
32
+ */
33
+ targetWorkspace?: string;
28
34
  /** exec | run | sync, or the MCP tool name for a refused non-lifecycle call */
29
35
  verb: string;
30
36
  detail: string;
@@ -63,6 +69,7 @@ export function recordAction(
63
69
  outcome?: "refused";
64
70
  reason?: string;
65
71
  addresses?: string[];
72
+ targetWorkspace?: string;
66
73
  },
67
74
  ): AuditEntry {
68
75
  const entry: AuditEntry = {
@@ -78,6 +85,9 @@ export function recordAction(
78
85
  ...(actor.workspace ? { workspace: actor.workspace } : {}),
79
86
  }
80
87
  : {}),
88
+ ...(action.targetWorkspace
89
+ ? { targetWorkspace: action.targetWorkspace }
90
+ : {}),
81
91
  verb: action.verb,
82
92
  detail: action.detail,
83
93
  ...(action.outcome ? { outcome: action.outcome } : {}),
@@ -194,6 +204,31 @@ export function recordAgentRefusal(
194
204
  });
195
205
  }
196
206
 
207
+ /**
208
+ * A scoped agent's call into a workspace it holds a grant for (AG-2a, AG
209
+ * spec §5b), reported by its MCP server before the call runs. Recorded
210
+ * with both workspaces: the agent's own (from its headers) and the target.
211
+ * Null, and nothing recorded, unless the actor is a scoped agent and the
212
+ * target is a well-formed workspace other than its own. `GET /api/activity
213
+ * ?address=workspace:<ws>` lists it under either workspace.
214
+ */
215
+ export function recordCrossWorkspace(
216
+ actor: Actor,
217
+ report: { tool?: unknown; crossWorkspace?: unknown },
218
+ ): AuditEntry | null {
219
+ if (!actor?.agent || !actor.workspace) return null;
220
+ const target =
221
+ typeof report.crossWorkspace === "string" ? report.crossWorkspace : "";
222
+ if (!WORKSPACE_NAME_RE.test(target) || target === actor.workspace)
223
+ return null;
224
+ const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
225
+ return recordAction(actor, {
226
+ verb: tool,
227
+ detail: `in workspace ${target}, under a grant to ${actor.agent} of workspace ${actor.workspace}`,
228
+ targetWorkspace: target,
229
+ });
230
+ }
231
+
197
232
  /**
198
233
  * The catalog repo a refused call's `path` is inside (manifest_validate), so
199
234
  * the refusal shows in that repo's activity: the repo whose checkout is the