@cldmv/gh-broker 0.0.0-stage → 0.7.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/.configs/fix-headers.json +3 -0
- package/.configs/vitest.config.mjs +44 -0
- package/.github/dependabot.yml +111 -0
- package/.github/workflows/branch-retention.yml +44 -0
- package/.github/workflows/bundle-size.yml +52 -0
- package/.github/workflows/ci.yml +389 -0
- package/.github/workflows/cla.yml +66 -0
- package/.github/workflows/codeql.yml +87 -0
- package/.github/workflows/dependabot-auto-merge.yml +62 -0
- package/.github/workflows/dependabot-recreate.yml +67 -0
- package/.github/workflows/dependency-review.yml +46 -0
- package/.github/workflows/feature-pr.yml +75 -0
- package/.github/workflows/hotfix-redirector.yml +67 -0
- package/.github/workflows/hotfixes-release.yml +70 -0
- package/.github/workflows/labeler.yml +48 -0
- package/.github/workflows/master-commit-audit.yml +56 -0
- package/.github/workflows/member-auto-merge.yml +75 -0
- package/.github/workflows/next-release.yml +76 -0
- package/.github/workflows/next-reset.yml +50 -0
- package/.github/workflows/pr-notify.yml +44 -0
- package/.github/workflows/pr-title-normalizer.yml +52 -0
- package/.github/workflows/provenance.yml +51 -0
- package/.github/workflows/publish.yml +146 -0
- package/.github/workflows/release-merge.yml +104 -0
- package/.github/workflows/release-notify.yml +41 -0
- package/.github/workflows/scorecard.yml +64 -0
- package/.github/workflows/stale.yml +50 -0
- package/.github/workflows/tag-health.yml +70 -0
- package/.github/workflows/update-major-version-tags.yml +94 -0
- package/.github/workflows/v4-bootstrap.yml +115 -0
- package/.github/workflows/welcome.yml +42 -0
- package/LICENSE +202 -0
- package/README.md +185 -2
- package/config.example.json +281 -0
- package/deploy/README.md +92 -0
- package/deploy/gh-broker.service +57 -0
- package/deploy/install.mjs +344 -0
- package/deploy/install.sh +29 -0
- package/deploy/lib/config-merge.mjs +56 -0
- package/deploy/lib/prompt.mjs +98 -0
- package/deploy/lib/secret-store.mjs +114 -0
- package/deploy/lib/smoke.mjs +137 -0
- package/deploy/lib/system.mjs +222 -0
- package/dist/index.mjs +22970 -0
- package/dist/index.mjs.map +7 -0
- package/docs/TOOLS.md +347 -0
- package/docs/agent-user-model.md +89 -0
- package/docs/changelog/v0/v0.2.0.md +26 -0
- package/docs/changelog/v0/v0.2.1.md +23 -0
- package/docs/changelog/v0/v0.2.2.md +23 -0
- package/docs/changelog/v0/v0.2.3.md +33 -0
- package/docs/changelog/v0/v0.2.4.md +25 -0
- package/docs/changelog/v0/v0.2.5.md +29 -0
- package/docs/changelog/v0/v0.3.0.md +84 -0
- package/docs/changelog/v0/v0.3.1.md +30 -0
- package/docs/changelog/v0/v0.3.2.md +24 -0
- package/docs/changelog/v0/v0.3.3.md +28 -0
- package/docs/changelog/v0/v0.4.0.md +58 -0
- package/docs/changelog/v0/v0.4.1.md +33 -0
- package/docs/changelog/v0/v0.4.2.md +45 -0
- package/docs/changelog/v0/v0.5.0.md +39 -0
- package/docs/changelog/v0/v0.5.1.md +28 -0
- package/docs/changelog/v0/v0.6.0.md +52 -0
- package/docs/changelog/v0/v0.6.1.md +48 -0
- package/docs/changelog/v0/v0.7.0.md +71 -0
- package/docs/deploy-architecture.md +82 -0
- package/package.json +71 -5
- package/src/appauth.ts +56 -0
- package/src/audit.ts +34 -0
- package/src/catalog.ts +165 -0
- package/src/config.ts +130 -0
- package/src/index.ts +107 -0
- package/src/paths.ts +60 -0
- package/src/router.ts +258 -0
- package/src/secrets.ts +137 -0
- package/src/server.ts +91 -0
- package/src/types.ts +104 -0
- package/src/upstream.ts +85 -0
- package/src/verbs/issue_labels.ts +329 -0
- package/src/verbs/link_issue.ts +84 -0
- package/src/verbs/release-analysis.ts +210 -0
- package/src/verbs/repo-tier.ts +90 -0
- package/src/verbs/report_ci_status.ts +358 -0
- package/src/verbs/report_release_prs.ts +238 -0
- package/src/verbs/report_security_alerts.ts +442 -0
- package/src/verbs/report_unlabeled.ts +194 -0
- package/src/verbs/rest-client.ts +181 -0
- package/src/verbs/review_thread.ts +138 -0
- package/tests/appauth.test.vitest.mts +76 -0
- package/tests/audit.test.vitest.mts +48 -0
- package/tests/catalog.test.vitest.mts +180 -0
- package/tests/config-merge.test.vitest.mts +58 -0
- package/tests/config.test.vitest.mts +314 -0
- package/tests/issue-labels.test.vitest.mts +347 -0
- package/tests/paths.test.vitest.mts +52 -0
- package/tests/release-prs.test.vitest.mts +237 -0
- package/tests/rest-client.test.vitest.mts +115 -0
- package/tests/router.test.vitest.mts +399 -0
- package/tests/run-vitest.mjs +51 -0
- package/tests/secret-store.test.vitest.mts +106 -0
- package/tests/secrets.test.vitest.mts +155 -0
- package/tests/security-alerts.test.vitest.mts +328 -0
- package/tests/server.test.vitest.mts +20 -0
- package/tests/upstream.test.vitest.mts +91 -0
- package/tests/verb-helpers.test.vitest.mts +331 -0
- package/tests/verbs.test.vitest.mts +624 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { buildCatalog } from "../src/catalog.ts";
|
|
3
|
+
|
|
4
|
+
function makeConfig(tools: any[], owners: Record<string, unknown> = { cldmv: { read: "cred:r", write: "cred:w" } }): any {
|
|
5
|
+
return { listen: "unix:/x", upstream: "https://api.githubcopilot.com/mcp/", tools, owners };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("buildCatalog", () => {
|
|
9
|
+
let secrets: { resolve: ReturnType<typeof vi.fn> };
|
|
10
|
+
let upstream: { listToolsFor: ReturnType<typeof vi.fn> };
|
|
11
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
secrets = { resolve: vi.fn(() => "tok") };
|
|
15
|
+
upstream = { listToolsFor: vi.fn() };
|
|
16
|
+
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
delete process.env.GH_BROKER_CATALOG_TIMEOUT_MS;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses upstream schemas, emits local schemas for custom verbs, and skips unexposed proxied tools", async () => {
|
|
25
|
+
const upstreamTool = { name: "get_me", description: "real", inputSchema: { type: "object", properties: {} } };
|
|
26
|
+
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
27
|
+
const config = makeConfig([
|
|
28
|
+
{ name: "get_me", mode: "read" },
|
|
29
|
+
{ name: "not_exposed", mode: "read" },
|
|
30
|
+
{ name: "link_issue", mode: "write", custom: true },
|
|
31
|
+
{ name: "resolve_review_thread", mode: "write", custom: true },
|
|
32
|
+
{ name: "reply_review_thread", mode: "write", custom: true },
|
|
33
|
+
{ name: "mystery_custom", mode: "read", custom: true }
|
|
34
|
+
]);
|
|
35
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
36
|
+
|
|
37
|
+
expect(secrets.resolve).toHaveBeenCalledWith("cred:r");
|
|
38
|
+
const names = catalog.map((t) => t.name);
|
|
39
|
+
// get_me is served from upstream; not_exposed is dropped (fetched but absent);
|
|
40
|
+
// the two wired custom verbs get local schemas; link_issue is DEFERRED (its
|
|
41
|
+
// catalog branch is commented out) and mystery_custom matches no branch — both
|
|
42
|
+
// are silently skipped even when allowlisted.
|
|
43
|
+
expect(names).toEqual(["get_me", "resolve_review_thread", "reply_review_thread"]);
|
|
44
|
+
expect(names).not.toContain("link_issue");
|
|
45
|
+
expect(catalog[0]).toBe(upstreamTool);
|
|
46
|
+
expect(catalog.find((t) => t.name === "resolve_review_thread")?.inputSchema).toMatchObject({ type: "object" });
|
|
47
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/allowlisted tool "not_exposed" is NOT exposed/));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("advertises report_unlabeled with its local schema", async () => {
|
|
51
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
52
|
+
const config = makeConfig([{ name: "report_unlabeled", mode: "read", custom: true }]);
|
|
53
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
54
|
+
expect(catalog).toHaveLength(1);
|
|
55
|
+
expect(catalog[0]).toMatchObject({ name: "report_unlabeled" });
|
|
56
|
+
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("advertises report_security_alerts with its local schema", async () => {
|
|
60
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
61
|
+
const config = makeConfig([{ name: "report_security_alerts", mode: "read", custom: true }]);
|
|
62
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
63
|
+
expect(catalog).toHaveLength(1);
|
|
64
|
+
expect(catalog[0]).toMatchObject({ name: "report_security_alerts" });
|
|
65
|
+
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("advertises report_release_prs with its local schema", async () => {
|
|
69
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
70
|
+
const config = makeConfig([{ name: "report_release_prs", mode: "read", custom: true }]);
|
|
71
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
72
|
+
expect(catalog).toHaveLength(1);
|
|
73
|
+
expect(catalog[0]).toMatchObject({ name: "report_release_prs" });
|
|
74
|
+
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("advertises issue_labels with its local schema", async () => {
|
|
78
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
79
|
+
const config = makeConfig([{ name: "issue_labels", mode: "write", custom: true }]);
|
|
80
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
81
|
+
expect(catalog).toHaveLength(1);
|
|
82
|
+
expect(catalog[0]).toMatchObject({ name: "issue_labels" });
|
|
83
|
+
expect(catalog[0]?.description).toMatch(/swap/);
|
|
84
|
+
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "issue_number"] });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("warns on issue_write that labels replaces the set, pointing at issue_labels when it is allowlisted", async () => {
|
|
88
|
+
const upstreamTool = { name: "issue_write", description: "Create or update an issue.", inputSchema: { type: "object", properties: { labels: {} } } };
|
|
89
|
+
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
90
|
+
const withVerb = await buildCatalog(
|
|
91
|
+
makeConfig([{ name: "issue_write", mode: "write" }, { name: "issue_labels", mode: "write", custom: true }]),
|
|
92
|
+
secrets as any,
|
|
93
|
+
upstream as any
|
|
94
|
+
);
|
|
95
|
+
const iw = withVerb.find((t) => t.name === "issue_write")!;
|
|
96
|
+
expect(iw.description).toMatch(/^Create or update an issue\. WARNING \(gh-broker\): "labels" REPLACES the issue's whole label set/);
|
|
97
|
+
expect(iw.description).toMatch(/use issue_labels instead/);
|
|
98
|
+
expect(iw.inputSchema).toBe(upstreamTool.inputSchema);
|
|
99
|
+
expect(upstreamTool.description).toBe("Create or update an issue."); // the fetched tool is not mutated
|
|
100
|
+
|
|
101
|
+
const without = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
102
|
+
expect(without[0]?.description).toMatch(/REPLACES/);
|
|
103
|
+
expect(without[0]?.description).not.toMatch(/issue_labels/);
|
|
104
|
+
expect(without[0]?.description).toMatch(/pass the full set/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("still warns on issue_write when the upstream fetch fails (minimal schema)", async () => {
|
|
108
|
+
upstream.listToolsFor.mockRejectedValue(new Error("down"));
|
|
109
|
+
const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
110
|
+
expect(catalog[0]?.description).toMatch(/^\(write\) proxied to the GitHub MCP WARNING/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("advertises report_ci_status with its local schema", async () => {
|
|
114
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
115
|
+
const config = makeConfig([{ name: "report_ci_status", mode: "read", custom: true }]);
|
|
116
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
117
|
+
expect(catalog).toHaveLength(1);
|
|
118
|
+
expect(catalog[0]).toMatchObject({ name: "report_ci_status" });
|
|
119
|
+
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo"] });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("layers ciVerbosity/logLines onto pull_request_read's real upstream schema without dropping its own params", async () => {
|
|
123
|
+
const upstreamTool = {
|
|
124
|
+
name: "pull_request_read",
|
|
125
|
+
description: "Get information on a specific pull request.",
|
|
126
|
+
inputSchema: { type: "object", required: ["owner", "repo", "pullNumber", "method"], properties: { method: { type: "string" }, pullNumber: { type: "number" } } }
|
|
127
|
+
};
|
|
128
|
+
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
129
|
+
const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
|
|
130
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
131
|
+
expect(catalog).toHaveLength(1);
|
|
132
|
+
const tool = catalog[0]!;
|
|
133
|
+
expect(tool.description).toContain("gh-broker extension");
|
|
134
|
+
expect((tool.inputSchema as any).required).toEqual(["owner", "repo", "pullNumber", "method"]);
|
|
135
|
+
expect((tool.inputSchema as any).properties.pullNumber).toEqual({ type: "number" });
|
|
136
|
+
expect((tool.inputSchema as any).properties.ciVerbosity).toMatchObject({ type: "number", enum: [0, 1, 2, 3] });
|
|
137
|
+
expect((tool.inputSchema as any).properties.logLines).toMatchObject({ type: "number" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("still adds ciVerbosity/logLines to pull_request_read when the upstream fetch fails entirely", async () => {
|
|
141
|
+
upstream.listToolsFor.mockRejectedValue(new Error("network down"));
|
|
142
|
+
const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
|
|
143
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
144
|
+
expect(catalog).toHaveLength(1);
|
|
145
|
+
expect((catalog[0]!.inputSchema as any).properties).toEqual({
|
|
146
|
+
ciVerbosity: { type: "number", enum: [0, 1, 2, 3], description: expect.any(String) },
|
|
147
|
+
logLines: { type: "number", description: expect.any(String) }
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("falls back to minimal schemas when the upstream fetch fails", async () => {
|
|
152
|
+
upstream.listToolsFor.mockRejectedValue(new Error("401 bad token"));
|
|
153
|
+
const config = makeConfig([
|
|
154
|
+
{ name: "get_me", mode: "read" },
|
|
155
|
+
{ name: "resolve_review_thread", mode: "write", custom: true }
|
|
156
|
+
]);
|
|
157
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
158
|
+
|
|
159
|
+
const getMe = catalog.find((t) => t.name === "get_me");
|
|
160
|
+
expect(getMe).toEqual({ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } });
|
|
161
|
+
expect(catalog.map((t) => t.name)).toContain("resolve_review_thread");
|
|
162
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/could not fetch upstream tool schemas/));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("skips the upstream fetch entirely when there are no owners", async () => {
|
|
166
|
+
const config = makeConfig([{ name: "get_me", mode: "read" }], {});
|
|
167
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
168
|
+
expect(upstream.listToolsFor).not.toHaveBeenCalled();
|
|
169
|
+
expect(catalog).toEqual([{ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } }]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("times out a slow upstream and advertises minimal schemas", async () => {
|
|
173
|
+
process.env.GH_BROKER_CATALOG_TIMEOUT_MS = "15";
|
|
174
|
+
upstream.listToolsFor.mockReturnValue(new Promise(() => {})); // never resolves
|
|
175
|
+
const config = makeConfig([{ name: "get_me", mode: "read" }]);
|
|
176
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
177
|
+
expect(catalog.map((t) => t.name)).toEqual(["get_me"]);
|
|
178
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 15ms/));
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergeConfig } from "../deploy/lib/config-merge.mjs";
|
|
3
|
+
|
|
4
|
+
const example = {
|
|
5
|
+
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
6
|
+
upstream: "https://api.githubcopilot.com/mcp/x/all",
|
|
7
|
+
tools: [
|
|
8
|
+
{ name: "get_me", mode: "read" },
|
|
9
|
+
{ name: "list_issues", mode: "read" }
|
|
10
|
+
],
|
|
11
|
+
owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } },
|
|
12
|
+
defaultOwner: "cldmv"
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("mergeConfig", () => {
|
|
16
|
+
it("uses the example as-is on first install (no current)", () => {
|
|
17
|
+
expect(mergeConfig(example, null)).toEqual(example);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("takes tools from the example so allowlist removals/tightenings apply", () => {
|
|
21
|
+
const current = { ...example, tools: [{ name: "get_me", mode: "read" }, { name: "removed_tool", mode: "write" }] };
|
|
22
|
+
const merged = mergeConfig(example, current);
|
|
23
|
+
expect(merged.tools).toEqual(example.tools);
|
|
24
|
+
expect(merged.tools.find((t: { name: string }) => t.name === "removed_tool")).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("preserves current owner data and adds example-only owners on upgrade", () => {
|
|
28
|
+
const current = { ...example, owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write", installationId: 123 } } };
|
|
29
|
+
const example2 = {
|
|
30
|
+
...example,
|
|
31
|
+
owners: {
|
|
32
|
+
cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" },
|
|
33
|
+
acme: { read: "cred:acme-read", write: "cred:acme-write" }
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const merged = mergeConfig(example2, current);
|
|
37
|
+
expect(merged.owners.cldmv.installationId).toBe(123); // current wins
|
|
38
|
+
expect(merged.owners.acme).toBeDefined(); // new example owner appears
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("lets current win for listen/upstream/defaultOwner/app/secrets", () => {
|
|
42
|
+
const current = { ...example, listen: "unix:/tmp/custom.sock", app: { appId: 1, privateKey: "cred:k" } };
|
|
43
|
+
const merged = mergeConfig(example, current);
|
|
44
|
+
expect(merged.listen).toBe("unix:/tmp/custom.sock");
|
|
45
|
+
expect(merged.app).toEqual({ appId: 1, privateKey: "cred:k" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not mutate its inputs", () => {
|
|
49
|
+
const ex = structuredClone(example);
|
|
50
|
+
mergeConfig(ex, { ...example, listen: "unix:/z" });
|
|
51
|
+
expect(ex).toEqual(example);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects an array as example or current (typeof reports 'object')", () => {
|
|
55
|
+
expect(() => mergeConfig([], null)).toThrow(/example config must be an object/);
|
|
56
|
+
expect(() => mergeConfig(example, [])).toThrow(/current config must be an object/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadConfig, indexTools } from "../src/config.ts";
|
|
5
|
+
|
|
6
|
+
// loadConfig reads a real file off disk, so we materialize each fixture into the
|
|
7
|
+
// repo's gitignored tmp/ and pass the path.
|
|
8
|
+
const DIR = join(process.cwd(), "tmp", "config-tests");
|
|
9
|
+
let counter = 0;
|
|
10
|
+
|
|
11
|
+
/** Write `contents` (string or object) to a unique tmp file and return its path. */
|
|
12
|
+
function writeConfig(contents: unknown): string {
|
|
13
|
+
const path = join(DIR, `cfg-${counter++}.json`);
|
|
14
|
+
writeFileSync(path, typeof contents === "string" ? contents : JSON.stringify(contents));
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function baseConfig(): Record<string, unknown> {
|
|
19
|
+
return {
|
|
20
|
+
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
21
|
+
upstream: "https://api.githubcopilot.com/mcp/",
|
|
22
|
+
tools: [{ name: "get_me", mode: "read" }],
|
|
23
|
+
owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } }
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
mkdirSync(DIR, { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
rmSync(DIR, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("loadConfig — file/parse errors", () => {
|
|
36
|
+
it("throws when the file cannot be read", () => {
|
|
37
|
+
expect(() => loadConfig(join(DIR, "does-not-exist.json"))).toThrow(/cannot read\/parse/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("throws on malformed JSON", () => {
|
|
41
|
+
const p = writeConfig("{ not json ");
|
|
42
|
+
expect(() => loadConfig(p)).toThrow(/cannot read\/parse/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws when the root is not an object", () => {
|
|
46
|
+
const p = writeConfig("123");
|
|
47
|
+
expect(() => loadConfig(p)).toThrow(/root must be an object/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("throws when the root is JSON null", () => {
|
|
51
|
+
const p = writeConfig("null");
|
|
52
|
+
expect(() => loadConfig(p)).toThrow(/root must be an object/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("loadConfig — listen / upstream", () => {
|
|
57
|
+
it("rejects a non-string listen", () => {
|
|
58
|
+
const c = baseConfig();
|
|
59
|
+
delete c.listen;
|
|
60
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.listen/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects a listen without the unix: prefix", () => {
|
|
64
|
+
const c = baseConfig();
|
|
65
|
+
c.listen = "tcp:0.0.0.0:9000";
|
|
66
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.listen/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects a non-http upstream", () => {
|
|
70
|
+
const c = baseConfig();
|
|
71
|
+
c.upstream = "ftp://example.com";
|
|
72
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.upstream/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("rejects a non-string upstream", () => {
|
|
76
|
+
const c = baseConfig();
|
|
77
|
+
c.upstream = 42;
|
|
78
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.upstream/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("loadConfig — tools", () => {
|
|
83
|
+
it("rejects a missing tools array", () => {
|
|
84
|
+
const c = baseConfig();
|
|
85
|
+
delete c.tools;
|
|
86
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools: must be a non-empty array/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects an empty tools array", () => {
|
|
90
|
+
const c = baseConfig();
|
|
91
|
+
c.tools = [];
|
|
92
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools: must be a non-empty array/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects a tool with no name", () => {
|
|
96
|
+
const c = baseConfig();
|
|
97
|
+
c.tools = [{ mode: "read" }];
|
|
98
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools\[0\]\.name: required/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rejects a tool with a bad mode", () => {
|
|
102
|
+
const c = baseConfig();
|
|
103
|
+
c.tools = [{ name: "get_me", mode: "admin" }];
|
|
104
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools\[0\]\.mode: must be read\|write/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects duplicate tool names", () => {
|
|
108
|
+
const c = baseConfig();
|
|
109
|
+
c.tools = [
|
|
110
|
+
{ name: "get_me", mode: "read" },
|
|
111
|
+
{ name: "get_me", mode: "write" }
|
|
112
|
+
];
|
|
113
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/duplicate tool "get_me"/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("carries the custom flag through, defaulting to false", () => {
|
|
117
|
+
const c = baseConfig();
|
|
118
|
+
c.tools = [
|
|
119
|
+
{ name: "get_me", mode: "read" },
|
|
120
|
+
{ name: "link_issue", mode: "write", custom: true }
|
|
121
|
+
];
|
|
122
|
+
const out = loadConfig(writeConfig(c));
|
|
123
|
+
expect(out.tools).toEqual([
|
|
124
|
+
{ name: "get_me", mode: "read", custom: false, augment: false },
|
|
125
|
+
{ name: "link_issue", mode: "write", custom: true, augment: false }
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("carries the augment flag through, defaulting to false", () => {
|
|
130
|
+
const c = baseConfig();
|
|
131
|
+
c.tools = [
|
|
132
|
+
{ name: "get_me", mode: "read" },
|
|
133
|
+
{ name: "pull_request_read", mode: "read", augment: true }
|
|
134
|
+
];
|
|
135
|
+
const out = loadConfig(writeConfig(c));
|
|
136
|
+
expect(out.tools).toEqual([
|
|
137
|
+
{ name: "get_me", mode: "read", custom: false, augment: false },
|
|
138
|
+
{ name: "pull_request_read", mode: "read", custom: false, augment: true }
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("rejects a tool marked both custom and augment", () => {
|
|
143
|
+
const c = baseConfig();
|
|
144
|
+
c.tools = [{ name: "weird", mode: "read", custom: true, augment: true }];
|
|
145
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/custom and augment are mutually exclusive/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("loadConfig — owners", () => {
|
|
150
|
+
it("rejects a non-object owners", () => {
|
|
151
|
+
const c = baseConfig();
|
|
152
|
+
c.owners = "cldmv";
|
|
153
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners: must be an object/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("rejects an owner missing read/write", () => {
|
|
157
|
+
const c = baseConfig();
|
|
158
|
+
c.owners = { cldmv: { read: "cred:x" } };
|
|
159
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners\.cldmv: needs "read" and "write"/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rejects a non-numeric installationId", () => {
|
|
163
|
+
const c = baseConfig();
|
|
164
|
+
c.owners = { cldmv: { read: "cred:r", write: "cred:w", installationId: "42" } };
|
|
165
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners\.cldmv\.installationId: must be a number/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("rejects an empty owners object", () => {
|
|
169
|
+
const c = baseConfig();
|
|
170
|
+
c.owners = {};
|
|
171
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/at least one owner required/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("lowercases owner keys and carries installationId", () => {
|
|
175
|
+
const c = baseConfig();
|
|
176
|
+
c.owners = { CLDMV: { read: "cred:r", write: "cred:w", installationId: 99 } };
|
|
177
|
+
const out = loadConfig(writeConfig(c));
|
|
178
|
+
expect(out.owners).toEqual({ cldmv: { read: "cred:r", write: "cred:w", installationId: 99 } });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("omits installationId when absent", () => {
|
|
182
|
+
const out = loadConfig(writeConfig(baseConfig()));
|
|
183
|
+
expect(out.owners.cldmv).toEqual({ read: "cred:cldmv-read", write: "cred:cldmv-write" });
|
|
184
|
+
expect(out.owners.cldmv).not.toHaveProperty("installationId");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("loadConfig — defaultOwner", () => {
|
|
189
|
+
it("is undefined when omitted", () => {
|
|
190
|
+
const out = loadConfig(writeConfig(baseConfig()));
|
|
191
|
+
expect(out.defaultOwner).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("lowercases a valid defaultOwner and carries it through", () => {
|
|
195
|
+
const c = baseConfig();
|
|
196
|
+
c.defaultOwner = "CLDMV";
|
|
197
|
+
const out = loadConfig(writeConfig(c));
|
|
198
|
+
expect(out.defaultOwner).toBe("cldmv");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("rejects a non-string / empty defaultOwner", () => {
|
|
202
|
+
const c = baseConfig();
|
|
203
|
+
c.defaultOwner = "";
|
|
204
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.defaultOwner: must be a non-empty string/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("rejects a defaultOwner that is not a configured owner", () => {
|
|
208
|
+
const c = baseConfig();
|
|
209
|
+
c.defaultOwner = "nobody";
|
|
210
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.defaultOwner "nobody" is not a key of config\.owners/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("loadConfig — app", () => {
|
|
215
|
+
it("rejects a non-numeric appId", () => {
|
|
216
|
+
const c = baseConfig();
|
|
217
|
+
c.app = { appId: "1", privateKey: "cred:app" };
|
|
218
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.app\.appId: must be a number/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("rejects a non-string privateKey", () => {
|
|
222
|
+
const c = baseConfig();
|
|
223
|
+
c.app = { appId: 1, privateKey: 5 };
|
|
224
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.app\.privateKey/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("carries a valid app config through", () => {
|
|
228
|
+
const c = baseConfig();
|
|
229
|
+
c.app = { appId: 12345, privateKey: "cred:gh-app-key" };
|
|
230
|
+
const out = loadConfig(writeConfig(c));
|
|
231
|
+
expect(out.app).toEqual({ appId: 12345, privateKey: "cred:gh-app-key" });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("omits app when absent", () => {
|
|
235
|
+
const out = loadConfig(writeConfig(baseConfig()));
|
|
236
|
+
expect(out.app).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("loadConfig — secrets", () => {
|
|
241
|
+
it("rejects an array (which typeof reports as 'object')", () => {
|
|
242
|
+
const c = baseConfig();
|
|
243
|
+
c.secrets = [];
|
|
244
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets: must be an object/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("rejects null", () => {
|
|
248
|
+
const c = baseConfig();
|
|
249
|
+
c.secrets = null;
|
|
250
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets: must be an object/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("rejects an unknown provider", () => {
|
|
254
|
+
const c = baseConfig();
|
|
255
|
+
c.secrets = { provider: "vault" };
|
|
256
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.provider/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("carries a valid secrets block through", () => {
|
|
260
|
+
const c = baseConfig();
|
|
261
|
+
c.secrets = { provider: "file", path: "/etc/gh-broker/secrets.json" };
|
|
262
|
+
const out = loadConfig(writeConfig(c));
|
|
263
|
+
expect(out.secrets).toEqual({ provider: "file", path: "/etc/gh-broker/secrets.json" });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("rejects a path without the file provider (omitted → systemd-creds default)", () => {
|
|
267
|
+
const c = baseConfig();
|
|
268
|
+
c.secrets = { path: "/etc/gh-broker/secrets.json" };
|
|
269
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path is only used by the "file" provider/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("rejects a path with the systemd-creds provider", () => {
|
|
273
|
+
const c = baseConfig();
|
|
274
|
+
c.secrets = { provider: "systemd-creds", path: "/etc/gh-broker/secrets.json" };
|
|
275
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path is only used by the "file" provider/);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("rejects an empty path (would be silently ignored by resolveSecretsPath)", () => {
|
|
279
|
+
const c = baseConfig();
|
|
280
|
+
c.secrets = { provider: "file", path: "" };
|
|
281
|
+
expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path: must be a non-empty string/);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("loadConfig — full valid config", () => {
|
|
286
|
+
it("returns the normalized shape", () => {
|
|
287
|
+
const c = baseConfig();
|
|
288
|
+
c.defaultOwner = "cldmv";
|
|
289
|
+
c.app = { appId: 7, privateKey: "cred:app" };
|
|
290
|
+
const out = loadConfig(writeConfig(c));
|
|
291
|
+
expect(out).toEqual({
|
|
292
|
+
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
293
|
+
upstream: "https://api.githubcopilot.com/mcp/",
|
|
294
|
+
tools: [{ name: "get_me", mode: "read", custom: false, augment: false }],
|
|
295
|
+
owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } },
|
|
296
|
+
app: { appId: 7, privateKey: "cred:app" },
|
|
297
|
+
defaultOwner: "cldmv"
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("indexTools", () => {
|
|
303
|
+
it("builds a name→rule map", () => {
|
|
304
|
+
const rules = [
|
|
305
|
+
{ name: "get_me", mode: "read" as const },
|
|
306
|
+
{ name: "create_pull_request", mode: "write" as const, custom: false }
|
|
307
|
+
];
|
|
308
|
+
const map = indexTools(rules);
|
|
309
|
+
expect(map.size).toBe(2);
|
|
310
|
+
expect(map.get("get_me")).toEqual({ name: "get_me", mode: "read" });
|
|
311
|
+
expect(map.get("create_pull_request")).toEqual({ name: "create_pull_request", mode: "write", custom: false });
|
|
312
|
+
expect(map.get("missing")).toBeUndefined();
|
|
313
|
+
});
|
|
314
|
+
});
|