@monster-orange/den 0.2.7 → 0.3.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 +1655 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +619 -3
- 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 +9 -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/agent-actor.ts +38 -3
- 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 +63 -0
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +185 -7
- 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/environment-manager/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +26 -3
- package/src/shared/environment-manager/git-remote.ts +49 -0
- 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/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +328 -0
- 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/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/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 +24 -5
- package/src/ui/cli/clone-plan.ts +154 -23
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +62 -3
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +649 -72
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/org-command.ts +12 -2
- package/src/ui/cli/plugin-command.ts +849 -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/up-args.ts +15 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +298 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +151 -24
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +90 -3
- 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 +29 -45
- 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 +590 -45
- 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 +15 -3
- 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 +24 -4
- package/src/ui/server/release-service.ts +25 -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 +55 -9
- 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/tools.ts
CHANGED
|
@@ -33,12 +33,27 @@ import {
|
|
|
33
33
|
type ServiceEntryLike,
|
|
34
34
|
type SecurityPlanLike,
|
|
35
35
|
} from "./guards.js";
|
|
36
|
+
import {
|
|
37
|
+
ScopeRefusal,
|
|
38
|
+
assertPathInScope,
|
|
39
|
+
assertScopeExists,
|
|
40
|
+
assertScopeFocused,
|
|
41
|
+
assertServicesInScope,
|
|
42
|
+
filterWorkspacesBody,
|
|
43
|
+
pinWorkspace,
|
|
44
|
+
type McpScope,
|
|
45
|
+
} from "./scope.js";
|
|
36
46
|
|
|
37
47
|
export interface ToolContext {
|
|
38
48
|
api: DenApi;
|
|
39
49
|
cfg: ResolvedMcpConfig;
|
|
40
50
|
/** The CLI name, for messages (`<cli> web up`). */
|
|
41
51
|
cli: string;
|
|
52
|
+
/**
|
|
53
|
+
* A scoped server's workspace and actor (AG-2a-core); absent, every tool
|
|
54
|
+
* behaves as it always has.
|
|
55
|
+
*/
|
|
56
|
+
scope?: McpScope;
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
type Args = Record<string, unknown>;
|
|
@@ -193,7 +208,10 @@ const READ_TOOLS: DenTool[] = [
|
|
|
193
208
|
"Workspaces",
|
|
194
209
|
"Workspaces, which are active and which is focused.",
|
|
195
210
|
{},
|
|
196
|
-
(_a, { api }) =>
|
|
211
|
+
async (_a, { api, scope }) => {
|
|
212
|
+
const body = await api.get("/api/workspaces");
|
|
213
|
+
return scope ? filterWorkspacesBody(body, scope) : body;
|
|
214
|
+
},
|
|
197
215
|
),
|
|
198
216
|
read(
|
|
199
217
|
"environments",
|
|
@@ -215,9 +233,19 @@ const READ_TOOLS: DenTool[] = [
|
|
|
215
233
|
.describe("repo/service names"),
|
|
216
234
|
env: envArg,
|
|
217
235
|
},
|
|
218
|
-
async (a, { api, cfg }) => {
|
|
236
|
+
async (a, { api, cfg, scope }) => {
|
|
219
237
|
const env = envOf(a);
|
|
220
238
|
assertLogsEnv(env, cfg);
|
|
239
|
+
if (scope)
|
|
240
|
+
await assertServicesInScope(
|
|
241
|
+
"logs_search",
|
|
242
|
+
api,
|
|
243
|
+
scope,
|
|
244
|
+
(a.targets as string[]).map((t) => {
|
|
245
|
+
const slash = t.lastIndexOf("/");
|
|
246
|
+
return { repo: t.slice(0, slash), service: t.slice(slash + 1) };
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
221
249
|
return api.get("/api/logs/search", {
|
|
222
250
|
q: String(a.q),
|
|
223
251
|
env,
|
|
@@ -241,9 +269,13 @@ const READ_TOOLS: DenTool[] = [
|
|
|
241
269
|
.describe("Lines to return (default 200)"),
|
|
242
270
|
env: envArg,
|
|
243
271
|
},
|
|
244
|
-
async (a, { api, cfg }) => {
|
|
272
|
+
async (a, { api, cfg, scope }) => {
|
|
245
273
|
const env = envOf(a);
|
|
246
274
|
assertLogsEnv(env, cfg);
|
|
275
|
+
if (scope)
|
|
276
|
+
await assertServicesInScope("logs_tail", api, scope, [
|
|
277
|
+
{ repo: String(a.repo), service: String(a.service) },
|
|
278
|
+
]);
|
|
247
279
|
const n = (a.lines as number | undefined) ?? 200;
|
|
248
280
|
const query: Query = { repo: String(a.repo), service: String(a.service) };
|
|
249
281
|
const r =
|
|
@@ -270,11 +302,17 @@ const READ_TOOLS: DenTool[] = [
|
|
|
270
302
|
"Available tests",
|
|
271
303
|
"The test modules of a pillar, and which repos (branch + SHA) they come from.",
|
|
272
304
|
{ pillar: z.enum(PILLARS) },
|
|
273
|
-
async (a, { api }) =>
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
305
|
+
async (a, { api, scope }) => {
|
|
306
|
+
if (scope) await assertScopeFocused("tests_available", api, scope);
|
|
307
|
+
return {
|
|
308
|
+
modules: await api.get("/api/modules", { pillar: String(a.pillar) }),
|
|
309
|
+
sources: await api.get("/api/test-sources", {
|
|
310
|
+
pillar: String(a.pillar),
|
|
311
|
+
}),
|
|
312
|
+
};
|
|
313
|
+
},
|
|
277
314
|
),
|
|
315
|
+
|
|
278
316
|
read(
|
|
279
317
|
"security_latest",
|
|
280
318
|
"Latest security scans",
|
|
@@ -307,13 +345,20 @@ const READ_TOOLS: DenTool[] = [
|
|
|
307
345
|
path: z.string().optional().describe("Repo directory or manifest file"),
|
|
308
346
|
content: z.string().optional().describe("Manifest YAML to validate"),
|
|
309
347
|
},
|
|
310
|
-
async (a, { api }) => {
|
|
348
|
+
async (a, { api, scope }) => {
|
|
311
349
|
if (str(a.content) !== undefined)
|
|
312
350
|
return api.get("/api/manifest/validate", {}, "POST", {
|
|
313
351
|
content: a.content,
|
|
314
352
|
});
|
|
315
353
|
if (str(a.path) === undefined)
|
|
316
354
|
throw new Refusal("give a path or the manifest content.");
|
|
355
|
+
if (scope)
|
|
356
|
+
await assertPathInScope(
|
|
357
|
+
"manifest_validate",
|
|
358
|
+
api,
|
|
359
|
+
scope,
|
|
360
|
+
String(a.path),
|
|
361
|
+
);
|
|
317
362
|
return api.get("/api/manifest/validate", { path: str(a.path) });
|
|
318
363
|
},
|
|
319
364
|
),
|
|
@@ -447,19 +492,27 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
|
447
492
|
shape: {
|
|
448
493
|
repo: z.string().optional().describe("One managed repo (default: all)"),
|
|
449
494
|
},
|
|
450
|
-
async run(a, { api }) {
|
|
495
|
+
async run(a, { api, scope }) {
|
|
451
496
|
const repo = str(a.repo);
|
|
497
|
+
// Scoped: the sync names the scoped workspace (the web server's own
|
|
498
|
+
// default is the focused one).
|
|
499
|
+
const ws = scope ? { workspace: scope.workspace } : {};
|
|
452
500
|
if (repo) {
|
|
453
501
|
const services = (await api.get("/api/services", {
|
|
454
502
|
env: "local",
|
|
503
|
+
...ws,
|
|
455
504
|
})) as ServiceEntryLike[];
|
|
456
505
|
assertSyncableRepo(repo, services);
|
|
457
506
|
}
|
|
458
|
-
const r = await api.stream(
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
507
|
+
const r = await api.stream(
|
|
508
|
+
"/api/workspace/sync",
|
|
509
|
+
repo ? { repo, ...ws } : { ...ws },
|
|
510
|
+
{
|
|
511
|
+
method: "POST",
|
|
512
|
+
maxLines: 5000,
|
|
513
|
+
timeoutMs: 15 * 60_000,
|
|
514
|
+
},
|
|
515
|
+
);
|
|
463
516
|
return actionResult(r, 300);
|
|
464
517
|
},
|
|
465
518
|
},
|
|
@@ -478,13 +531,14 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
|
478
531
|
.optional()
|
|
479
532
|
.describe("Security scan mode (default baseline)"),
|
|
480
533
|
},
|
|
481
|
-
async run(a, { api, cfg }) {
|
|
534
|
+
async run(a, { api, cfg, scope }) {
|
|
482
535
|
const pillar = String(a.pillar);
|
|
483
536
|
const env = envOf(a);
|
|
484
537
|
const modules = (a.modules as string[] | undefined) ?? [];
|
|
485
538
|
const mode =
|
|
486
539
|
pillar === "security" ? (str(a.mode) ?? "baseline") : undefined;
|
|
487
540
|
assertRunTestsAllowed({ pillar, env, mode }, cfg);
|
|
541
|
+
if (scope) await assertScopeFocused("run_tests", api, scope);
|
|
488
542
|
if (pillar === "security") {
|
|
489
543
|
for (const service of modules.length ? modules : [undefined]) {
|
|
490
544
|
const plan = (await api.get("/api/security/plan", {
|
|
@@ -547,13 +601,17 @@ export function versionBefore(a: string, b: string): boolean {
|
|
|
547
601
|
return false;
|
|
548
602
|
}
|
|
549
603
|
|
|
550
|
-
/** The running web server's
|
|
551
|
-
async function
|
|
604
|
+
/** The running web server's /api/server-info, or null when it has none. */
|
|
605
|
+
async function readServerInfo(
|
|
606
|
+
api: DenApi,
|
|
607
|
+
): Promise<Record<string, unknown> | null> {
|
|
552
608
|
try {
|
|
553
|
-
const info = (await api.get("/api/server-info")) as
|
|
554
|
-
return typeof info
|
|
609
|
+
const info = (await api.get("/api/server-info")) as unknown;
|
|
610
|
+
return info && typeof info === "object"
|
|
611
|
+
? (info as Record<string, unknown>)
|
|
612
|
+
: null;
|
|
555
613
|
} catch (e) {
|
|
556
|
-
if (e instanceof DenHttpError) return
|
|
614
|
+
if (e instanceof DenHttpError) return null;
|
|
557
615
|
throw e;
|
|
558
616
|
}
|
|
559
617
|
}
|
|
@@ -567,11 +625,13 @@ async function notFoundMessage(
|
|
|
567
625
|
e: DenHttpError,
|
|
568
626
|
tool: string,
|
|
569
627
|
ctx: ToolContext,
|
|
628
|
+
info: () => Promise<Record<string, unknown> | null>,
|
|
570
629
|
): Promise<string> {
|
|
571
630
|
const plain = `Error: ${tool}: the web server answered 404 for ${e.method} ${e.path}: ${e.body}`;
|
|
572
631
|
const since = ROUTE_SINCE[e.path];
|
|
573
632
|
if (!since) return plain;
|
|
574
|
-
const
|
|
633
|
+
const v = (await info())?.version;
|
|
634
|
+
const version = typeof v === "string" ? v : undefined;
|
|
575
635
|
if (version !== undefined && !versionBefore(version, since)) return plain;
|
|
576
636
|
return (
|
|
577
637
|
`web server is Den ${version ?? `< ${since}`}; ` +
|
|
@@ -599,6 +659,10 @@ async function reportRefusal(
|
|
|
599
659
|
tool,
|
|
600
660
|
...(typeof args.repo === "string" ? { repo: args.repo } : {}),
|
|
601
661
|
...(typeof args.service === "string" ? { service: args.service } : {}),
|
|
662
|
+
...(Array.isArray(args.targets) ? { targets: args.targets } : {}),
|
|
663
|
+
// A path (manifest_validate): the server records it under the catalog
|
|
664
|
+
// repo it is inside, never as the path itself.
|
|
665
|
+
...(typeof args.path === "string" ? { path: args.path } : {}),
|
|
602
666
|
reason,
|
|
603
667
|
});
|
|
604
668
|
} catch {
|
|
@@ -606,6 +670,51 @@ async function reportRefusal(
|
|
|
606
670
|
}
|
|
607
671
|
}
|
|
608
672
|
|
|
673
|
+
/**
|
|
674
|
+
* MD-2: when the web server runs an older engine than the one on disk (a
|
|
675
|
+
* pin upgraded Den under it), every failed call says so, naming both
|
|
676
|
+
* versions and the restart. Null when the engines agree or it can't tell.
|
|
677
|
+
*/
|
|
678
|
+
function staleEngineMessage(
|
|
679
|
+
tool: string,
|
|
680
|
+
ctx: ToolContext,
|
|
681
|
+
info: Record<string, unknown> | null,
|
|
682
|
+
): string | null {
|
|
683
|
+
if (info?.engineStale !== true) return null;
|
|
684
|
+
const ver = (x: unknown) =>
|
|
685
|
+
x &&
|
|
686
|
+
typeof x === "object" &&
|
|
687
|
+
typeof (x as { version?: unknown }).version === "string"
|
|
688
|
+
? (x as { version: string }).version
|
|
689
|
+
: "unknown";
|
|
690
|
+
return (
|
|
691
|
+
`Error: ${tool}: Den was updated to ${ver(info.onDisk)}, but the web server ` +
|
|
692
|
+
`is still running ${ver(info.loaded)}. Restart it: ${ctx.cli} web restart ` +
|
|
693
|
+
`(or your service restart).`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* A scoped server's arguments: the workspace exists (checked once per
|
|
699
|
+
* server), and a tool that takes a workspace gets the scoped one; naming
|
|
700
|
+
* another is refused.
|
|
701
|
+
*/
|
|
702
|
+
const verifiedScopes = new WeakSet<McpScope>();
|
|
703
|
+
async function scopeArgs(
|
|
704
|
+
tool: DenTool,
|
|
705
|
+
args: Args,
|
|
706
|
+
ctx: ToolContext,
|
|
707
|
+
scope: McpScope,
|
|
708
|
+
): Promise<Args> {
|
|
709
|
+
if (!verifiedScopes.has(scope)) {
|
|
710
|
+
await assertScopeExists(ctx.api, scope);
|
|
711
|
+
verifiedScopes.add(scope);
|
|
712
|
+
}
|
|
713
|
+
return "workspace" in tool.shape
|
|
714
|
+
? pinWorkspace(tool.name, args, scope)
|
|
715
|
+
: args;
|
|
716
|
+
}
|
|
717
|
+
|
|
609
718
|
export async function callTool(
|
|
610
719
|
name: string,
|
|
611
720
|
args: Args,
|
|
@@ -618,7 +727,10 @@ export async function callTool(
|
|
|
618
727
|
isError: true,
|
|
619
728
|
};
|
|
620
729
|
try {
|
|
621
|
-
const out = await tool.run(
|
|
730
|
+
const out = await tool.run(
|
|
731
|
+
ctx.scope ? await scopeArgs(tool, args, ctx, ctx.scope) : args,
|
|
732
|
+
ctx,
|
|
733
|
+
);
|
|
622
734
|
const text = typeof out === "string" ? out : JSON.stringify(out, null, 2);
|
|
623
735
|
return { text: redactSecrets(text), isError: false };
|
|
624
736
|
} catch (e) {
|
|
@@ -627,13 +739,28 @@ export async function callTool(
|
|
|
627
739
|
text: redactSecrets(JSON.stringify(e.out, null, 2)),
|
|
628
740
|
isError: true,
|
|
629
741
|
};
|
|
742
|
+
// server-info is read lazily, once, and only after a call failed.
|
|
743
|
+
let infoRead: Promise<Record<string, unknown> | null> | null = null;
|
|
744
|
+
const info = () => (infoRead ??= readServerInfo(ctx.api).catch(() => null));
|
|
745
|
+
if (e instanceof DenHttpError) {
|
|
746
|
+
const stale = staleEngineMessage(name, ctx, await info());
|
|
747
|
+
if (stale)
|
|
748
|
+
return {
|
|
749
|
+
text: redactSecrets(
|
|
750
|
+
`${stale} (${e.method} ${e.path} answered ${e.status}: ${e.body})`,
|
|
751
|
+
),
|
|
752
|
+
isError: true,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
630
755
|
if (e instanceof DenHttpError && e.status === 404) {
|
|
631
|
-
const text = await notFoundMessage(e, name, ctx).catch(
|
|
756
|
+
const text = await notFoundMessage(e, name, ctx, info).catch(
|
|
632
757
|
(inner: Error) => `Error: ${inner.message}`,
|
|
633
758
|
);
|
|
634
759
|
return { text: redactSecrets(text), isError: true };
|
|
635
760
|
}
|
|
636
|
-
|
|
761
|
+
// A call outside a scoped server's workspace is recorded whatever the
|
|
762
|
+
// tool: it is an attempt to cross the boundary (AG-2a-core).
|
|
763
|
+
if (e instanceof Refusal && (tool.action || e instanceof ScopeRefusal))
|
|
637
764
|
await reportRefusal(ctx, name, args, e.message);
|
|
638
765
|
const msg =
|
|
639
766
|
e instanceof Refusal
|
|
@@ -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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* as the web-server log, so agent actions sit beside the UI's own; the
|
|
6
6
|
* structured entries are kept in a small in-memory ring.
|
|
7
7
|
*/
|
|
8
|
+
import * as path from "path";
|
|
8
9
|
import {
|
|
9
10
|
actorLabel,
|
|
10
11
|
type Actor,
|
|
@@ -20,6 +21,10 @@ export interface AuditEntry {
|
|
|
20
21
|
clientVersion?: string;
|
|
21
22
|
mcpPid?: number | null;
|
|
22
23
|
session?: string;
|
|
24
|
+
/** A scoped MCP server's actor, `agent:<id>` (AG-2a-core). */
|
|
25
|
+
agent?: string;
|
|
26
|
+
/** The workspace that scoped server is pinned to. */
|
|
27
|
+
workspace?: string;
|
|
23
28
|
/** exec | run | sync, or the MCP tool name for a refused non-lifecycle call */
|
|
24
29
|
verb: string;
|
|
25
30
|
detail: string;
|
|
@@ -27,6 +32,11 @@ export interface AuditEntry {
|
|
|
27
32
|
outcome?: "refused";
|
|
28
33
|
/** Why it was refused (first line). */
|
|
29
34
|
reason?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The services (`repo/service`) or repos a refused non-lifecycle call
|
|
37
|
+
* named, so each one's activity list shows it (`GET /api/activity`).
|
|
38
|
+
*/
|
|
39
|
+
addresses?: string[];
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
type Sink = (line: string, actor: AgentActor | null) => void;
|
|
@@ -52,6 +62,7 @@ export function recordAction(
|
|
|
52
62
|
detail: string;
|
|
53
63
|
outcome?: "refused";
|
|
54
64
|
reason?: string;
|
|
65
|
+
addresses?: string[];
|
|
55
66
|
},
|
|
56
67
|
): AuditEntry {
|
|
57
68
|
const entry: AuditEntry = {
|
|
@@ -63,19 +74,28 @@ export function recordAction(
|
|
|
63
74
|
clientVersion: actor.clientVersion,
|
|
64
75
|
mcpPid: actor.mcpPid,
|
|
65
76
|
session: actor.session,
|
|
77
|
+
...(actor.agent ? { agent: actor.agent } : {}),
|
|
78
|
+
...(actor.workspace ? { workspace: actor.workspace } : {}),
|
|
66
79
|
}
|
|
67
80
|
: {}),
|
|
68
81
|
verb: action.verb,
|
|
69
82
|
detail: action.detail,
|
|
70
83
|
...(action.outcome ? { outcome: action.outcome } : {}),
|
|
71
84
|
...(action.reason ? { reason: clean(action.reason, 300) } : {}),
|
|
85
|
+
...(action.addresses?.length ? { addresses: action.addresses } : {}),
|
|
72
86
|
};
|
|
73
87
|
ring.push(entry);
|
|
74
88
|
if (ring.length > RING_MAX) ring.shift();
|
|
75
89
|
const mark = entry.outcome === "refused" ? "✗" : "▶";
|
|
76
90
|
const why = entry.reason ? ` — refused: ${entry.reason}` : "";
|
|
91
|
+
// A refusal whose detail is its reason names what it touched instead.
|
|
92
|
+
const what = entry.addresses
|
|
93
|
+
? entry.addresses.join(", ")
|
|
94
|
+
: entry.reason && action.detail === entry.reason
|
|
95
|
+
? ""
|
|
96
|
+
: action.detail;
|
|
77
97
|
sink?.(
|
|
78
|
-
`${mark} ${action.verb} ${
|
|
98
|
+
`${mark} ${action.verb}${what ? ` ${what}` : ""}${why}${actorSuffix(actor)}`,
|
|
79
99
|
actor,
|
|
80
100
|
);
|
|
81
101
|
return entry;
|
|
@@ -92,12 +112,51 @@ function clean(v: unknown, max: number): string {
|
|
|
92
112
|
|
|
93
113
|
const SAFE_PART = /^[A-Za-z0-9._-]{1,128}$/;
|
|
94
114
|
const LIFECYCLE_TOOL = /^service_(up|down|restart)$/;
|
|
115
|
+
/** At most this many targets of one refused call are recorded. */
|
|
116
|
+
const MAX_ADDRESSES = 40;
|
|
117
|
+
|
|
118
|
+
/** `repo/service` (or `repo`) when every part is a safe name, else "". */
|
|
119
|
+
function safeAddress(repo: string, service = ""): string {
|
|
120
|
+
if (!SAFE_PART.test(repo)) return "";
|
|
121
|
+
return service && SAFE_PART.test(service) ? `${repo}/${service}` : repo;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What a refused call named: its `repo` (with `service`), else each of its
|
|
126
|
+
* `targets` (`repo/service`, as logs_search takes them), deduplicated.
|
|
127
|
+
*/
|
|
128
|
+
function refusedAddresses(report: {
|
|
129
|
+
repo?: unknown;
|
|
130
|
+
service?: unknown;
|
|
131
|
+
targets?: unknown;
|
|
132
|
+
}): string[] {
|
|
133
|
+
const out: string[] = [];
|
|
134
|
+
if (typeof report.repo === "string") {
|
|
135
|
+
const a = safeAddress(
|
|
136
|
+
report.repo,
|
|
137
|
+
typeof report.service === "string" ? report.service : "",
|
|
138
|
+
);
|
|
139
|
+
if (a) out.push(a);
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(report.targets))
|
|
142
|
+
for (const t of report.targets.slice(0, MAX_ADDRESSES)) {
|
|
143
|
+
if (typeof t !== "string") continue;
|
|
144
|
+
const slash = t.lastIndexOf("/");
|
|
145
|
+
const a =
|
|
146
|
+
slash > 0
|
|
147
|
+
? safeAddress(t.slice(0, slash), t.slice(slash + 1))
|
|
148
|
+
: safeAddress(t);
|
|
149
|
+
if (a && !out.includes(a)) out.push(a);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
95
153
|
|
|
96
154
|
/**
|
|
97
155
|
* An MCP guard refused an agent's call before it reached an action route.
|
|
98
156
|
* The agent's own report, so every field is sanitised: a lifecycle tool with
|
|
99
157
|
* a valid repo becomes an "exec" entry for that service (what the panel's
|
|
100
|
-
* activity list shows); anything else is recorded under the tool's name
|
|
158
|
+
* activity list shows); anything else is recorded under the tool's name,
|
|
159
|
+
* with the refusal as its detail and the services it named as `addresses`.
|
|
101
160
|
*/
|
|
102
161
|
export function recordAgentRefusal(
|
|
103
162
|
actor: Actor,
|
|
@@ -105,6 +164,7 @@ export function recordAgentRefusal(
|
|
|
105
164
|
tool?: unknown;
|
|
106
165
|
repo?: unknown;
|
|
107
166
|
service?: unknown;
|
|
167
|
+
targets?: unknown;
|
|
108
168
|
reason?: unknown;
|
|
109
169
|
},
|
|
110
170
|
): AuditEntry {
|
|
@@ -123,14 +183,41 @@ export function recordAgentRefusal(
|
|
|
123
183
|
reason,
|
|
124
184
|
});
|
|
125
185
|
}
|
|
186
|
+
// Any other tool: the refusal is the detail, under every service or repo
|
|
187
|
+
// the call named (all of a logs_search's targets, in one entry).
|
|
126
188
|
return recordAction(actor, {
|
|
127
189
|
verb: tool,
|
|
128
|
-
detail:
|
|
190
|
+
detail: reason,
|
|
129
191
|
outcome: "refused",
|
|
130
192
|
reason,
|
|
193
|
+
addresses: refusedAddresses(report),
|
|
131
194
|
});
|
|
132
195
|
}
|
|
133
196
|
|
|
197
|
+
/**
|
|
198
|
+
* The catalog repo a refused call's `path` is inside (manifest_validate), so
|
|
199
|
+
* the refusal shows in that repo's activity: the repo whose checkout is the
|
|
200
|
+
* path or holds it, the deepest when checkouts nest. Null for a relative
|
|
201
|
+
* path or one inside no repo. The path itself is never recorded.
|
|
202
|
+
*/
|
|
203
|
+
export function repoAtPath(
|
|
204
|
+
p: unknown,
|
|
205
|
+
repos: readonly { repoName: string; repoPath: string }[],
|
|
206
|
+
): string | null {
|
|
207
|
+
if (typeof p !== "string" || p.length > 4096 || !path.isAbsolute(p))
|
|
208
|
+
return null;
|
|
209
|
+
const target = path.resolve(p);
|
|
210
|
+
let best: { repoName: string; depth: number } | null = null;
|
|
211
|
+
for (const r of repos) {
|
|
212
|
+
if (!r.repoPath || !path.isAbsolute(r.repoPath)) continue;
|
|
213
|
+
const root = path.resolve(r.repoPath);
|
|
214
|
+
if (target !== root && !target.startsWith(root + path.sep)) continue;
|
|
215
|
+
if (!best || root.length > best.depth)
|
|
216
|
+
best = { repoName: r.repoName, depth: root.length };
|
|
217
|
+
}
|
|
218
|
+
return best && SAFE_PART.test(best.repoName) ? best.repoName : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
134
221
|
/** The most recent actions, oldest first. */
|
|
135
222
|
export function recentActions(): AuditEntry[] {
|
|
136
223
|
return ring.slice();
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* to any workspace/catalog service is process-death-equivalent evidence and
|
|
16
16
|
* is removed directly. Records another root registered (all roots of a
|
|
17
17
|
* product share the file) are never touched, and an unstamped legacy record
|
|
18
|
-
* is not removed for being unresolvable.
|
|
18
|
+
* is not removed for being unresolvable. A record whose checkout is gone, or
|
|
19
|
+
* whose service moved to another checkout with nothing of it running
|
|
20
|
+
* (`staleReason`), is removed whatever the address's health says.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
|
|
@@ -49,6 +51,19 @@ export interface DaemonReaperDeps {
|
|
|
49
51
|
* and an unstamped (pre-0.2.7) one is not dropped for being unresolvable.
|
|
50
52
|
*/
|
|
51
53
|
rootOf: (daemon: DaemonRecord) => "here" | "other" | "unstamped";
|
|
54
|
+
/**
|
|
55
|
+
* Why a record describes nothing that can be running whatever its health
|
|
56
|
+
* (service-runner's daemonStaleReason: its checkout is gone, or the
|
|
57
|
+
* service moved to another checkout and none of its containers run), or
|
|
58
|
+
* null. Injected; omitted, no record is dropped for it. `running` is the
|
|
59
|
+
* container listing, taken once per sweep by `runningContainers`.
|
|
60
|
+
*/
|
|
61
|
+
staleReason?: (
|
|
62
|
+
daemon: DaemonRecord,
|
|
63
|
+
running: Set<string> | null,
|
|
64
|
+
) => string | null;
|
|
65
|
+
/** The running container base names (null: unverifiable), once a sweep. */
|
|
66
|
+
runningContainers?: () => Promise<Set<string> | null>;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
export interface DaemonReapResult {
|
|
@@ -96,6 +111,11 @@ export async function reapDeadDaemons(
|
|
|
96
111
|
): Promise<DaemonReapResult> {
|
|
97
112
|
const removed: string[] = [];
|
|
98
113
|
const kept: string[] = [];
|
|
114
|
+
const running = deps.staleReason
|
|
115
|
+
? await (deps.runningContainers?.() ?? Promise.resolve(null)).catch(
|
|
116
|
+
() => null,
|
|
117
|
+
)
|
|
118
|
+
: null;
|
|
99
119
|
|
|
100
120
|
await Promise.allSettled(
|
|
101
121
|
deps.listDaemons().map(async (daemon) => {
|
|
@@ -105,6 +125,14 @@ export async function reapDeadDaemons(
|
|
|
105
125
|
kept.push(daemon.address);
|
|
106
126
|
return;
|
|
107
127
|
}
|
|
128
|
+
// Its checkout is gone, or the service has moved and nothing of
|
|
129
|
+
// the record's runs: whatever the address's health says (it is
|
|
130
|
+
// another checkout's), the record is stale.
|
|
131
|
+
if (deps.staleReason?.(daemon, running)) {
|
|
132
|
+
deps.unregisterDaemon(daemon.address);
|
|
133
|
+
removed.push(daemon.address);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
108
136
|
const service = resolveForDaemon(deps, daemon);
|
|
109
137
|
if (!service) {
|
|
110
138
|
// No longer in any owning workspace/catalog — nothing could restart
|