@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.
- package/CHANGELOG.md +1731 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +730 -6
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +12 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +173 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +192 -0
- package/src/shared/agents/runtimes.ts +47 -0
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +33 -3
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +257 -5
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/engine.ts +23 -0
- package/src/shared/environment-manager/default-workspace.ts +16 -1
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/environment-manager/workspaces.ts +19 -0
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +78 -59
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secret-env.ts +70 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/sessions/client.ts +277 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +79 -0
- package/src/shared/sessions/host.ts +667 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -0
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +12 -3
- package/src/ui/cli/agents-command.ts +812 -0
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +260 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +18 -5
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +580 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/sessions-command.ts +718 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +145 -20
- package/src/ui/mcp/server.ts +27 -2
- package/src/ui/mcp/tools.ts +94 -12
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +35 -0
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +23 -44
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +539 -51
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +14 -2
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +37 -3
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +48 -18
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
package/src/ui/mcp/scope.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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:
|
|
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
|
|
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)
|
|
163
|
-
|
|
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: {
|
|
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<
|
|
334
|
+
): Promise<string> {
|
|
211
335
|
const focused = focusedOf(await api.get("/api/workspaces"));
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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}: ${
|
|
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
|
}
|
package/src/ui/mcp/server.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
|
605
|
-
async function
|
|
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
|
|
608
|
-
return typeof info
|
|
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
|
|
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
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
+
}
|
package/src/ui/server/audit.ts
CHANGED
|
@@ -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
|