@monster-orange/den 0.2.6 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/bin/den +9 -0
  3. package/docs/distributions.md +205 -6
  4. package/package.json +1 -1
  5. package/service/install.sh +41 -2
  6. package/src/shared/agent-actor.ts +38 -3
  7. package/src/shared/config-error.ts +33 -0
  8. package/src/shared/den-config.ts +84 -5
  9. package/src/shared/environment-manager/catalog.ts +61 -3
  10. package/src/shared/environment-manager/default-workspace.ts +53 -14
  11. package/src/shared/environment-manager/git-remote.ts +49 -0
  12. package/src/shared/environment-manager/health.ts +51 -8
  13. package/src/shared/environment-manager/service-runner.ts +51 -5
  14. package/src/shared/environment-manager/workspace-manager.ts +69 -26
  15. package/src/shared/environment-manager/workspace-org.ts +279 -0
  16. package/src/shared/manifest/service-links.ts +20 -8
  17. package/src/shared/org-sources.ts +309 -0
  18. package/src/shared/types/index.ts +19 -2
  19. package/src/ui/cli/clone-plan.ts +374 -0
  20. package/src/ui/cli/flag-parser.ts +57 -3
  21. package/src/ui/cli/index.ts +346 -48
  22. package/src/ui/cli/org-command.ts +172 -0
  23. package/src/ui/cli/results-viewer.ts +5 -5
  24. package/src/ui/cli/table.ts +20 -0
  25. package/src/ui/cli/up-args.ts +15 -0
  26. package/src/ui/cli/validate-all.ts +171 -0
  27. package/src/ui/mcp/scope.ts +277 -0
  28. package/src/ui/mcp/server.ts +94 -9
  29. package/src/ui/mcp/tools.ts +101 -17
  30. package/src/ui/server/audit.ts +90 -3
  31. package/src/ui/server/daemon-reaper.ts +22 -4
  32. package/src/ui/server/github-service.ts +6 -1
  33. package/src/ui/server/index.ts +96 -5
  34. package/src/ui/server/public/panel.js +1 -1
  35. package/src/ui/server/readonly-routes.ts +12 -3
  36. package/src/ui/server/release-service.ts +40 -5
  37. package/src/ui/server/workspace-routes.ts +13 -6
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `<cli> org list` (ORG-1a): every org with its label, its workspaces on
3
+ * this machine, whether it is the default (`github_org`), and the org
4
+ * warnings (C1). The orgs are `/api/orgs`'s (catalog owners plus `orgs:`),
5
+ * then the default org and any workspace's org that list does not name.
6
+ *
7
+ * `--org <id>` (on `org list` and `workspace list`) keeps one org's rows.
8
+ */
9
+ import chalk from "chalk";
10
+ import { product } from "../../shared/product.js";
11
+ import { listOrgs } from "../../shared/manifest/service-links.js";
12
+ import { githubOwnersOf } from "../../shared/org-sources.js";
13
+ import { readDenConfig } from "../../shared/den-config.js";
14
+ import { isConfigError } from "../../shared/config-error.js";
15
+ import {
16
+ defaultOrg,
17
+ githubOrgFor,
18
+ readOrgContext,
19
+ resolveAllWorkspaceOrgs,
20
+ withOrgMembership,
21
+ type OrgContext,
22
+ type WorkspaceOrg,
23
+ } from "../../shared/environment-manager/workspace-org.js";
24
+ import { newTable } from "./table.js";
25
+
26
+ /** `--org <id>`'s value from `args` (also `--org=<id>`), or undefined. */
27
+ export function orgFlag(args: readonly string[]): string | undefined {
28
+ for (let i = 0; i < args.length; i++) {
29
+ const a = args[i];
30
+ if (a === "--org") return args[i + 1];
31
+ if (a.startsWith("--org=")) return a.slice("--org=".length);
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ /** `args` without `--org <id>` / `--org=<id>`. */
37
+ export function withoutOrgFlag(args: readonly string[]): string[] {
38
+ const out: string[] = [];
39
+ for (let i = 0; i < args.length; i++) {
40
+ if (args[i] === "--org") {
41
+ i++;
42
+ continue;
43
+ }
44
+ if (args[i].startsWith("--org=")) continue;
45
+ out.push(args[i]);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export const sameOrg = (a: string | null, b: string): boolean =>
51
+ a !== null && a.toLowerCase() === b.toLowerCase();
52
+
53
+ export interface OrgListRow {
54
+ id: string;
55
+ label: string;
56
+ default: boolean;
57
+ workspaces: string[];
58
+ }
59
+
60
+ /** The rows `org list` prints, and every workspace's warnings. */
61
+ export function orgListRows(ctx: OrgContext = readOrgContext()): {
62
+ rows: OrgListRow[];
63
+ warnings: string[];
64
+ resolved: WorkspaceOrg[];
65
+ } {
66
+ const resolved = resolveAllWorkspaceOrgs(ctx);
67
+ const listed = withOrgMembership(
68
+ listOrgs(ctx.catalog, ctx.config.orgs ?? {}),
69
+ resolved,
70
+ ctx.file,
71
+ ctx.config,
72
+ ).map((o) => ({
73
+ id: o.id,
74
+ label: o.label,
75
+ default: o.default,
76
+ workspaces: o.workspaces,
77
+ }));
78
+ const local = new Set(Object.keys(ctx.file.workspaces));
79
+ const extra = new Map<string, OrgListRow>();
80
+ const add = (id: string, isDefault: boolean): void => {
81
+ if (listed.some((r) => sameOrg(r.id, id))) return;
82
+ const key = id.toLowerCase();
83
+ if (!extra.has(key))
84
+ extra.set(key, { id, label: id, default: isDefault, workspaces: [] });
85
+ };
86
+ const d = defaultOrg(ctx.config);
87
+ if (d) add(d, true);
88
+ for (const w of resolved)
89
+ if (w.org && local.has(w.workspace)) {
90
+ add(w.org, sameOrg(d, w.org));
91
+ extra.get(w.org.toLowerCase())?.workspaces.push(w.workspace);
92
+ }
93
+ return {
94
+ rows: [...listed, ...extra.values()],
95
+ warnings: resolved
96
+ .filter((w) => local.has(w.workspace))
97
+ .flatMap((w) => w.warnings),
98
+ resolved,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The GitHub org `register` lists repos from: `--org <id>`, else the
104
+ * workspace's resolved org. Never Den's own checkout or remote (SEC-0).
105
+ * Undefined when there is none, or it is not a GitHub owner name.
106
+ */
107
+ export function registerOrg(
108
+ workspace: string,
109
+ orgArg?: string,
110
+ ctx?: OrgContext,
111
+ ): string | undefined {
112
+ let org = orgArg?.trim();
113
+ if (org) {
114
+ // ORG-1d: an org id names its first GitHub source's owner; an org with
115
+ // no GitHub source has nothing to list.
116
+ org = githubOwnersOf(org, (ctx?.config ?? readDenConfig()).orgs)[0];
117
+ if (!org) return undefined;
118
+ } else {
119
+ try {
120
+ org = githubOrgFor(workspace, ctx);
121
+ } catch (e) {
122
+ // No org is "nothing to register"; a config that fails validation is not.
123
+ if (isConfigError(e)) throw e;
124
+ return undefined;
125
+ }
126
+ }
127
+ return /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(org) ? org : undefined;
128
+ }
129
+
130
+ /** Print the org warnings under a listing. */
131
+ export function printOrgWarnings(warnings: readonly string[]): void {
132
+ for (const w of warnings) console.log(chalk.yellow(` ⚠ ${w}`));
133
+ if (warnings.length) console.log();
134
+ }
135
+
136
+ export function cmdOrg(args: string[]): void {
137
+ const sub = args[0] ?? "list";
138
+ if (sub !== "list") {
139
+ console.log(chalk.red(`Usage: ${product().cli} org list [--org <id>]`));
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ const only = orgFlag(args.slice(1));
144
+ const { rows, warnings, resolved } = orgListRows();
145
+ const shown = only ? rows.filter((r) => sameOrg(r.id, only)) : rows;
146
+ const table = newTable({
147
+ head: ["Org", "Label", "Default", "Workspaces"],
148
+ style: { head: ["cyan"] },
149
+ });
150
+ for (const r of shown)
151
+ table.push([
152
+ chalk.bold(r.id),
153
+ r.label,
154
+ r.default ? chalk.green("✓") : "",
155
+ r.workspaces.join(", ") || chalk.gray("none"),
156
+ ]);
157
+ console.log(table.toString());
158
+ if (only && shown.length === 0)
159
+ console.log(chalk.yellow(` No org ${only}.`));
160
+ const inScope = only
161
+ ? new Set(
162
+ resolved.filter((w) => sameOrg(w.org, only)).map((w) => w.workspace),
163
+ )
164
+ : null;
165
+ printOrgWarnings(
166
+ inScope
167
+ ? warnings.filter((w) =>
168
+ [...inScope].some((n) => w.startsWith(`workspace ${n}:`)),
169
+ )
170
+ : warnings,
171
+ );
172
+ }
@@ -3,7 +3,7 @@
3
3
  * Display test results in CLI
4
4
  */
5
5
 
6
- import Table from "cli-table3";
6
+ import { newTable } from "./table.js";
7
7
  import chalk from "chalk";
8
8
  import { resultReader } from "../../shared/results/result-reader.js";
9
9
  import type {
@@ -40,7 +40,7 @@ export class ResultsViewer {
40
40
  for (const pillar of pillars) {
41
41
  console.log(chalk.bold(`\n${this.formatPillarName(pillar)}:`));
42
42
 
43
- const table = new Table({
43
+ const table = newTable({
44
44
  head: [
45
45
  chalk.cyan("Environment"),
46
46
  chalk.cyan("Total"),
@@ -115,7 +115,7 @@ export class ResultsViewer {
115
115
  return;
116
116
  }
117
117
 
118
- const table = new Table({
118
+ const table = newTable({
119
119
  head: [
120
120
  chalk.cyan("Test"),
121
121
  chalk.cyan("Status"),
@@ -191,7 +191,7 @@ export class ResultsViewer {
191
191
  return;
192
192
  }
193
193
 
194
- const table = new Table({
194
+ const table = newTable({
195
195
  head: [
196
196
  chalk.cyan("Journey"),
197
197
  chalk.cyan("Verdict"),
@@ -281,7 +281,7 @@ export class ResultsViewer {
281
281
  return;
282
282
  }
283
283
 
284
- const table = new Table({
284
+ const table = newTable({
285
285
  head: [
286
286
  chalk.cyan("Test"),
287
287
  chalk.cyan("Runs"),
@@ -0,0 +1,20 @@
1
+ /**
2
+ * cli-table3 colours its borders (grey) and head cells (its own default, or
3
+ * the `style` given) through @colors/colors, which ignores Den's colour
4
+ * detection: piped `status` output carried ANSI codes from the table alone.
5
+ * Every CLI table goes through here, and takes no colour when chalk has none
6
+ * (not a TTY, NO_COLOR, FORCE_COLOR=0).
7
+ */
8
+ import chalk from "chalk";
9
+ import Table from "cli-table3";
10
+
11
+ export function newTable(
12
+ opts: Table.TableConstructorOptions = {},
13
+ colour: boolean = chalk.level > 0,
14
+ ): Table.Table {
15
+ if (colour) return new Table(opts);
16
+ return new Table({
17
+ ...opts,
18
+ style: { ...(opts.style ?? {}), head: [], border: [] },
19
+ });
20
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `up -w <ws>` with no repo, no `-a` and no service repo under the cwd
3
+ * (0.2.8-dev.1). `-w` only picks the workspace; it does not say what in it
4
+ * to start, so the plan used to come back "No services specified and no
5
+ * invocation target resolvable". It is a usage error naming the two forms
6
+ * that do: a repo, or `-a` for the whole workspace.
7
+ */
8
+ export function upNeedsTarget(cli: string, ws: string, plan: boolean): string {
9
+ const name = /^[A-Za-z0-9._-]{1,64}$/.test(ws) ? ws : "<workspace>";
10
+ const p = plan ? " --plan" : "";
11
+ return (
12
+ `up -w ${name} needs what to start: \`${cli} up -a${p} -w ${name}\` ` +
13
+ `for the whole workspace, or \`${cli} up <repo>${p} -w ${name}\` for one repo and its dependencies.`
14
+ );
15
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Bare `<cli> validate` in the distribution root: every manifest the
3
+ * distribution uses, without cloning anything.
4
+ *
5
+ * - Each repo of every workspace, once per distinct checkout path: its
6
+ * layered manifest (own or held, then the override, then the merge).
7
+ * - A repo with no checkout on disk (a managed clone not made yet, a
8
+ * dev-managed path that is gone) falls back to its held manifest, marked
9
+ * "held only, no checkout"; with no held manifest either it is a warning,
10
+ * not a failure.
11
+ * - Each held manifest (and override) in manifests/ that no repo above used.
12
+ *
13
+ * The layered validator comes in as `validate` (validateManifestLayers), so
14
+ * this file stays under the typecheck gate (see validate-layers.ts).
15
+ *
16
+ * `--held` checks only manifests/, as if nothing were checked out: a result
17
+ * that does not depend on the machine (an upgrade gate).
18
+ */
19
+ import * as fs from "fs";
20
+ import * as path from "path";
21
+ import { product } from "../../shared/product.js";
22
+ import { readCatalog } from "../../shared/environment-manager/catalog.js";
23
+ import {
24
+ readWorkspacesFile,
25
+ resolveServiceSource,
26
+ } from "../../shared/environment-manager/workspaces.js";
27
+ import {
28
+ heldManifestsDir,
29
+ overrideSuffix,
30
+ } from "../../shared/manifest/read-manifest.js";
31
+ import type { LayeredValidation } from "../../shared/manifest/validate-layers.js";
32
+
33
+ export interface RepoValidation {
34
+ repo: string;
35
+ /** The checkout validated, or null when only manifests/ was read. */
36
+ checkout: string | null;
37
+ /** Workspaces that use this checkout. */
38
+ workspaces: string[];
39
+ /** own | held, plus "+ override" when one layered on. */
40
+ source: string;
41
+ /** Set when there was no checkout and the held manifest stood in. */
42
+ note?: string;
43
+ /** No checkout and no held manifest: reported, not failed. */
44
+ missing?: boolean;
45
+ result: LayeredValidation;
46
+ }
47
+
48
+ /**
49
+ * A path that never exists, named after the repo, so the layer lookup finds
50
+ * only manifests/<repo>… and never a checkout's own manifest.
51
+ */
52
+ function heldOnlyPath(repo: string): string {
53
+ return path.join(heldManifestsDir(), ".held-only", repo);
54
+ }
55
+
56
+ function sourceOf(result: LayeredValidation): string {
57
+ const base = result.layers.find(
58
+ (l) => l.layer === "repo" || l.layer === "held",
59
+ );
60
+ const own = base?.layer === "repo" ? "own" : base ? "held" : "none";
61
+ return result.layers.some((l) => l.layer === "override")
62
+ ? `${own} + override`
63
+ : own;
64
+ }
65
+
66
+ /** The repo keys manifests/ holds a manifest or an override for. */
67
+ function heldKeys(): string[] {
68
+ const dir = heldManifestsDir();
69
+ if (!fs.existsSync(dir)) return [];
70
+ const yml = product().manifestYml;
71
+ const over = overrideSuffix();
72
+ const keys = new Set<string>();
73
+ for (const name of fs.readdirSync(dir)) {
74
+ if (name.endsWith(over)) keys.add(name.slice(0, -over.length));
75
+ else if (name.endsWith(yml)) keys.add(name.slice(0, -yml.length));
76
+ }
77
+ return [...keys].sort();
78
+ }
79
+
80
+ type Validate = (repoPath: string) => LayeredValidation;
81
+
82
+ function heldOnly(repo: string, validate: Validate): RepoValidation {
83
+ const result = validate(heldOnlyPath(repo));
84
+ return {
85
+ repo,
86
+ checkout: null,
87
+ workspaces: [],
88
+ source: sourceOf(result),
89
+ result,
90
+ };
91
+ }
92
+
93
+ export function validateAll(opts: {
94
+ held?: boolean;
95
+ validate: Validate;
96
+ }): RepoValidation[] {
97
+ const { validate } = opts;
98
+ if (opts.held)
99
+ return heldKeys().map((k) => {
100
+ const r = heldOnly(k, validate);
101
+ // An override with no held base layers onto the repo's own manifest,
102
+ // which --held deliberately does not read: noted, not failed.
103
+ const overrideOnly =
104
+ r.result.layers.length > 0 &&
105
+ r.result.layers.every((l) => l.layer === "override");
106
+ return overrideOnly
107
+ ? {
108
+ ...r,
109
+ source: "override",
110
+ missing: true,
111
+ note: "override only: its base is the repo's own manifest (not read with --held)",
112
+ }
113
+ : r;
114
+ });
115
+
116
+ const file = readWorkspacesFile();
117
+ const catalog = readCatalog();
118
+ // repo + checkout path → the workspaces that use it.
119
+ const seen = new Map<
120
+ string,
121
+ { repo: string; path: string; workspaces: string[] }
122
+ >();
123
+ for (const [ws, config] of Object.entries(file.workspaces)) {
124
+ for (const address of Object.keys(config.services ?? {})) {
125
+ const src = resolveServiceSource(address, ws, config, catalog);
126
+ const repo = src?.repoName ?? address.split("/")[0];
127
+ const p = src?.repoPath ? path.resolve(src.repoPath) : "";
128
+ const key = `${repo}\0${p}`;
129
+ const hit = seen.get(key);
130
+ if (hit) {
131
+ if (!hit.workspaces.includes(ws)) hit.workspaces.push(ws);
132
+ } else seen.set(key, { repo, path: p, workspaces: [ws] });
133
+ }
134
+ }
135
+
136
+ const out: RepoValidation[] = [];
137
+ const used = new Set<string>();
138
+ for (const { repo, path: p, workspaces } of seen.values()) {
139
+ used.add(repo);
140
+ if (p && fs.existsSync(p)) {
141
+ const result = validate(p);
142
+ out.push({
143
+ repo,
144
+ checkout: p,
145
+ workspaces,
146
+ source: sourceOf(result),
147
+ result,
148
+ ...(result.layers.length === 0 ? { missing: true } : {}),
149
+ });
150
+ continue;
151
+ }
152
+ const held = heldOnly(repo, validate);
153
+ out.push({
154
+ ...held,
155
+ checkout: p || null,
156
+ workspaces,
157
+ ...(held.result.layers.length
158
+ ? { note: "held only, no checkout" }
159
+ : { missing: true, note: "no checkout and no held manifest" }),
160
+ });
161
+ }
162
+ for (const key of heldKeys())
163
+ if (!used.has(key))
164
+ out.push({ ...heldOnly(key, validate), note: "held, in no workspace" });
165
+ return out;
166
+ }
167
+
168
+ /** Whether the run fails: any layer invalid, ignoring `missing` entries. */
169
+ export function validateAllFailed(results: RepoValidation[]): boolean {
170
+ return results.some((r) => !r.missing && !r.result.valid);
171
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * A scoped MCP server (AG-2a-core): `<cli> mcp --workspace <ws> --actor
3
+ * agent:<id>`. It pins one workspace, refuses any call that names another,
4
+ * filters lists to it, and stamps the actor on every web API call (the
5
+ * x-den-mcp-agent / x-den-mcp-workspace headers, see shared/agent-actor.ts).
6
+ *
7
+ * It binds cooperating agents only: the scope lives in this process, and the
8
+ * web server's HTTP API does not check it yet (WS-3a).
9
+ */
10
+ import * as path from "path";
11
+ import { AGENT_ACTOR_RE, WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
12
+ import type { DenApi } from "./api.js";
13
+ import { Refusal } from "./guards.js";
14
+
15
+ export interface McpScope {
16
+ /** The one workspace this server serves. */
17
+ workspace: string;
18
+ /** Who acts: `agent:<id>`. */
19
+ actor: string;
20
+ }
21
+
22
+ /** A call reached outside the scoped workspace. Always reported (F2). */
23
+ export class ScopeRefusal extends Refusal {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "ScopeRefusal";
27
+ }
28
+ }
29
+
30
+ /** `--workspace` / `--actor` were given wrongly: a usage error (exit 2). */
31
+ export class ScopeUsageError extends Error {
32
+ constructor(message: string) {
33
+ super(message);
34
+ this.name = "ScopeUsageError";
35
+ }
36
+ }
37
+
38
+ const FLAGS: Record<string, "workspace" | "actor"> = {
39
+ "--workspace": "workspace",
40
+ "-w": "workspace",
41
+ "--actor": "actor",
42
+ };
43
+
44
+ /**
45
+ * Take `--workspace <ws>` (or `-w`) and `--actor agent:<id>` out of `argv`,
46
+ * also in the `--flag=value` form. Both or neither: a scope without an actor
47
+ * would act unattributed, an actor without a scope would look bound when it
48
+ * is not. Returns the scope (null when neither is given) and the rest of
49
+ * argv, unchanged otherwise.
50
+ */
51
+ export function parseScopeFlags(argv: readonly string[]): {
52
+ scope: McpScope | null;
53
+ rest: string[];
54
+ } {
55
+ const got: Partial<Record<"workspace" | "actor", string>> = {};
56
+ const rest: string[] = [];
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const a = argv[i];
59
+ const eq = a.indexOf("=");
60
+ const name = a.startsWith("-") && eq !== -1 ? a.slice(0, eq) : a;
61
+ const key = FLAGS[name];
62
+ if (!key) {
63
+ rest.push(a);
64
+ continue;
65
+ }
66
+ let value: string | undefined;
67
+ if (name !== a) value = a.slice(eq + 1);
68
+ else {
69
+ value = argv[i + 1];
70
+ if (value === undefined || value.startsWith("-"))
71
+ throw new ScopeUsageError(`${name} needs a value`);
72
+ i++;
73
+ }
74
+ if (got[key] !== undefined && got[key] !== value)
75
+ throw new ScopeUsageError(`--${key} given twice`);
76
+ got[key] = value;
77
+ }
78
+ if (got.workspace === undefined && got.actor === undefined)
79
+ return { scope: null, rest };
80
+ if (got.workspace === undefined)
81
+ throw new ScopeUsageError(
82
+ "--actor needs --workspace: a scoped server serves one workspace",
83
+ );
84
+ if (got.actor === undefined)
85
+ throw new ScopeUsageError(
86
+ "--workspace needs --actor agent:<id>: a scoped server says who acts",
87
+ );
88
+ if (!WORKSPACE_NAME_RE.test(got.workspace))
89
+ throw new ScopeUsageError(
90
+ `--workspace "${printable(got.workspace)}" is not a workspace name ` +
91
+ "(letters, digits, dots, dashes, underscores; at most 64)",
92
+ );
93
+ if (!AGENT_ACTOR_RE.test(got.actor))
94
+ throw new ScopeUsageError(
95
+ `--actor "${printable(got.actor)}" is not agent:<id> ` +
96
+ "(the id: letters, digits, dots, dashes, underscores; at most 64)",
97
+ );
98
+ return { scope: { workspace: got.workspace, actor: got.actor }, rest };
99
+ }
100
+
101
+ /** A value echoed back in an error: one printable line, bounded. */
102
+ function printable(v: string): string {
103
+ return v.replace(/[^\x20-\x7e]/g, "?").slice(0, 80);
104
+ }
105
+
106
+ /** The refusal for a call that names `other`. */
107
+ export function otherWorkspace(
108
+ tool: string,
109
+ scope: McpScope,
110
+ other: string,
111
+ ): ScopeRefusal {
112
+ return new ScopeRefusal(
113
+ `${tool}: this MCP server is scoped to workspace "${scope.workspace}" ` +
114
+ `(${scope.actor}); "${printable(other)}" is another workspace, so nothing was read or run.`,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * A tool's arguments with the workspace pinned: absent, it becomes the
120
+ * scoped one; another one is refused before anything is asked of the API.
121
+ */
122
+ export function pinWorkspace(
123
+ tool: string,
124
+ args: Record<string, unknown>,
125
+ scope: McpScope,
126
+ ): Record<string, unknown> {
127
+ const ws = args.workspace;
128
+ if (ws !== undefined && ws !== null && ws !== "" && ws !== scope.workspace)
129
+ throw otherWorkspace(tool, scope, String(ws));
130
+ return { ...args, workspace: scope.workspace };
131
+ }
132
+
133
+ /** What /api/workspaces answers (the fields the scope reads). */
134
+ interface WorkspacesBody {
135
+ active?: unknown;
136
+ focus?: unknown;
137
+ workspaces?: unknown;
138
+ [k: string]: unknown;
139
+ }
140
+
141
+ const asRecord = (v: unknown): Record<string, unknown> =>
142
+ v && typeof v === "object" && !Array.isArray(v)
143
+ ? (v as Record<string, unknown>)
144
+ : {};
145
+
146
+ /**
147
+ * /api/workspaces narrowed to the scoped workspace: only its entry, its
148
+ * resolved directory and code-workspace path, `active` holding it or empty,
149
+ * and `focus` only when it is the focus. Everything else is left out.
150
+ */
151
+ export function filterWorkspacesBody(
152
+ body: unknown,
153
+ scope: McpScope,
154
+ ): Record<string, unknown> {
155
+ const b = asRecord(body) as WorkspacesBody;
156
+ const ws = scope.workspace;
157
+ const only = (v: unknown): Record<string, unknown> => {
158
+ const r = asRecord(v);
159
+ return ws in r ? { [ws]: r[ws] } : {};
160
+ };
161
+ return {
162
+ active: Array.isArray(b.active) ? b.active.filter((n) => n === ws) : [],
163
+ ...(b.focus === ws ? { focus: ws } : {}),
164
+ workspaces: only(b.workspaces),
165
+ ...(b.resolvedDirs !== undefined
166
+ ? { resolvedDirs: only(b.resolvedDirs) }
167
+ : {}),
168
+ ...(b.codeWorkspacePaths !== undefined
169
+ ? { codeWorkspacePaths: only(b.codeWorkspacePaths) }
170
+ : {}),
171
+ scope: { workspace: ws, actor: scope.actor },
172
+ };
173
+ }
174
+
175
+ /** The workspace /api/workspaces says is focused (focus, else active[0]). */
176
+ function focusedOf(body: unknown): string | null {
177
+ const b = asRecord(body) as WorkspacesBody;
178
+ if (typeof b.focus === "string" && b.focus) return b.focus;
179
+ if (Array.isArray(b.active) && typeof b.active[0] === "string")
180
+ return b.active[0];
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Throws unless the scoped workspace exists in this distribution. A missing
186
+ * one must not fall through to the web server's "no workspace" defaults,
187
+ * which list services outside any workspace.
188
+ */
189
+ export async function assertScopeExists(
190
+ api: DenApi,
191
+ scope: McpScope,
192
+ ): Promise<unknown> {
193
+ const body = await api.get("/api/workspaces");
194
+ if (!(scope.workspace in asRecord(asRecord(body).workspaces)))
195
+ throw new ScopeRefusal(
196
+ `this MCP server is scoped to workspace "${scope.workspace}", which does not exist in this distribution.`,
197
+ );
198
+ return body;
199
+ }
200
+
201
+ /**
202
+ * Tests run against the focused workspace (the web server has no workspace
203
+ * argument for them): a scoped server runs or lists them only when that is
204
+ * its own.
205
+ */
206
+ export async function assertScopeFocused(
207
+ tool: string,
208
+ api: DenApi,
209
+ scope: McpScope,
210
+ ): Promise<void> {
211
+ const focused = focusedOf(await api.get("/api/workspaces"));
212
+ if (focused !== scope.workspace)
213
+ throw new ScopeRefusal(
214
+ `${tool}: tests run against the focused workspace` +
215
+ `${focused ? ` ("${focused}")` : ""}, and this MCP server is scoped to ` +
216
+ `"${scope.workspace}". Focus "${scope.workspace}" first; nothing was read or run.`,
217
+ );
218
+ }
219
+
220
+ /** The fields of an /api/services entry the scope checks read. */
221
+ interface ScopedEntry {
222
+ repo?: unknown;
223
+ service?: unknown;
224
+ repoPath?: unknown;
225
+ }
226
+
227
+ async function scopedEntries(
228
+ api: DenApi,
229
+ scope: McpScope,
230
+ ): Promise<ScopedEntry[]> {
231
+ const list = await api.get("/api/services", {
232
+ env: "local",
233
+ workspace: scope.workspace,
234
+ });
235
+ return Array.isArray(list) ? (list as ScopedEntry[]) : [];
236
+ }
237
+
238
+ /** Every `repo/service` must be one of the scoped workspace's services. */
239
+ export async function assertServicesInScope(
240
+ tool: string,
241
+ api: DenApi,
242
+ scope: McpScope,
243
+ targets: readonly { repo: string; service: string }[],
244
+ ): Promise<void> {
245
+ const entries = await scopedEntries(api, scope);
246
+ const have = new Set(entries.map((e) => `${e.repo}/${e.service}`));
247
+ const outside = targets
248
+ .map((t) => `${t.repo}/${t.service}`)
249
+ .filter((t) => !have.has(t));
250
+ if (outside.length)
251
+ throw new ScopeRefusal(
252
+ `${tool}: ${outside.map(printable).join(", ")} ${outside.length === 1 ? "is" : "are"} not a service ` +
253
+ `of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
254
+ );
255
+ }
256
+
257
+ /** A manifest path must be absolute and inside one of the workspace's repos. */
258
+ export async function assertPathInScope(
259
+ tool: string,
260
+ api: DenApi,
261
+ scope: McpScope,
262
+ p: string,
263
+ ): Promise<void> {
264
+ if (!path.isAbsolute(p))
265
+ throw new ScopeRefusal(
266
+ `${tool}: a scoped MCP server takes an absolute path inside workspace "${scope.workspace}"'s repos (or the manifest content).`,
267
+ );
268
+ const target = path.resolve(p);
269
+ const roots = (await scopedEntries(api, scope))
270
+ .map((e) => e.repoPath)
271
+ .filter((r): r is string => typeof r === "string" && r.length > 0)
272
+ .map((r) => path.resolve(r));
273
+ if (!roots.some((r) => target === r || target.startsWith(r + path.sep)))
274
+ throw new ScopeRefusal(
275
+ `${tool}: ${printable(p)} is not inside a repo of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
276
+ );
277
+ }