@cldmv/gh-broker 0.7.0 → 0.9.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/README.md +165 -41
- package/config.example.json +38 -9
- package/config.sample.json +45 -0
- package/deploy/README.md +76 -17
- package/deploy/install.mjs +171 -104
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +5864 -1809
- package/dist/index.mjs.map +4 -4
- package/package.json +18 -8
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
- package/docs/TOOLS.md +0 -347
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/deploy-architecture.md +0 -82
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/catalog.ts +0 -165
- package/src/config.ts +0 -130
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -258
- package/src/secrets.ts +0 -137
- package/src/server.ts +0 -91
- package/src/types.ts +0 -104
- package/src/upstream.ts +0 -85
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/release-analysis.ts +0 -210
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -180
- package/tests/config-merge.test.vitest.mts +0 -58
- package/tests/config.test.vitest.mts +0 -314
- package/tests/issue-labels.test.vitest.mts +0 -347
- package/tests/paths.test.vitest.mts +0 -52
- package/tests/release-prs.test.vitest.mts +0 -237
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -399
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -106
- package/tests/secrets.test.vitest.mts +0 -155
- package/tests/security-alerts.test.vitest.mts +0 -328
- package/tests/server.test.vitest.mts +0 -20
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -331
- package/tests/verbs.test.vitest.mts +0 -624
- package/tsconfig.json +0 -21
package/src/secrets.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/secrets.ts
|
|
5
|
-
* @Date: 2026-07-07T00:24:51-07:00 (1783409091)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
17
|
-
import { join } from "node:path";
|
|
18
|
-
import type { Config, SecretStore } from "./types.ts";
|
|
19
|
-
import { resolveSecretsPath } from "./paths.ts";
|
|
20
|
-
|
|
21
|
-
const CRED_PREFIX = "cred:";
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Loads PATs from the configured secret store and returns a resolver mapping
|
|
25
|
-
* "cred:<name>" refs to token values. Never writes or logs a token.
|
|
26
|
-
*
|
|
27
|
-
* Provider seam (chosen at install time, recorded in config.secrets.provider):
|
|
28
|
-
* - "systemd-creds" (default) — tokens are systemd-encrypted, host/TPM-bound
|
|
29
|
-
* `.cred` files delivered via LoadCredentialEncrypted into $CREDENTIALS_DIRECTORY
|
|
30
|
-
* (memory-only, service-user-readable). Encrypted at rest; not portable.
|
|
31
|
-
* - "file" — a JSON `{ "<name>": "<token>" }` map at an agent-unreadable path.
|
|
32
|
-
* Plaintext at rest (perms only); portable and platform-neutral.
|
|
33
|
-
*
|
|
34
|
-
* Both keep tokens off any agent-readable path — the agent boundary is filesystem
|
|
35
|
-
* perms either way. See docs/deploy-architecture.md.
|
|
36
|
-
*/
|
|
37
|
-
export function loadSecretStore(config: Config): SecretStore {
|
|
38
|
-
const provider = config.secrets?.provider ?? "systemd-creds";
|
|
39
|
-
switch (provider) {
|
|
40
|
-
case "systemd-creds":
|
|
41
|
-
return systemdCredsStore(config);
|
|
42
|
-
case "file":
|
|
43
|
-
return fileStore(config);
|
|
44
|
-
default:
|
|
45
|
-
throw new Error(`secrets: unknown provider "${provider}" (expected "systemd-creds" or "file")`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const SAFE_CRED_NAME = /^[A-Za-z0-9._-]+$/;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Credential names become filesystem path components (`join(dir, name)`) and are
|
|
53
|
-
* interpolated into systemd drop-in lines, so constrain them to a safe charset —
|
|
54
|
-
* no path separators, "..", colons, or control characters — to prevent reads
|
|
55
|
-
* outside $CREDENTIALS_DIRECTORY and malformed drop-ins.
|
|
56
|
-
*/
|
|
57
|
-
function assertSafeName(name: string): string {
|
|
58
|
-
if (name === "." || name === ".." || !SAFE_CRED_NAME.test(name)) {
|
|
59
|
-
throw new Error(`secrets: unsafe credential name "${name}" (allowed: letters, digits, ".", "_", "-")`);
|
|
60
|
-
}
|
|
61
|
-
return name;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function credName(ref: string): string {
|
|
65
|
-
if (!ref.startsWith(CRED_PREFIX)) throw new Error(`secrets: bad credential ref "${ref}"`);
|
|
66
|
-
return assertSafeName(ref.slice(CRED_PREFIX.length));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Eagerly read every referenced credential so a missing token fails at startup, not on first request. */
|
|
70
|
-
function validateRefs(config: Config, read: (name: string) => string): void {
|
|
71
|
-
for (const [owner, creds] of Object.entries(config.owners)) {
|
|
72
|
-
for (const ref of [creds.read, creds.write]) {
|
|
73
|
-
if (!ref.startsWith(CRED_PREFIX)) throw new Error(`secrets: owner "${owner}" ref must be "cred:<name>", got "${ref}"`);
|
|
74
|
-
read(assertSafeName(ref.slice(CRED_PREFIX.length)));
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (config.app) {
|
|
78
|
-
// The App key is a secret too — it must be a cred: ref, never inlined in the
|
|
79
|
-
// token-free config. Reject a non-cred ref rather than silently accepting it.
|
|
80
|
-
if (!config.app.privateKey.startsWith(CRED_PREFIX)) {
|
|
81
|
-
throw new Error(`secrets: config.app.privateKey must be a "cred:<name>" ref, got "${config.app.privateKey}"`);
|
|
82
|
-
}
|
|
83
|
-
read(assertSafeName(config.app.privateKey.slice(CRED_PREFIX.length)));
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** systemd-creds provider: read decrypted creds from $CREDENTIALS_DIRECTORY. */
|
|
88
|
-
function systemdCredsStore(config: Config): SecretStore {
|
|
89
|
-
const dir = process.env.CREDENTIALS_DIRECTORY;
|
|
90
|
-
if (!dir) {
|
|
91
|
-
throw new Error("secrets: $CREDENTIALS_DIRECTORY unset — the systemd-creds provider needs LoadCredentialEncrypted= (run under systemd)");
|
|
92
|
-
}
|
|
93
|
-
const cache = new Map<string, string>();
|
|
94
|
-
const read = (name: string): string => {
|
|
95
|
-
const cached = cache.get(name);
|
|
96
|
-
if (cached !== undefined) return cached;
|
|
97
|
-
let val: string;
|
|
98
|
-
try {
|
|
99
|
-
val = readFileSync(join(dir, name), "utf8").replace(/\r?\n$/, "");
|
|
100
|
-
} catch (err) {
|
|
101
|
-
throw new Error(`secrets: credential "${name}" not available: ${(err as Error).message}`);
|
|
102
|
-
}
|
|
103
|
-
if (!val) throw new Error(`secrets: credential "${name}" is empty`);
|
|
104
|
-
cache.set(name, val);
|
|
105
|
-
return val;
|
|
106
|
-
};
|
|
107
|
-
validateRefs(config, read);
|
|
108
|
-
return { resolve: (ref) => read(credName(ref)) };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** file provider: read tokens from a JSON `{ "<name>": "<token>" }` map. */
|
|
112
|
-
function fileStore(config: Config): SecretStore {
|
|
113
|
-
const path = resolveSecretsPath(config.secrets?.path);
|
|
114
|
-
let raw: unknown;
|
|
115
|
-
try {
|
|
116
|
-
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
117
|
-
} catch (err) {
|
|
118
|
-
throw new Error(`secrets: cannot read/parse secrets file ${path}: ${(err as Error).message}`);
|
|
119
|
-
}
|
|
120
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
121
|
-
throw new Error(`secrets: ${path} must be a JSON object of { "<name>": "<token>" }`);
|
|
122
|
-
}
|
|
123
|
-
const map = raw as Record<string, unknown>; // read once into memory; never re-read or write
|
|
124
|
-
|
|
125
|
-
const read = (name: string): string => {
|
|
126
|
-
const val = map[name];
|
|
127
|
-
if (typeof val !== "string") throw new Error(`secrets: credential "${name}" missing or empty in ${path}`);
|
|
128
|
-
// Normalize like the systemd-creds provider: strip a single trailing newline
|
|
129
|
-
// (common when a token is copy/pasted into the JSON) so it isn't silently
|
|
130
|
-
// mismatched at auth time. A value that is only a newline then reads as empty.
|
|
131
|
-
const token = val.replace(/\r?\n$/, "");
|
|
132
|
-
if (token.length === 0) throw new Error(`secrets: credential "${name}" missing or empty in ${path}`);
|
|
133
|
-
return token;
|
|
134
|
-
};
|
|
135
|
-
validateRefs(config, read);
|
|
136
|
-
return { resolve: (ref) => read(credName(ref)) };
|
|
137
|
-
}
|
package/src/server.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/server.ts
|
|
5
|
-
* @Date: 2026-07-07T00:24:51-07:00 (1783409091)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createServer, type Socket } from "node:net";
|
|
17
|
-
import { chmodSync, chownSync, existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
18
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
19
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
-
import type { Config } from "./types.ts";
|
|
22
|
-
import type { Router } from "./router.ts";
|
|
23
|
-
|
|
24
|
-
const SERVER_INFO = { name: "gh-broker", version: "0.1.0" };
|
|
25
|
-
|
|
26
|
-
function socketPath(listen: string): string {
|
|
27
|
-
if (!listen.startsWith("unix:")) throw new Error(`server: unsupported listen "${listen}"`);
|
|
28
|
-
return listen.slice("unix:".length);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Resolve a group name → gid via /etc/group (no native Node API for this). */
|
|
32
|
-
function groupGid(name: string): number | undefined {
|
|
33
|
-
try {
|
|
34
|
-
for (const line of readFileSync("/etc/group", "utf8").split("\n")) {
|
|
35
|
-
const parts = line.split(":");
|
|
36
|
-
if (parts[0] === name && parts[2]) return Number(parts[2]);
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
/* ignore — fall back to RuntimeDirectory perms */
|
|
40
|
-
}
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Each agent connection (a socat stdio bridge to our Unix socket) gets its own MCP
|
|
46
|
-
* Server over a StdioServerTransport bound to that socket. `tools/list` returns the
|
|
47
|
-
* pre-built catalog; `tools/call` delegates to the router (allowlist + owner/mode →
|
|
48
|
-
* PAT + forward/dispatch). The token never crosses to this side.
|
|
49
|
-
*/
|
|
50
|
-
function handleConnection(socket: Socket, router: Router, catalog: Tool[]): void {
|
|
51
|
-
const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
|
|
52
|
-
|
|
53
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: catalog }));
|
|
54
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
55
|
-
const args = (req.params.arguments ?? {}) as Record<string, unknown>;
|
|
56
|
-
return router.call(req.params.name, args);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const transport = new StdioServerTransport(socket, socket);
|
|
60
|
-
server.connect(transport).catch((err: unknown) => {
|
|
61
|
-
console.error(`gh-broker: connection error: ${(err as Error).message}`);
|
|
62
|
-
socket.destroy();
|
|
63
|
-
});
|
|
64
|
-
socket.on("close", () => void server.close().catch(() => {}));
|
|
65
|
-
socket.on("error", () => {
|
|
66
|
-
/* client hangup — ignore */
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function startServer(config: Config, router: Router, catalog: Tool[]): Promise<void> {
|
|
71
|
-
const path = socketPath(config.listen);
|
|
72
|
-
if (existsSync(path)) unlinkSync(path); // clear a stale socket from a prior run
|
|
73
|
-
|
|
74
|
-
return new Promise((resolve, reject) => {
|
|
75
|
-
const server = createServer((sock: Socket) => handleConnection(sock, router, catalog));
|
|
76
|
-
server.on("error", reject);
|
|
77
|
-
server.listen(path, () => {
|
|
78
|
-
// Restrict the socket to the agent-clients group: chgrp to gh-broker-clients,
|
|
79
|
-
// mode 0660 (owner+group rw). The agent user is a member of that group.
|
|
80
|
-
const group = process.env.GH_BROKER_SOCKET_GROUP ?? "gh-broker-clients";
|
|
81
|
-
const gid = groupGid(group);
|
|
82
|
-
try {
|
|
83
|
-
if (gid !== undefined) chownSync(path, process.getuid?.() ?? -1, gid);
|
|
84
|
-
chmodSync(path, 0o660);
|
|
85
|
-
} catch {
|
|
86
|
-
/* best-effort; RuntimeDirectory perms already restrict access */
|
|
87
|
-
}
|
|
88
|
-
resolve();
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/types.ts
|
|
5
|
-
* @Date: 2026-07-07T00:24:51-07:00 (1783409091)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Config + internal types for gh-broker.
|
|
18
|
-
*
|
|
19
|
-
* The on-disk config holds no secrets — only the tool allowlist and, per owner,
|
|
20
|
-
* the *names* of the credentials to use. Actual PAT values are resolved at
|
|
21
|
-
* runtime from a separate, agent-unreadable secrets store (see secrets.ts and
|
|
22
|
-
* docs/deploy-architecture.md). Keeping the config token-free is what makes it
|
|
23
|
-
* safe to copy between machines.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
export type Mode = "read" | "write";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* One entry in the default-deny allowlist. `custom` verbs are handled locally, not
|
|
30
|
-
* proxied. `augment` tools ARE proxied (forwarded to the hosted MCP) but the router
|
|
31
|
-
* post-processes the response afterward — gh-broker's own MCP contract for a proxied
|
|
32
|
-
* tool is not required to match the hosted MCP's exact schema/response verbatim (see
|
|
33
|
-
* router.ts's dispatchAugmented). `custom` and `augment` are mutually exclusive.
|
|
34
|
-
*/
|
|
35
|
-
export interface ToolRule {
|
|
36
|
-
name: string;
|
|
37
|
-
mode: Mode;
|
|
38
|
-
custom?: boolean;
|
|
39
|
-
augment?: boolean;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Credential *references* (e.g. "cred:cldmv-read"), resolved via the SecretStore. */
|
|
43
|
-
export interface OwnerCreds {
|
|
44
|
-
read: string;
|
|
45
|
-
write: string;
|
|
46
|
-
/**
|
|
47
|
-
* GitHub App installation id for this owner. Required only for verbs that need
|
|
48
|
-
* an App token (currently `link_issue` → addClosingIssueReference, which is not
|
|
49
|
-
* available to fine-grained PATs). Omit if the owner has no App installed.
|
|
50
|
-
*/
|
|
51
|
-
installationId?: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** GitHub App config — for App-token-only operations (`link_issue`). Optional. */
|
|
55
|
-
export interface AppConfig {
|
|
56
|
-
appId: number;
|
|
57
|
-
/** Credential reference (e.g. "cred:gh-app-key") for the App's PEM private key. */
|
|
58
|
-
privateKey: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Where the actual token values live — chosen at install time. A provider seam:
|
|
63
|
-
* - "systemd-creds" (default): encrypted, host-bound `.cred` files via
|
|
64
|
-
* LoadCredentialEncrypted → $CREDENTIALS_DIRECTORY (encrypted at rest, not portable).
|
|
65
|
-
* - "file": a JSON `{ "<name>": "<token>" }` map at an agent-unreadable path
|
|
66
|
-
* (plaintext at rest, portable, platform-neutral).
|
|
67
|
-
* Further providers (OS keychain, DPAPI, encrypted vault) can be added here
|
|
68
|
-
* without touching call sites. Omit to use the systemd-creds provider.
|
|
69
|
-
*/
|
|
70
|
-
export interface SecretsConfig {
|
|
71
|
-
provider?: "systemd-creds" | "file";
|
|
72
|
-
/** For provider "file": path to the JSON secrets file. Defaults to <configDir>/secrets.json. */
|
|
73
|
-
path?: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface Config {
|
|
77
|
-
/** e.g. "unix:/run/gh-broker/gh-broker.sock" */
|
|
78
|
-
listen: string;
|
|
79
|
-
/** GitHub hosted MCP base URL, e.g. "https://api.githubcopilot.com/mcp/" */
|
|
80
|
-
upstream: string;
|
|
81
|
-
tools: ToolRule[];
|
|
82
|
-
owners: Record<string, OwnerCreds>;
|
|
83
|
-
/**
|
|
84
|
-
* Owner whose READ PAT handles calls that carry no owner in their args —
|
|
85
|
-
* the genuinely global/identity reads (global security-advisory DB, `get_me`,
|
|
86
|
-
* `list_notifications`). Must be a key of `owners`. Applies to `mode: "read"`
|
|
87
|
-
* only; write calls always require an explicit owner. Omit to reject every
|
|
88
|
-
* owner-less call (the stricter original behaviour).
|
|
89
|
-
*/
|
|
90
|
-
defaultOwner?: string;
|
|
91
|
-
/**
|
|
92
|
-
* Optional GitHub App, reserved for `link_issue` (an App-token-only mutation).
|
|
93
|
-
* `link_issue` is currently deferred — it is neither advertised nor dispatched —
|
|
94
|
-
* so providing this does not enable any verb today.
|
|
95
|
-
*/
|
|
96
|
-
app?: AppConfig;
|
|
97
|
-
/** Optional secret-store backend selection. Defaults to the systemd-creds provider. */
|
|
98
|
-
secrets?: SecretsConfig;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Resolves a "cred:<name>" reference to a token, never logging it. */
|
|
102
|
-
export interface SecretStore {
|
|
103
|
-
resolve(ref: string): string;
|
|
104
|
-
}
|
package/src/upstream.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/upstream.ts
|
|
5
|
-
* @Date: 2026-07-07T00:24:51-07:00 (1783409091)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
18
|
-
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
19
|
-
import type { Config } from "./types.ts";
|
|
20
|
-
import type { Upstream } from "./router.ts";
|
|
21
|
-
|
|
22
|
-
const CLIENT_INFO = { name: "cldmv-gh-broker", version: "0.1.0" };
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Pool of MCP client connections to the hosted GitHub MCP — one per bearer token,
|
|
26
|
-
* so each org's PAT drives its own upstream session. Connections are created lazily
|
|
27
|
-
* and reused; the PAT is only ever set as an `Authorization: Bearer` header and is
|
|
28
|
-
* never logged.
|
|
29
|
-
*/
|
|
30
|
-
export class UpstreamPool implements Upstream {
|
|
31
|
-
private readonly url: URL;
|
|
32
|
-
private readonly clients = new Map<string, Promise<Client>>();
|
|
33
|
-
|
|
34
|
-
constructor(config: Config) {
|
|
35
|
-
this.url = new URL(config.upstream);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
private connect(token: string): Promise<Client> {
|
|
39
|
-
const existing = this.clients.get(token);
|
|
40
|
-
if (existing) return existing;
|
|
41
|
-
const pending = (async (): Promise<Client> => {
|
|
42
|
-
const client = new Client(CLIENT_INFO);
|
|
43
|
-
const transport = new StreamableHTTPClientTransport(this.url, {
|
|
44
|
-
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
45
|
-
});
|
|
46
|
-
await client.connect(transport);
|
|
47
|
-
return client;
|
|
48
|
-
})();
|
|
49
|
-
// Don't cache a failed connect — let the next call retry.
|
|
50
|
-
pending.catch(() => this.clients.delete(token));
|
|
51
|
-
this.clients.set(token, pending);
|
|
52
|
-
return pending;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async forward(tool: string, args: Record<string, unknown>, token: string): Promise<CallToolResult> {
|
|
56
|
-
return this.withReconnect(token, (client) => client.callTool({ name: tool, arguments: args }) as Promise<CallToolResult>);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Fetch upstream tool schemas (owner-independent) to build the advertised catalog. */
|
|
60
|
-
async listToolsFor(token: string): Promise<Tool[]> {
|
|
61
|
-
return this.withReconnect(token, async (client) => (await client.listTools()).tools);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Run an upstream op with a single transparent reconnect. The hosted MCP's
|
|
66
|
-
* streamable-HTTP session can die server-side (idle-session expiry, upstream
|
|
67
|
-
* restart) even though our cached client still looks connected — the next call
|
|
68
|
-
* then fails with "invalid session". Rather than surface that first failure to
|
|
69
|
-
* the agent (and leave it stuck until the whole broker restarts), evict the dead
|
|
70
|
-
* client and retry ONCE with a fresh connection. A genuinely broken call (bad
|
|
71
|
-
* token, real upstream error) fails the retry too and propagates.
|
|
72
|
-
*/
|
|
73
|
-
private async withReconnect<T>(token: string, op: (client: Client) => Promise<T>): Promise<T> {
|
|
74
|
-
for (let attempt = 0; ; attempt++) {
|
|
75
|
-
const client = await this.connect(token);
|
|
76
|
-
try {
|
|
77
|
-
return await op(client);
|
|
78
|
-
} catch (err) {
|
|
79
|
-
// Evict the (possibly stale) session so this retry / the next call reconnects.
|
|
80
|
-
this.clients.delete(token);
|
|
81
|
-
if (attempt >= 1) throw err;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|