@monster-orange/den 0.2.7 → 0.2.8

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.
@@ -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 }) => api.get("/api/workspaces"),
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
- modules: await api.get("/api/modules", { pillar: String(a.pillar) }),
275
- sources: await api.get("/api/test-sources", { pillar: String(a.pillar) }),
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("/api/workspace/sync", repo ? { repo } : {}, {
459
- method: "POST",
460
- maxLines: 5000,
461
- timeoutMs: 15 * 60_000,
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", {
@@ -599,6 +653,10 @@ async function reportRefusal(
599
653
  tool,
600
654
  ...(typeof args.repo === "string" ? { repo: args.repo } : {}),
601
655
  ...(typeof args.service === "string" ? { service: args.service } : {}),
656
+ ...(Array.isArray(args.targets) ? { targets: args.targets } : {}),
657
+ // A path (manifest_validate): the server records it under the catalog
658
+ // repo it is inside, never as the path itself.
659
+ ...(typeof args.path === "string" ? { path: args.path } : {}),
602
660
  reason,
603
661
  });
604
662
  } catch {
@@ -606,6 +664,27 @@ async function reportRefusal(
606
664
  }
607
665
  }
608
666
 
667
+ /**
668
+ * A scoped server's arguments: the workspace exists (checked once per
669
+ * server), and a tool that takes a workspace gets the scoped one; naming
670
+ * another is refused.
671
+ */
672
+ const verifiedScopes = new WeakSet<McpScope>();
673
+ async function scopeArgs(
674
+ tool: DenTool,
675
+ args: Args,
676
+ ctx: ToolContext,
677
+ scope: McpScope,
678
+ ): Promise<Args> {
679
+ if (!verifiedScopes.has(scope)) {
680
+ await assertScopeExists(ctx.api, scope);
681
+ verifiedScopes.add(scope);
682
+ }
683
+ return "workspace" in tool.shape
684
+ ? pinWorkspace(tool.name, args, scope)
685
+ : args;
686
+ }
687
+
609
688
  export async function callTool(
610
689
  name: string,
611
690
  args: Args,
@@ -618,7 +697,10 @@ export async function callTool(
618
697
  isError: true,
619
698
  };
620
699
  try {
621
- const out = await tool.run(args, ctx);
700
+ const out = await tool.run(
701
+ ctx.scope ? await scopeArgs(tool, args, ctx, ctx.scope) : args,
702
+ ctx,
703
+ );
622
704
  const text = typeof out === "string" ? out : JSON.stringify(out, null, 2);
623
705
  return { text: redactSecrets(text), isError: false };
624
706
  } catch (e) {
@@ -633,7 +715,9 @@ export async function callTool(
633
715
  );
634
716
  return { text: redactSecrets(text), isError: true };
635
717
  }
636
- if (e instanceof Refusal && tool.action)
718
+ // A call outside a scoped server's workspace is recorded whatever the
719
+ // tool: it is an attempt to cross the boundary (AG-2a-core).
720
+ if (e instanceof Refusal && (tool.action || e instanceof ScopeRefusal))
637
721
  await reportRefusal(ctx, name, args, e.message);
638
722
  const msg =
639
723
  e instanceof Refusal
@@ -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} ${action.detail}${why}${actorSuffix(actor)}`,
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();
@@ -9,6 +9,7 @@ import { execFileSync, execSync } from "child_process";
9
9
  import * as path from "path";
10
10
  import { readDenConfig } from "../../shared/den-config.js";
11
11
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
12
+ import { githubOwnersOf } from "../../shared/org-sources.js";
12
13
  import {
13
14
  GH_API_TIMEOUT_MS,
14
15
  GIT_REMOTE_TIMEOUT_MS,
@@ -183,7 +184,11 @@ export class GitHubService {
183
184
  const config = readDenConfig();
184
185
  const orgs = new Set<string>();
185
186
  if (config.github_org) orgs.add(config.github_org);
186
- for (const id of Object.keys(config.orgs ?? {})) orgs.add(id);
187
+ // A legacy `orgs:` key is a GitHub owner; an ORG-1d org lists its GitHub
188
+ // sources' owners, and one with no GitHub source adds none (discovery is
189
+ // hidden for it).
190
+ for (const id of Object.keys(config.orgs ?? {}))
191
+ for (const owner of githubOwnersOf(id, config.orgs)) orgs.add(owner);
187
192
  for (const entry of Object.values(readCatalog() ?? {})) {
188
193
  if (entry?.gh && entry.gh.includes("/")) {
189
194
  orgs.add(entry.gh.slice(0, entry.gh.indexOf("/")));
@@ -50,6 +50,7 @@ import {
50
50
  getWorkspaceByName,
51
51
  isWorkspaceActive,
52
52
  listWorkspaceServices,
53
+ readWorkspacesFile,
53
54
  resolveDepAddress,
54
55
  parseAddress,
55
56
  readManifestAnyBranch,
@@ -96,6 +97,7 @@ import {
96
97
  getServiceLogPath,
97
98
  } from "../../shared/process/spawn-streaming.js";
98
99
  import {
100
+ assertDenConfigValid,
99
101
  readDenConfig,
100
102
  isProductionEnv,
101
103
  isLocalEnabled,
@@ -106,6 +108,11 @@ import {
106
108
  writeUserReleaseConfig,
107
109
  type ReleaseConfig,
108
110
  } from "../../shared/den-config.js";
111
+ import {
112
+ CONFIG_ERROR_STATUS,
113
+ configErrorBody,
114
+ isConfigError,
115
+ } from "../../shared/config-error.js";
109
116
  import { readOrg } from "../../shared/org.js";
110
117
  import { engineVersion, getDistRoot } from "../../shared/paths.js";
111
118
  import { enginePath } from "../../shared/engine.js";
@@ -143,6 +150,7 @@ import {
143
150
  actorSuffix,
144
151
  recordAction,
145
152
  recordAgentRefusal,
153
+ repoAtPath,
146
154
  setAuditSink,
147
155
  } from "./audit.js";
148
156
  import { handleReadonlyRoutes } from "./readonly-routes.js";
@@ -417,6 +425,26 @@ const STATIC_EXTS = new Set([
417
425
  ".woff2",
418
426
  ]);
419
427
 
428
+ /** Every workspace's checkouts (active or not), for repoAtPath. */
429
+ function allWorkspaceCheckouts(): ResolvedService[] {
430
+ const catalog = readCatalog();
431
+ return Object.entries(readWorkspacesFile().workspaces).flatMap(
432
+ ([name, config]) => listWorkspaceServices(config, catalog, name),
433
+ );
434
+ }
435
+
436
+ /**
437
+ * API routes that answer whatever the config holds: the identity probe the
438
+ * CLI and MCP use to find this server, its own log (where a config error is
439
+ * also written), the changelog, and restarting it.
440
+ */
441
+ const CONFIG_FREE_ROUTES = new Set([
442
+ "/api/server-info",
443
+ "/api/server-log",
444
+ "/api/changelog",
445
+ "/api/web/restart",
446
+ ]);
447
+
420
448
  // ── Sudo password store ───────────────────────────────────────────────────────
421
449
  import {
422
450
  setSudoPassword,
@@ -609,6 +637,13 @@ async function handleRequest(
609
637
  }
610
638
  }
611
639
 
640
+ // ── Config validation ──────────────────────────────────────────────────────
641
+ // A config that fails validation answers every API route that reads it as
642
+ // the ConfigError the handler below maps to 422 JSON, before any route's own
643
+ // catch can turn it into a 400, a 500 or an empty list. Pages still load.
644
+ if (pathname.startsWith("/api/") && !CONFIG_FREE_ROUTES.has(pathname))
645
+ assertDenConfigValid();
646
+
612
647
  // ── Static UI ──────────────────────────────────────────────────────────────
613
648
  // Local Dev is the default page when local environment is enabled.
614
649
  if (
@@ -690,6 +725,9 @@ async function handleRequest(
690
725
 
691
726
  // ── API: environments ──────────────────────────────────────────────────────
692
727
  // Orgs the catalog's repos belong to, with config/den.yml display names.
728
+ // ORG-1d: a repo's org is an org listing it, else one whose source claims
729
+ // its owner, else its GitHub owner; a claimed owner is not listed. Service
730
+ // rows keep `org` as the repo's GitHub owner (C3).
693
731
  // Each entry also says whether it is the default org (`github_org`) and
694
732
  // which workspaces on this machine belong to it (ORG-1a). Nothing else
695
733
  // changes: same entries, order and fields (ORG-SPEC C3).
@@ -704,7 +742,7 @@ async function handleRequest(
704
742
  );
705
743
  if (url.searchParams.get("repos") !== "1") return sendJson(res, orgs);
706
744
  const repos = Object.fromEntries(
707
- Object.keys(catalog).map((r) => [r, orgOf(r, catalog)]),
745
+ Object.keys(catalog).map((r) => [r, orgOf(r, catalog, ctx.config.orgs)]),
708
746
  );
709
747
  return sendJson(res, { orgs, repos });
710
748
  }
@@ -809,6 +847,17 @@ async function handleRequest(
809
847
  } catch {
810
848
  return send400(res, "invalid JSON");
811
849
  }
850
+ // A call that named a path (manifest_validate) is recorded under the
851
+ // catalog repo that path is inside, across every workspace's checkouts.
852
+ if (typeof report.repo !== "string" && report.path !== undefined) {
853
+ let repo: string | null = null;
854
+ try {
855
+ repo = repoAtPath(report.path, allWorkspaceCheckouts());
856
+ } catch {
857
+ /* unresolvable workspaces: recorded under the tool only */
858
+ }
859
+ if (repo) report = { ...report, repo };
860
+ }
812
861
  const entry = recordAgentRefusal(actor, report);
813
862
  return sendJson(res, { recorded: true, ts: entry.ts });
814
863
  }
@@ -3152,6 +3201,21 @@ const server = http.createServer((req, res) => {
3152
3201
  // rejection — which CRASHES the whole server on modern Node (freezing every
3153
3202
  // health dot) and leaves the request hanging. Fail the one request instead.
3154
3203
  handleRequest(req, res).catch((err) => {
3204
+ // A config that fails validation is the user's to fix: name it to the
3205
+ // caller as JSON (the {ok, error, code} frame), not a bare 500.
3206
+ if (isConfigError(err)) {
3207
+ serverLog(
3208
+ ` ⚠ ${req.method} ${(req.url ?? "").split("?")[0]}: ${err.message}`,
3209
+ );
3210
+ try {
3211
+ if (!res.headersSent)
3212
+ return sendJson(res, configErrorBody(err), CONFIG_ERROR_STATUS);
3213
+ res.end();
3214
+ } catch {
3215
+ /* socket already gone */
3216
+ }
3217
+ return;
3218
+ }
3155
3219
  console.error(`[server] ${req.method} ${req.url} failed:`, err);
3156
3220
  try {
3157
3221
  if (!res.headersSent) {
@@ -781,7 +781,7 @@ function activityList(x) {
781
781
  .then(({ entries = [] }) => {
782
782
  if (!live) return;
783
783
  el.innerHTML = entries.length
784
- ? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? "[agent]" + (a.client ? " " + a.client : "") : "you")}</span><span>${esc(a.verb)} ${esc(a.detail)}${a.outcome === "refused" ? ` <em class="refused">refused: ${esc(a.reason || "")}</em>` : ""}</span></li>`).join("")}</ol>`
784
+ ? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? (a.agent ? "[" + a.agent + "]" : "[agent]") + (a.client ? " " + a.client : "") : "you")}</span><span>${esc(a.verb)} ${esc(a.detail)}${a.outcome === "refused" ? ` <em class="refused">refused: ${esc(a.reason || "")}</em>` : ""}</span></li>`).join("")}</ol>`
785
785
  : none(
786
786
  "No actions yet. Starts, restarts and syncs, yours and agents', show here.",
787
787
  );
@@ -96,13 +96,22 @@ export function activityFor(
96
96
  // and for each of its services.
97
97
  const repoLevel = (e: AuditEntry, repo: string): boolean =>
98
98
  e.verb === "exec" && e.detail.startsWith(repo + " ");
99
+ // A refused non-lifecycle call (logs_tail, logs_search, …) is listed under
100
+ // each service or repo it named.
101
+ const named = (e: AuditEntry, hit: (a: string) => boolean): boolean =>
102
+ (e.addresses ?? []).some(hit);
99
103
  const matches = (e: AuditEntry): boolean => {
100
- if (target.address)
104
+ if (target.address) {
105
+ const address = target.address;
106
+ const repo = address.split("/")[0];
101
107
  return (
102
- (e.verb === "exec" && e.detail.startsWith(target.address + " ")) ||
103
- repoLevel(e, target.address.split("/")[0])
108
+ (e.verb === "exec" && e.detail.startsWith(address + " ")) ||
109
+ repoLevel(e, repo) ||
110
+ named(e, (a) => a === address || a === repo)
104
111
  );
112
+ }
105
113
  const repo = target.repo ?? "";
114
+ if (named(e, (a) => a === repo || a.startsWith(repo + "/"))) return true;
106
115
  if (e.verb === "exec")
107
116
  return e.detail.startsWith(repo + "/") || repoLevel(e, repo);
108
117
  if (e.verb === "sync") return e.detail.split(" ").includes(`repo=${repo}`);
@@ -19,6 +19,7 @@ import { execFile } from "child_process";
19
19
  import { promisify } from "util";
20
20
  import { GH_API_TIMEOUT_MS } from "../../shared/constants.js";
21
21
  import {
22
+ readDenConfig,
22
23
  readExplicitReleaseOrg,
23
24
  readReleaseConfig,
24
25
  releaseOwnerFor,
@@ -29,6 +30,11 @@ import {
29
30
  } from "../../shared/den-config.js";
30
31
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
31
32
  import { orgOf } from "../../shared/manifest/service-links.js";
33
+ import {
34
+ ghOwnerOf,
35
+ isSourcedOrg,
36
+ orgHasGithub,
37
+ } from "../../shared/org-sources.js";
32
38
  import { listReposWithManifests } from "../../shared/manifest/workspace-manifests.js";
33
39
 
34
40
  import { product } from "../../shared/product.js";
@@ -258,10 +264,10 @@ export class ReleaseService {
258
264
  const known = new Set(cfg.repos.map((r) => r.name));
259
265
  return {
260
266
  ...cfg,
261
- repos: [
267
+ repos: releasableOnGithub([
262
268
  ...cfg.repos,
263
269
  ...manifestReleaseRepos().filter((r) => !known.has(r.name)),
264
- ],
270
+ ]),
265
271
  };
266
272
  }
267
273
 
@@ -274,7 +280,8 @@ export class ReleaseService {
274
280
  private ownerFor(repo: string, cfg: ReleaseConfig): string {
275
281
  return releaseOwnerFor({
276
282
  explicitOrg: readExplicitReleaseOrg(),
277
- catalogOwner: orgOf(repo, readCatalog()),
283
+ // The repo's GitHub owner, never its ORG-1d org id (which needn't be one).
284
+ catalogOwner: ghOwnerOf(readCatalog()[repo]),
278
285
  githubOrg: cfg.org,
279
286
  });
280
287
  }
@@ -1122,6 +1129,21 @@ export async function readVersionSha(
1122
1129
  }
1123
1130
  }
1124
1131
 
1132
+ /**
1133
+ * Release repos less those of an org with no GitHub source (ORG-1d): their
1134
+ * release flow needs GitHub, so the Release page hides them rather than
1135
+ * failing. With no ORG-1d keys every repo stays.
1136
+ */
1137
+ export function releasableOnGithub(repos: ReleaseRepo[]): ReleaseRepo[] {
1138
+ const orgs = readDenConfig().orgs ?? {};
1139
+ if (!Object.values(orgs).some(isSourcedOrg)) return repos;
1140
+ const catalog = readCatalog();
1141
+ return repos.filter((r) => {
1142
+ const org = orgOf(r.name, catalog, orgs);
1143
+ return !org || orgHasGithub(org, orgs);
1144
+ });
1145
+ }
1146
+
1125
1147
  /** Workspace repos whose manifest has a `release:` block, as release repos. */
1126
1148
  export function manifestReleaseRepos(): ReleaseRepo[] {
1127
1149
  const catalog = readCatalog();