@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.
- package/CHANGELOG.md +273 -0
- package/README.md +9 -9
- package/docs/distributions.md +313 -50
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +53 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +543 -0
- package/src/shared/agents/runtime.ts +43 -1
- package/src/shared/agents/runtimes.ts +53 -4
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +92 -2
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/ui/cli/agent-ops.ts +298 -19
- package/src/ui/cli/agents-command.ts +129 -4
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +123 -26
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +171 -25
- package/src/ui/server/public/agent-start.js +241 -0
- package/src/ui/server/public/agents-page.js +246 -45
- package/src/ui/server/public/agents-section.js +7 -21
- package/src/ui/server/public/agents.html +55 -1
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +26 -13
- package/src/ui/server/public/i18n/ja.js +25 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +62 -22
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +3 -2
- package/src/ui/server/public/settings-registry.js +226 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +67 -22
- package/src/ui/server/public/topbar.js +217 -158
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +123 -8
- package/src/ui/server/route-classes.ts +14 -2
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-2: what an agent reads, shaped so it gets a whole, honest picture.
|
|
3
|
+
*
|
|
4
|
+
* - Scoped answers say so (MCP-2.2): a `scope` block naming the workspace
|
|
5
|
+
* shown and how much is left out (`hidden: { repos: 6 }`), pointing at
|
|
6
|
+
* `org_summary` for the rest.
|
|
7
|
+
* - Health is honest (MCP-2.4): every row has a `state` (up, down,
|
|
8
|
+
* redirect, pending, unknown, not-checked), and a row with no verdict
|
|
9
|
+
* says why.
|
|
10
|
+
* - `org_summary` (MCP-2.1): every workspace and repo with status counts,
|
|
11
|
+
* the graph, the edge and the environments, for every agent. It never
|
|
12
|
+
* carries logs, env values, commands, URLs, paths or check output from
|
|
13
|
+
* outside the agent's own workspace: only names and counts.
|
|
14
|
+
*
|
|
15
|
+
* Pure: the tools fetch, these shape.
|
|
16
|
+
*/
|
|
17
|
+
import { rollupRepo } from "../../shared/health/rollup.js";
|
|
18
|
+
import { notCheckedReason } from "../../shared/health/not-checked.js";
|
|
19
|
+
import type { McpScope } from "./scope.js";
|
|
20
|
+
|
|
21
|
+
type Rec = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
const asRecord = (v: unknown): Rec =>
|
|
24
|
+
v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
|
|
25
|
+
const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
|
|
26
|
+
const str = (v: unknown): string | undefined =>
|
|
27
|
+
typeof v === "string" && v.length > 0 ? v : undefined;
|
|
28
|
+
|
|
29
|
+
// ── Workspaces and their repos ──────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Workspace names in an /api/workspaces body. */
|
|
32
|
+
export function workspaceNames(body: unknown): string[] {
|
|
33
|
+
return Object.keys(asRecord(asRecord(body).workspaces));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The repos a workspace's config names: its service addresses' repos
|
|
38
|
+
* ("shop/api" → shop) and its managed clones.
|
|
39
|
+
*/
|
|
40
|
+
export function workspaceRepos(body: unknown, ws: string): string[] {
|
|
41
|
+
const cfg = asRecord(asRecord(asRecord(body).workspaces)[ws]);
|
|
42
|
+
const repos = new Set<string>();
|
|
43
|
+
for (const address of Object.keys(asRecord(cfg.services)))
|
|
44
|
+
repos.add(address.split("/")[0]);
|
|
45
|
+
for (const repo of Object.keys(asRecord(cfg.repos))) repos.add(repo);
|
|
46
|
+
return [...repos].sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every repo any workspace names. */
|
|
50
|
+
function orgRepos(body: unknown): Set<string> {
|
|
51
|
+
const all = new Set<string>();
|
|
52
|
+
for (const ws of workspaceNames(body))
|
|
53
|
+
for (const r of workspaceRepos(body, ws)) all.add(r);
|
|
54
|
+
return all;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── The scope block (MCP-2.2) ───────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export interface ScopeBlock {
|
|
60
|
+
workspace: string;
|
|
61
|
+
actor: string;
|
|
62
|
+
/** Set when `workspace` is a granted one, not the agent's own. */
|
|
63
|
+
grantedFrom?: string;
|
|
64
|
+
grants?: string[];
|
|
65
|
+
otherWorkspaces: number;
|
|
66
|
+
hidden: Record<string, number>;
|
|
67
|
+
note: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `scope` of a scoped answer about workspace `shown`: what it shows
|
|
72
|
+
* ("3 services in north (your scope)"), and what it leaves out: the other
|
|
73
|
+
* workspaces and their repos (`hidden.repos`: repos only they name), plus
|
|
74
|
+
* any count the tool adds (`extraHidden`, e.g. stacks).
|
|
75
|
+
*/
|
|
76
|
+
export function scopeBlock(
|
|
77
|
+
scope: McpScope,
|
|
78
|
+
workspacesBody: unknown,
|
|
79
|
+
shown: string,
|
|
80
|
+
shows: { count: number; noun: string },
|
|
81
|
+
extraHidden: Record<string, number> = {},
|
|
82
|
+
): ScopeBlock {
|
|
83
|
+
const mine = new Set(workspaceRepos(workspacesBody, shown));
|
|
84
|
+
const hiddenRepos = [...orgRepos(workspacesBody)].filter(
|
|
85
|
+
(r) => !mine.has(r),
|
|
86
|
+
).length;
|
|
87
|
+
const others = workspaceNames(workspacesBody).filter(
|
|
88
|
+
(w) => w !== shown,
|
|
89
|
+
).length;
|
|
90
|
+
const hidden = { repos: hiddenRepos, ...extraHidden };
|
|
91
|
+
const what = `${shows.count} ${shows.noun}${shows.count === 1 ? "" : "s"}`;
|
|
92
|
+
const yours =
|
|
93
|
+
shown === scope.workspace ? "your scope" : `granted to ${scope.actor}`;
|
|
94
|
+
return {
|
|
95
|
+
workspace: shown,
|
|
96
|
+
actor: scope.actor,
|
|
97
|
+
...(shown !== scope.workspace ? { grantedFrom: scope.workspace } : {}),
|
|
98
|
+
...(scope.grants?.length ? { grants: scope.grants } : {}),
|
|
99
|
+
otherWorkspaces: others,
|
|
100
|
+
hidden,
|
|
101
|
+
note:
|
|
102
|
+
`${what} in ${shown} (${yours}); ` +
|
|
103
|
+
`${others} other workspace${others === 1 ? "" : "s"}` +
|
|
104
|
+
(hiddenRepos
|
|
105
|
+
? ` with ${hiddenRepos} more repo${hiddenRepos === 1 ? "" : "s"}`
|
|
106
|
+
: "") +
|
|
107
|
+
": summary only, see org_summary",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Honest health (MCP-2.4) ─────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export type RowState =
|
|
114
|
+
| "up"
|
|
115
|
+
| "down"
|
|
116
|
+
| "redirect"
|
|
117
|
+
| "pending"
|
|
118
|
+
| "unknown"
|
|
119
|
+
| "not-checked";
|
|
120
|
+
|
|
121
|
+
/** One /api/health row's state. */
|
|
122
|
+
export function rowState(row: Rec): RowState {
|
|
123
|
+
if (row.ok === true) return "up";
|
|
124
|
+
if (row.ok === false) return "down";
|
|
125
|
+
if (str(row.redirect) !== undefined) return "redirect";
|
|
126
|
+
if (row.pending === true) return "pending";
|
|
127
|
+
if (str(row.url) !== undefined || str(row.check) !== undefined)
|
|
128
|
+
return "unknown";
|
|
129
|
+
return "not-checked";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Why a row has the state it has, when that is not obvious from it. */
|
|
133
|
+
function rowWhy(row: Rec, state: RowState, env: string): string | undefined {
|
|
134
|
+
if (state === "redirect")
|
|
135
|
+
return `redirect: ${row.status ?? "3xx"} → ${String(row.redirect)} (answering; not counted as down)`;
|
|
136
|
+
if (state === "pending")
|
|
137
|
+
return `checked, no answer yet${str(row.error) ? ` (${String(row.error)})` : ""}`;
|
|
138
|
+
if (state === "unknown")
|
|
139
|
+
return `checked, but the check could not tell${str(row.error) ? `: ${String(row.error)}` : ""}`;
|
|
140
|
+
if (state === "not-checked")
|
|
141
|
+
return (
|
|
142
|
+
str(row.why) ??
|
|
143
|
+
notCheckedReason({
|
|
144
|
+
env,
|
|
145
|
+
runs: row.runs as never,
|
|
146
|
+
target: asRecord(row.target) as { env?: string },
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A health row with its `state`, and `why` when it has no plain verdict. */
|
|
153
|
+
export function annotateRow(row: unknown, env: string): Rec {
|
|
154
|
+
const r = asRecord(row);
|
|
155
|
+
const state = rowState(r);
|
|
156
|
+
const why = rowWhy(r, state, env);
|
|
157
|
+
return { ...r, state, ...(why ? { why } : {}) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface StateCounts {
|
|
161
|
+
services: number;
|
|
162
|
+
up: number;
|
|
163
|
+
down: number;
|
|
164
|
+
redirect: number;
|
|
165
|
+
pending: number;
|
|
166
|
+
unknown: number;
|
|
167
|
+
notChecked: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function countStates(states: RowState[]): StateCounts {
|
|
171
|
+
const c: StateCounts = {
|
|
172
|
+
services: states.length,
|
|
173
|
+
up: 0,
|
|
174
|
+
down: 0,
|
|
175
|
+
redirect: 0,
|
|
176
|
+
pending: 0,
|
|
177
|
+
unknown: 0,
|
|
178
|
+
notChecked: 0,
|
|
179
|
+
};
|
|
180
|
+
for (const s of states) {
|
|
181
|
+
if (s === "not-checked") c.notChecked++;
|
|
182
|
+
else c[s]++;
|
|
183
|
+
}
|
|
184
|
+
return c;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The `status` answer: counts, then every row with its state. */
|
|
188
|
+
export function statusView(rows: unknown, env: string): Rec {
|
|
189
|
+
const services = asArray(rows).map((r) => annotateRow(r, env));
|
|
190
|
+
return {
|
|
191
|
+
env,
|
|
192
|
+
counts: countStates(services.map((s) => s.state as RowState)),
|
|
193
|
+
services,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── The graph ───────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A scoped `graph` (MCP-2.2): the workspace's own repos and the edges out
|
|
201
|
+
* of them. A repo they depend on outside the workspace is named in
|
|
202
|
+
* `outside`; the org-wide map is `org_summary.graph`.
|
|
203
|
+
*/
|
|
204
|
+
export function graphInScope(graph: unknown, repos: readonly string[]): Rec {
|
|
205
|
+
const g = asRecord(graph);
|
|
206
|
+
const mine = new Set(repos);
|
|
207
|
+
const nodes = asArray(g.nodes).filter((n) => mine.has(String(n)));
|
|
208
|
+
const edges = asArray(g.edges).filter((e) =>
|
|
209
|
+
mine.has(String(asRecord(e).from)),
|
|
210
|
+
);
|
|
211
|
+
const outside = [
|
|
212
|
+
...new Set(
|
|
213
|
+
edges.map((e) => String(asRecord(e).to)).filter((to) => !mine.has(to)),
|
|
214
|
+
),
|
|
215
|
+
].sort();
|
|
216
|
+
return {
|
|
217
|
+
map: "workspace",
|
|
218
|
+
nodes,
|
|
219
|
+
edges,
|
|
220
|
+
...(Array.isArray(g.order)
|
|
221
|
+
? { order: g.order.filter((n) => mine.has(String(n))) }
|
|
222
|
+
: {}),
|
|
223
|
+
outside,
|
|
224
|
+
hiddenNodes: asArray(g.nodes).length - nodes.length,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The org-wide map from each active workspace's /api/graph (the web server
|
|
230
|
+
* derives a graph for one workspace: its repos, as that workspace checks
|
|
231
|
+
* them out). Nodes are the union; an edge two workspaces both derive is
|
|
232
|
+
* listed once (the first's). `order` is a startup order within one
|
|
233
|
+
* workspace, so it is kept only when there is one. A workspace whose graph
|
|
234
|
+
* failed is named in `unavailable`; when every one failed, the first error.
|
|
235
|
+
*/
|
|
236
|
+
export function mergeGraphs(
|
|
237
|
+
graphs: readonly { workspace: string; graph: unknown }[],
|
|
238
|
+
): Rec {
|
|
239
|
+
const ok = graphs.filter((x) => !str(asRecord(x.graph).error));
|
|
240
|
+
const unavailable = graphs
|
|
241
|
+
.filter((x) => str(asRecord(x.graph).error))
|
|
242
|
+
.map((x) => ({ workspace: x.workspace, error: asRecord(x.graph).error }));
|
|
243
|
+
if (!ok.length && unavailable.length) return { error: unavailable[0].error };
|
|
244
|
+
const nodes = new Set<string>();
|
|
245
|
+
const edges: unknown[] = [];
|
|
246
|
+
const seen = new Set<string>();
|
|
247
|
+
for (const { graph } of ok) {
|
|
248
|
+
const g = asRecord(graph);
|
|
249
|
+
for (const n of asArray(g.nodes)) nodes.add(String(n));
|
|
250
|
+
for (const e of asArray(g.edges)) {
|
|
251
|
+
const x = asRecord(e);
|
|
252
|
+
const key = `${String(x.from)}\0${String(x.to)}\0${String(x.kind)}`;
|
|
253
|
+
if (seen.has(key)) continue;
|
|
254
|
+
seen.add(key);
|
|
255
|
+
edges.push(e);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const only = ok.length === 1 ? asRecord(ok[0].graph) : null;
|
|
259
|
+
return {
|
|
260
|
+
map: "org-wide",
|
|
261
|
+
workspaces: ok.map((x) => x.workspace),
|
|
262
|
+
nodes: [...nodes].sort(),
|
|
263
|
+
edges,
|
|
264
|
+
...(only && Array.isArray(only.order) ? { order: only.order } : {}),
|
|
265
|
+
...(unavailable.length ? { unavailable } : {}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The org-wide graph with names and edge kinds only (no evidence). */
|
|
270
|
+
function graphSummary(graph: unknown): Rec {
|
|
271
|
+
const g = asRecord(graph);
|
|
272
|
+
if (str(g.error)) return { error: g.error };
|
|
273
|
+
return {
|
|
274
|
+
map: "org-wide",
|
|
275
|
+
...(Array.isArray(g.workspaces)
|
|
276
|
+
? { workspaces: g.workspaces.map(String) }
|
|
277
|
+
: {}),
|
|
278
|
+
nodes: asArray(g.nodes).map(String),
|
|
279
|
+
edges: asArray(g.edges).map((e) => {
|
|
280
|
+
const x = asRecord(e);
|
|
281
|
+
return {
|
|
282
|
+
from: String(x.from),
|
|
283
|
+
to: String(x.to),
|
|
284
|
+
kind: x.kind,
|
|
285
|
+
blocking: x.blocking,
|
|
286
|
+
};
|
|
287
|
+
}),
|
|
288
|
+
...(Array.isArray(g.order) ? { order: g.order.map(String) } : {}),
|
|
289
|
+
...(Array.isArray(g.unavailable)
|
|
290
|
+
? {
|
|
291
|
+
unavailable: g.unavailable.map((u) => ({
|
|
292
|
+
workspace: String(asRecord(u).workspace),
|
|
293
|
+
error: String(asRecord(u).error),
|
|
294
|
+
})),
|
|
295
|
+
}
|
|
296
|
+
: {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── The org summary (MCP-2.1) ───────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
export type WorkspaceState =
|
|
303
|
+
| "running"
|
|
304
|
+
| "partial"
|
|
305
|
+
| "stopped"
|
|
306
|
+
| "unknown"
|
|
307
|
+
| "inactive";
|
|
308
|
+
|
|
309
|
+
function workspaceState(active: boolean, c: StateCounts): WorkspaceState {
|
|
310
|
+
if (!active) return "inactive";
|
|
311
|
+
const answering = c.up + c.redirect;
|
|
312
|
+
if (answering > 0 && c.down === 0) return "running";
|
|
313
|
+
if (answering > 0) return "partial";
|
|
314
|
+
if (c.down > 0) return "stopped";
|
|
315
|
+
return "unknown";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Rows that belong to workspace `ws`: named in `workspaces`, else by repo. */
|
|
319
|
+
function rowsOf(rows: Rec[], ws: string, repos: Set<string>): Rec[] {
|
|
320
|
+
return rows.filter((r) =>
|
|
321
|
+
Array.isArray(r.workspaces)
|
|
322
|
+
? r.workspaces.includes(ws)
|
|
323
|
+
: repos.has(String(r.repo)),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function repoSummary(repo: string, rows: Rec[], active: boolean): Rec {
|
|
328
|
+
if (!active) return { repo, state: "inactive" };
|
|
329
|
+
const states = rows.map((r) => rowState(r));
|
|
330
|
+
const counts = countStates(states);
|
|
331
|
+
const rolled = rollupRepo(
|
|
332
|
+
repo,
|
|
333
|
+
rows.map((r, i) => ({
|
|
334
|
+
service: String(r.service),
|
|
335
|
+
ok: states[i] === "up" ? true : states[i] === "down" ? false : null,
|
|
336
|
+
isEntrypoint: r.isEntrypoint === true,
|
|
337
|
+
awaiting: states[i] === "pending",
|
|
338
|
+
})),
|
|
339
|
+
);
|
|
340
|
+
const state =
|
|
341
|
+
rolled.state === "unknown" && counts.redirect > 0
|
|
342
|
+
? "redirect"
|
|
343
|
+
: rolled.state;
|
|
344
|
+
return {
|
|
345
|
+
repo,
|
|
346
|
+
state,
|
|
347
|
+
services: counts.services,
|
|
348
|
+
up: counts.up,
|
|
349
|
+
down: counts.down,
|
|
350
|
+
...(counts.redirect ? { redirect: counts.redirect } : {}),
|
|
351
|
+
...(counts.notChecked ? { notChecked: counts.notChecked } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The edge in a few fields: up, domains, TLS and whether HTTPS serves. */
|
|
356
|
+
function edgeSummary(edge: unknown): Rec {
|
|
357
|
+
const e = asRecord(edge);
|
|
358
|
+
if (e.configured === false) return { configured: false };
|
|
359
|
+
if (str(e.error) && e.configured === undefined) return { error: e.error };
|
|
360
|
+
const tls = asRecord(e.tls);
|
|
361
|
+
const https = asRecord(e.https);
|
|
362
|
+
return {
|
|
363
|
+
configured: true,
|
|
364
|
+
up: e.up === true,
|
|
365
|
+
state: e.state,
|
|
366
|
+
domains: asArray(e.domains),
|
|
367
|
+
routes: asArray(e.routes).length,
|
|
368
|
+
https: {
|
|
369
|
+
strategy: https.strategy ?? tls.strategy ?? null,
|
|
370
|
+
...(https.certs !== undefined ? { certs: https.certs } : {}),
|
|
371
|
+
serving: typeof https.serving === "boolean" ? https.serving : null,
|
|
372
|
+
...(str(https.detail)
|
|
373
|
+
? { detail: https.detail }
|
|
374
|
+
: { detail: "the web server did not say (it predates 0.4.4)" }),
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface OrgSummaryInput {
|
|
380
|
+
workspaces: unknown;
|
|
381
|
+
/** /api/health for every active workspace (local). */
|
|
382
|
+
health: unknown;
|
|
383
|
+
graph: unknown;
|
|
384
|
+
edge: unknown;
|
|
385
|
+
environments: unknown;
|
|
386
|
+
/** Scoped: the agent's own workspace's /api/health rows (local). */
|
|
387
|
+
own?: unknown;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The org summary. For workspaces outside the scope only names and counts
|
|
392
|
+
* are copied: never a row's url, check, error, path or links, and never
|
|
393
|
+
* the graph's evidence.
|
|
394
|
+
*/
|
|
395
|
+
export function orgSummary(input: OrgSummaryInput, scope?: McpScope): Rec {
|
|
396
|
+
const body = asRecord(input.workspaces);
|
|
397
|
+
const active = new Set(asArray(body.active).map(String));
|
|
398
|
+
const focus = str(body.focus);
|
|
399
|
+
const rows = asArray(input.health).map(asRecord);
|
|
400
|
+
const names = workspaceNames(body);
|
|
401
|
+
const workspaces = names.map((ws) => {
|
|
402
|
+
const repos = workspaceRepos(body, ws);
|
|
403
|
+
const isActive = active.has(ws);
|
|
404
|
+
const mine = isActive ? rowsOf(rows, ws, new Set(repos)) : [];
|
|
405
|
+
const counts = countStates(mine.map((r) => rowState(r)));
|
|
406
|
+
return {
|
|
407
|
+
name: ws,
|
|
408
|
+
active: isActive,
|
|
409
|
+
...(focus === ws ? { focused: true } : {}),
|
|
410
|
+
...(scope?.workspace === ws ? { yours: true } : {}),
|
|
411
|
+
state: workspaceState(isActive, counts),
|
|
412
|
+
...(isActive ? { counts } : {}),
|
|
413
|
+
repos: repos.map((repo) =>
|
|
414
|
+
repoSummary(
|
|
415
|
+
repo,
|
|
416
|
+
mine.filter((r) => String(r.repo) === repo),
|
|
417
|
+
isActive,
|
|
418
|
+
),
|
|
419
|
+
),
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
const repoCount = orgRepos(body).size;
|
|
423
|
+
const activeCount = workspaces.filter((w) => w.active).length;
|
|
424
|
+
const out: Rec = {
|
|
425
|
+
summary:
|
|
426
|
+
`${names.length} workspace${names.length === 1 ? "" : "s"} ` +
|
|
427
|
+
`(${activeCount} active), ${repoCount} repo${repoCount === 1 ? "" : "s"}. ` +
|
|
428
|
+
"Counts are local health; an inactive workspace is not checked." +
|
|
429
|
+
(scope
|
|
430
|
+
? ` Your scope: ${scope.workspace} (its services are in "yours"); ` +
|
|
431
|
+
"other workspaces are names and counts only."
|
|
432
|
+
: ""),
|
|
433
|
+
workspaces,
|
|
434
|
+
graph: graphSummary(input.graph),
|
|
435
|
+
edge: edgeSummary(input.edge),
|
|
436
|
+
environments: asArray(input.environments).map(String),
|
|
437
|
+
};
|
|
438
|
+
if (scope) {
|
|
439
|
+
out.scope = {
|
|
440
|
+
workspace: scope.workspace,
|
|
441
|
+
actor: scope.actor,
|
|
442
|
+
...(scope.grants?.length ? { grants: scope.grants } : {}),
|
|
443
|
+
};
|
|
444
|
+
out.yours = {
|
|
445
|
+
workspace: scope.workspace,
|
|
446
|
+
...statusView(input.own, "local"),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Workspaces page's Agents section (AG-4a, AG spec §5c), its routes:
|
|
3
3
|
*
|
|
4
|
-
* GET /api/personas
|
|
4
|
+
* GET /api/personas[?workspace=<ws>] personas and runtimes (each
|
|
5
|
+
* with its path, where it came
|
|
6
|
+
* from and its version: AG-0.2;
|
|
7
|
+
* for <ws>, its own paths win)
|
|
8
|
+
* GET /api/agents/runtimes[?workspace=<ws>] the runtime catalog as
|
|
9
|
+
* Settings › Agents lists it
|
|
10
|
+
* POST /api/agents/runtimes/<id>/path { scope: global|workspace,
|
|
11
|
+
* workspace?, bin: path|null }:
|
|
12
|
+
* runs its version command and
|
|
13
|
+
* refuses a path that isn't
|
|
14
|
+
* that runtime (AG-0.5)
|
|
5
15
|
* GET /api/workspaces/<ws>/agents the workspace's agents
|
|
6
16
|
* POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
|
|
7
17
|
* POST /api/workspaces/<ws>/agents/<id>/stop
|
|
@@ -12,14 +22,15 @@
|
|
|
12
22
|
* `<cli> agents list -w <ws>` shows, and the other way round. A workspace
|
|
13
23
|
* lists its own agents only: another workspace's id is no agent here.
|
|
14
24
|
*
|
|
15
|
-
* Guards on Start, Stop and Resume
|
|
16
|
-
* - the lock (R8): locked, the action is refused (423 locked); the page
|
|
17
|
-
* opens the lock popover first, as Local Dev's service actions do;
|
|
25
|
+
* Guards on Start, Stop and Resume:
|
|
18
26
|
* - a person only: an agent (its actor token, SEC-1c) can't start or
|
|
19
27
|
* stop agents from the page (403; agents use MCP agent_spawn, §5,
|
|
20
28
|
* under its own guards);
|
|
21
29
|
* - agents.max_concurrent (the host's cap on agents, never plain
|
|
22
30
|
* sessions: 429 cap).
|
|
31
|
+
* The admin password (R8) is no guard here (LK-0): it is for sudo, and
|
|
32
|
+
* nothing an agent route does runs sudo. Starting, stopping and resuming
|
|
33
|
+
* an agent never asks for it.
|
|
23
34
|
* The env is the CLI's (§5a-env): the allowlist and the runtime's, built
|
|
24
35
|
* from this server's environment, never all of it. Every action, refused
|
|
25
36
|
* or done, is audited (recordAction) with the person as its actor.
|
|
@@ -42,6 +53,7 @@ import {
|
|
|
42
53
|
listPersonas,
|
|
43
54
|
listRuntimes,
|
|
44
55
|
NOT_IN_WORKSPACE,
|
|
56
|
+
setRuntimePath,
|
|
45
57
|
resolveAgentsDeps,
|
|
46
58
|
resumeAgent,
|
|
47
59
|
startAgent,
|
|
@@ -53,18 +65,16 @@ import {
|
|
|
53
65
|
import { recordAction } from "./audit.js";
|
|
54
66
|
import { auditActorOf, principalOf } from "./request-guard.js";
|
|
55
67
|
import { readBody, sendJson } from "./http-helpers.js";
|
|
56
|
-
import { hasPasswordlessSudo, hasSudoPassword } from "./sudo-store.js";
|
|
57
68
|
import type { Actor } from "../../shared/agent-actor.js";
|
|
58
69
|
|
|
59
70
|
export interface AgentRouteDeps {
|
|
60
71
|
/** The operations' deps (tests: an in-process host, a temp state dir). */
|
|
61
72
|
agents?: AgentsDeps;
|
|
62
|
-
/** R8: whether the lock is open (the lock chip's own status). */
|
|
63
|
-
unlocked?: () => boolean;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
const AGENTS_PATH =
|
|
67
76
|
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume))?$/;
|
|
77
|
+
const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
|
|
68
78
|
/** A start's body is a few short names. */
|
|
69
79
|
const MAX_BODY = 4096;
|
|
70
80
|
|
|
@@ -87,11 +97,6 @@ function webDeps(over: AgentsDeps = {}): AgentsResolved {
|
|
|
87
97
|
});
|
|
88
98
|
}
|
|
89
99
|
|
|
90
|
-
/** The lock chip's state: an admin password set, or sudo without one. */
|
|
91
|
-
function defaultUnlocked(): boolean {
|
|
92
|
-
return hasSudoPassword() || hasPasswordlessSudo();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
100
|
/** An AgentError (or the host's refusal) as the R26 answer. */
|
|
96
101
|
function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
|
|
97
102
|
if (e instanceof AgentError) {
|
|
@@ -197,14 +202,31 @@ export async function handleAgentRoutes(
|
|
|
197
202
|
deps: AgentRouteDeps = {},
|
|
198
203
|
): Promise<boolean> {
|
|
199
204
|
const m = AGENTS_PATH.exec(pathname);
|
|
200
|
-
|
|
205
|
+
const rp = RUNTIME_PATH.exec(pathname);
|
|
206
|
+
if (
|
|
207
|
+
pathname !== "/api/personas" &&
|
|
208
|
+
pathname !== "/api/agents/runtimes" &&
|
|
209
|
+
!m &&
|
|
210
|
+
!rp
|
|
211
|
+
)
|
|
212
|
+
return false;
|
|
201
213
|
const method = req.method ?? "GET";
|
|
202
214
|
const d = webDeps(deps.agents);
|
|
203
215
|
|
|
216
|
+
if (rp) return runtimePath(req, res, safeDecode(rp[1]), d);
|
|
204
217
|
if (!m) {
|
|
205
|
-
if (method !== "GET"
|
|
218
|
+
if (method !== "GET" && method !== "HEAD")
|
|
219
|
+
return methodNotAllowed(res, "GET");
|
|
206
220
|
try {
|
|
207
|
-
|
|
221
|
+
const ws = queryWorkspace(req, d);
|
|
222
|
+
const runtimes = await listRuntimes(d, ws);
|
|
223
|
+
sendJson(res, {
|
|
224
|
+
...(pathname === "/api/personas" ? { personas: listPersonas(d) } : {}),
|
|
225
|
+
runtimes,
|
|
226
|
+
...(ws ? { workspace: ws } : {}),
|
|
227
|
+
// AG-0.2: the file a person edits to set a runtime's path.
|
|
228
|
+
userConfig: d.userConfigFile(),
|
|
229
|
+
});
|
|
208
230
|
} catch (e) {
|
|
209
231
|
sendError(res, e);
|
|
210
232
|
}
|
|
@@ -257,9 +279,9 @@ export async function handleAgentRoutes(
|
|
|
257
279
|
let what = `${verb} ${id ?? "?"} in ${ws}`;
|
|
258
280
|
try {
|
|
259
281
|
// Another workspace's agent is no agent here: a 404 that doesn't name
|
|
260
|
-
// it, before
|
|
282
|
+
// it, before anything else is said about it.
|
|
261
283
|
if (id !== undefined) agentRecord(d, id, ws);
|
|
262
|
-
guard(actor
|
|
284
|
+
guard(actor);
|
|
263
285
|
if (verb === "start") {
|
|
264
286
|
const body = await startBody(req);
|
|
265
287
|
if (!body.persona)
|
|
@@ -291,22 +313,97 @@ export async function handleAgentRoutes(
|
|
|
291
313
|
return true;
|
|
292
314
|
}
|
|
293
315
|
|
|
316
|
+
/** `?workspace=<ws>`, when it names a workspace here; else none. */
|
|
317
|
+
function queryWorkspace(
|
|
318
|
+
req: http.IncomingMessage,
|
|
319
|
+
d: AgentsResolved,
|
|
320
|
+
): string | undefined {
|
|
321
|
+
const ws = new URL(req.url ?? "/", "http://x").searchParams.get("workspace");
|
|
322
|
+
if (!ws) return undefined;
|
|
323
|
+
if (!d.workspaceExists(ws))
|
|
324
|
+
throw new AgentError(`workspace "${ws}" does not exist`, { status: 404 });
|
|
325
|
+
return ws;
|
|
326
|
+
}
|
|
327
|
+
|
|
294
328
|
/**
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* an x-den-actor header alone is an unverified claim, not an agent.
|
|
329
|
+
* POST /api/agents/runtimes/<id>/path: a person only, never an agent (it
|
|
330
|
+
* runs the path given). Audited, refused or done; never the path's output.
|
|
298
331
|
*/
|
|
299
|
-
function
|
|
332
|
+
async function runtimePath(
|
|
333
|
+
req: http.IncomingMessage,
|
|
334
|
+
res: http.ServerResponse,
|
|
335
|
+
id: string,
|
|
336
|
+
d: AgentsResolved,
|
|
337
|
+
): Promise<true> {
|
|
338
|
+
if (req.method !== "POST") return methodNotAllowed(res, "POST");
|
|
339
|
+
const actor = auditActorOf(req);
|
|
340
|
+
let what = `runtime ${id} path`;
|
|
341
|
+
const audit = (reason?: string) =>
|
|
342
|
+
recordAction(
|
|
343
|
+
actor,
|
|
344
|
+
{
|
|
345
|
+
verb: "agents",
|
|
346
|
+
detail: what,
|
|
347
|
+
...(reason ? { outcome: "refused" as const, reason } : {}),
|
|
348
|
+
},
|
|
349
|
+
principalOf(req),
|
|
350
|
+
);
|
|
351
|
+
try {
|
|
352
|
+
if (actor && !actor.unverified)
|
|
353
|
+
throw new AgentError("an agent can't set a runtime's path", {
|
|
354
|
+
status: 403,
|
|
355
|
+
});
|
|
356
|
+
const raw = await readBody(req).catch(() => "");
|
|
357
|
+
if (raw.length > MAX_BODY)
|
|
358
|
+
throw new AgentError("request too large", { status: 413, usage: true });
|
|
359
|
+
let v: unknown;
|
|
360
|
+
try {
|
|
361
|
+
v = JSON.parse(raw || "{}");
|
|
362
|
+
} catch {
|
|
363
|
+
throw new AgentError("invalid JSON", { usage: true });
|
|
364
|
+
}
|
|
365
|
+
const b = (v && typeof v === "object" ? v : {}) as Record<string, unknown>;
|
|
366
|
+
const scope = b.scope === "workspace" ? "workspace" : "global";
|
|
367
|
+
if (
|
|
368
|
+
b.scope !== undefined &&
|
|
369
|
+
b.scope !== "global" &&
|
|
370
|
+
b.scope !== "workspace"
|
|
371
|
+
)
|
|
372
|
+
throw new AgentError('scope must be "global" or "workspace"', {
|
|
373
|
+
usage: true,
|
|
374
|
+
});
|
|
375
|
+
if (b.bin !== null && typeof b.bin !== "string")
|
|
376
|
+
throw new AgentError("bin must be an absolute path, or null to clear", {
|
|
377
|
+
usage: true,
|
|
378
|
+
});
|
|
379
|
+
const ws = typeof b.workspace === "string" ? b.workspace : undefined;
|
|
380
|
+
what = `runtime ${id} path (${scope === "workspace" ? `workspace ${ws ?? "?"}` : "global"}${b.bin === null ? ", cleared" : ""})`;
|
|
381
|
+
const row = await setRuntimePath(d, {
|
|
382
|
+
id,
|
|
383
|
+
scope,
|
|
384
|
+
...(ws ? { workspace: ws } : {}),
|
|
385
|
+
bin: b.bin === null ? null : b.bin.trim(),
|
|
386
|
+
});
|
|
387
|
+
audit();
|
|
388
|
+
sendJson(res, { ok: true, runtime: row });
|
|
389
|
+
} catch (e) {
|
|
390
|
+
audit(e instanceof Error ? e.message : String(e));
|
|
391
|
+
sendError(res, e);
|
|
392
|
+
}
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The actor: a person. An agent is one its actor token proves (the request
|
|
398
|
+
* checks already refuse its start and resume); an x-den-actor header alone
|
|
399
|
+
* is an unverified claim, not an agent.
|
|
400
|
+
*/
|
|
401
|
+
function guard(actor: Actor): void {
|
|
300
402
|
if (actor && !actor.unverified)
|
|
301
403
|
throw new AgentError(
|
|
302
404
|
"an agent can't start, stop or resume agents from the page; agents use MCP agent_spawn",
|
|
303
405
|
{ status: 403 },
|
|
304
406
|
);
|
|
305
|
-
if (!unlocked())
|
|
306
|
-
throw new AgentError("Unlock first: agent actions need the lock open", {
|
|
307
|
-
status: 423,
|
|
308
|
-
code: "locked",
|
|
309
|
-
});
|
|
310
407
|
}
|
|
311
408
|
|
|
312
409
|
function safeDecode(s: string): string {
|