@cldmv/gh-broker 0.8.0 → 0.10.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 +48 -13
- package/config.example.json +86 -3
- package/config.sample.json +1 -1
- package/deploy/README.md +58 -18
- package/deploy/cli.mjs +38 -0
- package/deploy/gh-broker.service +9 -2
- package/deploy/install.mjs +100 -41
- package/deploy/lib/agent-cli.mjs +166 -0
- package/deploy/lib/agents.mjs +128 -0
- package/deploy/lib/cli.mjs +72 -0
- package/deploy/lib/config-merge.mjs +29 -8
- package/deploy/lib/install-args.mjs +3 -2
- package/deploy/lib/mcp-config.mjs +211 -0
- package/deploy/lib/otp-cli.mjs +136 -0
- package/deploy/lib/otp.mjs +325 -0
- package/deploy/lib/secret-store.mjs +1 -0
- package/deploy/lib/system.mjs +24 -12
- package/deploy/real-deps.mjs +102 -0
- package/dist/index.mjs +5171 -1686
- package/dist/index.mjs.map +4 -4
- package/package.json +14 -3
- package/docs/TOOLS.md +0 -538
- 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/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- 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 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- package/tsconfig.json +0 -21
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
-
import { createServer, type Server, type Socket } from "node:net";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { smokeTest } from "../deploy/lib/smoke.mjs";
|
|
7
|
-
|
|
8
|
-
// Real node:net, except connect() lets a test grab the client socket (to inject an error on it).
|
|
9
|
-
const hook = vi.hoisted(() => ({ onSock: null as null | ((s: any) => void) }));
|
|
10
|
-
vi.mock("node:net", async (importOriginal) => {
|
|
11
|
-
const actual = await importOriginal<typeof import("node:net")>();
|
|
12
|
-
return {
|
|
13
|
-
...actual,
|
|
14
|
-
connect: (...a: any[]) => {
|
|
15
|
-
const sock = (actual.connect as any)(...a);
|
|
16
|
-
hook.onSock?.(sock);
|
|
17
|
-
return sock;
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
const root = join(process.cwd(), "tmp", "tests", "smoke");
|
|
23
|
-
mkdirSync(root, { recursive: true });
|
|
24
|
-
let n = 0;
|
|
25
|
-
const servers: Server[] = [];
|
|
26
|
-
const conns: Socket[] = [];
|
|
27
|
-
|
|
28
|
-
type Handler = (msg: any, sock: Socket) => void;
|
|
29
|
-
|
|
30
|
-
/** A fake broker on a real unix socket; `onMsg` answers each newline-delimited JSON-RPC message. */
|
|
31
|
-
function fakeBroker(path: string, onMsg: Handler, onConn?: (s: Socket) => void): Promise<Server> {
|
|
32
|
-
return new Promise((resolve) => {
|
|
33
|
-
const srv = createServer((sock) => {
|
|
34
|
-
conns.push(sock);
|
|
35
|
-
if (onConn) return onConn(sock);
|
|
36
|
-
let buf = "";
|
|
37
|
-
sock.on("data", (d) => {
|
|
38
|
-
buf += d.toString("utf8");
|
|
39
|
-
let nl;
|
|
40
|
-
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
41
|
-
const line = buf.slice(0, nl);
|
|
42
|
-
buf = buf.slice(nl + 1);
|
|
43
|
-
onMsg(JSON.parse(line), sock);
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
sock.on("error", () => {});
|
|
47
|
-
});
|
|
48
|
-
servers.push(srv);
|
|
49
|
-
srv.listen(path, () => resolve(srv));
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const reply = (sock: Socket, id: number, result: unknown) => sock.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
|
54
|
-
const replyErr = (sock: Socket, id: number, error: unknown) => sock.write(JSON.stringify({ jsonrpc: "2.0", id, error }) + "\n");
|
|
55
|
-
const path = () => {
|
|
56
|
-
const p = join(root, `s${++n}.sock`);
|
|
57
|
-
rmSync(p, { force: true }); // a socket left behind by an earlier run
|
|
58
|
-
return p;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
afterEach(async () => {
|
|
62
|
-
for (const c of conns.splice(0)) c.destroy();
|
|
63
|
-
await Promise.all(servers.splice(0).map((s) => new Promise((r) => s.close(() => r(null)))));
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("smokeTest", () => {
|
|
67
|
-
it("handshakes, lists tools and round-trips get_me", async () => {
|
|
68
|
-
const p = path();
|
|
69
|
-
await fakeBroker(p, (m, s) => {
|
|
70
|
-
if (m.method === "initialize") reply(s, m.id, {});
|
|
71
|
-
else if (m.method === "tools/list") reply(s, m.id, { tools: [{ name: "get_me" }, { name: "other" }] });
|
|
72
|
-
else if (m.method === "tools/call") reply(s, m.id, { content: [] });
|
|
73
|
-
});
|
|
74
|
-
expect(await smokeTest(p)).toEqual({ tools: ["get_me", "other"], roundTrip: "get_me ok" });
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("reports an isError get_me, and a failing get_me", async () => {
|
|
78
|
-
const p1 = path();
|
|
79
|
-
await fakeBroker(p1, (m, s) => {
|
|
80
|
-
if (m.method === "tools/list") reply(s, m.id, { tools: [{ name: "get_me" }] });
|
|
81
|
-
else if (m.method === "tools/call") reply(s, m.id, { isError: true });
|
|
82
|
-
else if (m.id) reply(s, m.id, {});
|
|
83
|
-
});
|
|
84
|
-
expect((await smokeTest(p1)).roundTrip).toBe("get_me returned an error");
|
|
85
|
-
|
|
86
|
-
const p2 = path();
|
|
87
|
-
await fakeBroker(p2, (m, s) => {
|
|
88
|
-
if (m.method === "tools/list") reply(s, m.id, { tools: [{ name: "get_me" }] });
|
|
89
|
-
else if (m.method === "tools/call") replyErr(s, m.id, { message: "denied" });
|
|
90
|
-
else if (m.id) reply(s, m.id, {});
|
|
91
|
-
});
|
|
92
|
-
expect((await smokeTest(p2)).roundTrip).toBe("get_me failed: denied");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("skips the round trip when get_me is not advertised, tolerating an empty list result", async () => {
|
|
96
|
-
const p = path();
|
|
97
|
-
await fakeBroker(p, (m, s) => {
|
|
98
|
-
if (m.method === "tools/list") reply(s, m.id, {});
|
|
99
|
-
else if (m.id) reply(s, m.id, {});
|
|
100
|
-
});
|
|
101
|
-
expect(await smokeTest(p)).toEqual({ tools: [], roundTrip: null });
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("ignores blank lines, garbage and unknown ids, and reassembles split lines", async () => {
|
|
105
|
-
const p = path();
|
|
106
|
-
await fakeBroker(p, (m, s) => {
|
|
107
|
-
if (m.method === "initialize") {
|
|
108
|
-
s.write("\n \nnot-json\n");
|
|
109
|
-
s.write(JSON.stringify({ jsonrpc: "2.0", id: 999, result: {} }) + "\n");
|
|
110
|
-
const full = JSON.stringify({ jsonrpc: "2.0", id: m.id, result: {} }) + "\n";
|
|
111
|
-
s.write(full.slice(0, 5));
|
|
112
|
-
setTimeout(() => s.write(full.slice(5)), 20);
|
|
113
|
-
} else if (m.method === "tools/list") reply(s, m.id, { tools: [] });
|
|
114
|
-
});
|
|
115
|
-
expect((await smokeTest(p)).tools).toEqual([]);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("rejects with the rpc error message, falling back to a generic one", async () => {
|
|
119
|
-
const p1 = path();
|
|
120
|
-
await fakeBroker(p1, (m, s) => replyErr(s, m.id, { message: "bad init" }));
|
|
121
|
-
await expect(smokeTest(p1)).rejects.toThrow("bad init");
|
|
122
|
-
|
|
123
|
-
const p2 = path();
|
|
124
|
-
await fakeBroker(p2, (m, s) => replyErr(s, m.id, {}));
|
|
125
|
-
await expect(smokeTest(p2)).rejects.toThrow("rpc error");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("retries until the socket appears", async () => {
|
|
129
|
-
const p = path();
|
|
130
|
-
const pending = smokeTest(p, { timeoutMs: 5000 });
|
|
131
|
-
await new Promise((r) => setTimeout(r, 400)); // first attempts hit ENOENT
|
|
132
|
-
await fakeBroker(p, (m, s) => {
|
|
133
|
-
if (m.method === "tools/list") reply(s, m.id, { tools: [] });
|
|
134
|
-
else if (m.id) reply(s, m.id, {});
|
|
135
|
-
});
|
|
136
|
-
expect((await pending).tools).toEqual([]);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("retries on ECONNREFUSED (stale socket file with no listener) until the deadline", async () => {
|
|
140
|
-
const p = path();
|
|
141
|
-
// a child binds the socket and is SIGKILLed, leaving the socket file behind with nobody listening
|
|
142
|
-
const child = spawn(process.execPath, ["-e", `require("net").createServer().listen(${JSON.stringify(p)}, () => console.log("up"))`], {
|
|
143
|
-
stdio: ["ignore", "pipe", "inherit"]
|
|
144
|
-
});
|
|
145
|
-
await new Promise((r) => child.stdout.once("data", r));
|
|
146
|
-
child.kill("SIGKILL");
|
|
147
|
-
await new Promise((r) => child.once("exit", r));
|
|
148
|
-
await expect(smokeTest(p, { timeoutMs: 600 })).rejects.toThrow(/smoke: socket error .*ECONNREFUSED/);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("gives up once the deadline passes", async () => {
|
|
152
|
-
await expect(smokeTest(path(), { timeoutMs: 300 })).rejects.toThrow(/smoke: socket error .*ENOENT/);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("fails immediately on a non-retryable connect error", async () => {
|
|
156
|
-
const file = join(root, "plainfile");
|
|
157
|
-
writeFileSync(file, "x");
|
|
158
|
-
await expect(smokeTest(join(file, "child.sock"), { timeoutMs: 5000 })).rejects.toThrow(/smoke: socket error/);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("rejects when the broker never answers", async () => {
|
|
162
|
-
const p = path();
|
|
163
|
-
await fakeBroker(p, () => {});
|
|
164
|
-
await expect(smokeTest(p, { timeoutMs: 100 })).rejects.toThrow("smoke: no response before deadline");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("rejects when the socket errors mid-handshake", async () => {
|
|
168
|
-
const p = path();
|
|
169
|
-
await fakeBroker(p, () => {});
|
|
170
|
-
hook.onSock = (sock) => sock.once("connect", () => setTimeout(() => sock.emit("error", new Error("boom")), 30));
|
|
171
|
-
try {
|
|
172
|
-
await expect(smokeTest(p)).rejects.toThrow("smoke: socket error: boom");
|
|
173
|
-
} finally {
|
|
174
|
-
hook.onSock = null;
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,355 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
// Every host-touching primitive is mocked: nothing here spawns a process or touches the real filesystem.
|
|
4
|
-
const h = vi.hoisted(() => ({ spawnSync: vi.fn() }));
|
|
5
|
-
vi.mock("node:child_process", () => ({ spawnSync: h.spawnSync }));
|
|
6
|
-
vi.mock("node:fs", () => ({
|
|
7
|
-
existsSync: vi.fn(),
|
|
8
|
-
mkdirSync: vi.fn(),
|
|
9
|
-
chmodSync: vi.fn(),
|
|
10
|
-
chownSync: vi.fn(),
|
|
11
|
-
lstatSync: vi.fn(),
|
|
12
|
-
writeFileSync: vi.fn(),
|
|
13
|
-
cpSync: vi.fn(),
|
|
14
|
-
rmSync: vi.fn(),
|
|
15
|
-
symlinkSync: vi.fn(),
|
|
16
|
-
realpathSync: vi.fn()
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
import * as fs from "node:fs";
|
|
20
|
-
import * as sys from "../deploy/lib/system.mjs";
|
|
21
|
-
|
|
22
|
-
const f = vi.mocked(fs) as any;
|
|
23
|
-
const ok = (stdout = "", stderr = "") => ({ status: 0, stdout, stderr });
|
|
24
|
-
const fail = (status = 1, stdout = "", stderr = "") => ({ status, stdout, stderr });
|
|
25
|
-
|
|
26
|
-
/** Route spawnSync by `cmd args...` string prefix; unmatched commands succeed with empty output. */
|
|
27
|
-
function route(map: Record<string, any>) {
|
|
28
|
-
h.spawnSync.mockImplementation((cmd: string, args: string[] = []) => {
|
|
29
|
-
const line = [cmd, ...args].join(" ");
|
|
30
|
-
for (const [k, v] of Object.entries(map)) if (line.startsWith(k)) return typeof v === "function" ? v(line) : v;
|
|
31
|
-
return ok();
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
const calls = () => h.spawnSync.mock.calls.map((c: any[]) => [c[0], ...c[1]].join(" "));
|
|
35
|
-
|
|
36
|
-
beforeEach(() => {
|
|
37
|
-
h.spawnSync.mockReset();
|
|
38
|
-
for (const k of Object.keys(f)) if (typeof f[k]?.mockReset === "function") f[k].mockReset();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("run / has / isRoot", () => {
|
|
42
|
-
it("returns the result on success and passes options through", () => {
|
|
43
|
-
h.spawnSync.mockReturnValue(ok("x"));
|
|
44
|
-
expect(sys.run("echo", ["a"], { input: "i" }).stdout).toBe("x");
|
|
45
|
-
expect(h.spawnSync).toHaveBeenCalledWith("echo", ["a"], { encoding: "utf8", input: "i" });
|
|
46
|
-
sys.run("echo"); // default args
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("throws when the process could not be spawned", () => {
|
|
50
|
-
h.spawnSync.mockReturnValue({ error: new Error("ENOENT") });
|
|
51
|
-
expect(() => sys.run("nope")).toThrow("nope: ENOENT");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("throws on a non-zero exit with stderr, then stdout, then nothing", () => {
|
|
55
|
-
h.spawnSync.mockReturnValue(fail(2, "out", "err"));
|
|
56
|
-
expect(() => sys.run("c", ["a", "b"])).toThrow("c a b exited 2: err");
|
|
57
|
-
h.spawnSync.mockReturnValue(fail(2, "out", ""));
|
|
58
|
-
expect(() => sys.run("c")).toThrow("exited 2: out");
|
|
59
|
-
h.spawnSync.mockReturnValue(fail(2, "", ""));
|
|
60
|
-
expect(() => sys.run("c")).toThrow(/exited 2: $/);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("allowFail returns the failing result instead of throwing", () => {
|
|
64
|
-
h.spawnSync.mockReturnValue(fail(3));
|
|
65
|
-
expect(sys.run("c", [], { allowFail: true }).status).toBe(3);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("has() reflects `command -v`", () => {
|
|
69
|
-
h.spawnSync.mockReturnValue({ status: 0 });
|
|
70
|
-
expect(sys.has("rsync")).toBe(true);
|
|
71
|
-
h.spawnSync.mockReturnValue({ status: 1 });
|
|
72
|
-
expect(sys.has("rsync")).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("isRoot() follows getuid", () => {
|
|
76
|
-
const orig = process.getuid;
|
|
77
|
-
try {
|
|
78
|
-
process.getuid = () => 0;
|
|
79
|
-
expect(sys.isRoot()).toBe(true);
|
|
80
|
-
process.getuid = () => 1000;
|
|
81
|
-
expect(sys.isRoot()).toBe(false);
|
|
82
|
-
(process as any).getuid = undefined;
|
|
83
|
-
expect(sys.isRoot()).toBe(false);
|
|
84
|
-
} finally {
|
|
85
|
-
process.getuid = orig;
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("user / group lookups", () => {
|
|
91
|
-
it("userExists / groupExists use the exit status", () => {
|
|
92
|
-
route({ id: ok(), getent: fail(2) });
|
|
93
|
-
expect(sys.userExists("u")).toBe(true);
|
|
94
|
-
expect(sys.groupExists("g")).toBe(false);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("gidOf parses getent output, and is undefined on failure or junk", () => {
|
|
98
|
-
route({ getent: ok("grp:x:994:\n") });
|
|
99
|
-
expect(sys.gidOf("grp")).toBe(994);
|
|
100
|
-
route({ getent: fail(2) });
|
|
101
|
-
expect(sys.gidOf("grp")).toBeUndefined();
|
|
102
|
-
route({ getent: ok("grp:x:abc:\n") });
|
|
103
|
-
expect(sys.gidOf("grp")).toBeUndefined();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("uidOf parses `id -u`, and is undefined on failure or junk", () => {
|
|
107
|
-
route({ id: ok("1001\n") });
|
|
108
|
-
expect(sys.uidOf("u")).toBe(1001);
|
|
109
|
-
route({ id: fail(1) });
|
|
110
|
-
expect(sys.uidOf("u")).toBeUndefined();
|
|
111
|
-
route({ id: ok("junk\n") });
|
|
112
|
-
expect(sys.uidOf("u")).toBeUndefined();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("requireGid / requireUid throw a clear error when unresolved", () => {
|
|
116
|
-
route({ getent: ok("g:x:5:\n"), id: ok("7\n") });
|
|
117
|
-
expect(sys.requireGid("g")).toBe(5);
|
|
118
|
-
expect(sys.requireUid("u")).toBe(7);
|
|
119
|
-
route({ getent: fail(2), id: fail(1) });
|
|
120
|
-
expect(() => sys.requireGid("g")).toThrow('cannot resolve gid for group "g"');
|
|
121
|
-
expect(() => sys.requireUid("u")).toThrow('cannot resolve uid for user "u"');
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe("account creation", () => {
|
|
126
|
-
it("ensureGroup only creates a missing group", () => {
|
|
127
|
-
route({ getent: ok() });
|
|
128
|
-
sys.ensureGroup("g");
|
|
129
|
-
expect(calls()).not.toContain("groupadd --system g");
|
|
130
|
-
h.spawnSync.mockReset();
|
|
131
|
-
route({ getent: fail(2) });
|
|
132
|
-
sys.ensureGroup("g");
|
|
133
|
-
expect(calls()).toContain("groupadd --system g");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("addUserToGroup appends the group", () => {
|
|
137
|
-
route({});
|
|
138
|
-
sys.addUserToGroup("u", "g");
|
|
139
|
-
expect(calls()).toContain("usermod --append --groups g u");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("ensureServiceUser creates a missing user", () => {
|
|
143
|
-
route({ getent: ok(), id: fail(1) });
|
|
144
|
-
sys.ensureServiceUser("svc", "svcg", "clients");
|
|
145
|
-
const useradd = calls().find((c) => c.startsWith("useradd"))!;
|
|
146
|
-
expect(useradd).toContain("--system --gid svcg --groups clients");
|
|
147
|
-
expect(useradd.endsWith(" svc")).toBe(true);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("ensureServiceUser only adds an existing user to the clients group", () => {
|
|
151
|
-
route({ getent: ok(), id: ok() });
|
|
152
|
-
sys.ensureServiceUser("svc", "svcg", "clients");
|
|
153
|
-
expect(calls().some((c) => c.startsWith("useradd"))).toBe(false);
|
|
154
|
-
expect(calls()).toContain("usermod --append --groups clients svc");
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("createAgentUser creates a plain user", () => {
|
|
158
|
-
route({});
|
|
159
|
-
sys.createAgentUser("agent");
|
|
160
|
-
expect(calls()).toContain("useradd --create-home --shell /bin/bash agent");
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe("systemd-creds", () => {
|
|
165
|
-
it("trySystemdCredDecrypt returns null when the file or the tool is missing", () => {
|
|
166
|
-
f.existsSync.mockReturnValue(false);
|
|
167
|
-
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
168
|
-
f.existsSync.mockReturnValue(true);
|
|
169
|
-
route({ sh: fail(1) });
|
|
170
|
-
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("trySystemdCredDecrypt returns the token without its trailing newline", () => {
|
|
174
|
-
f.existsSync.mockReturnValue(true);
|
|
175
|
-
route({ sh: ok(), "systemd-creds": ok("tok\r\n") });
|
|
176
|
-
expect(sys.trySystemdCredDecrypt("n", "/c")).toBe("tok");
|
|
177
|
-
expect(calls()).toContain("systemd-creds decrypt --name=n /c -");
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("trySystemdCredDecrypt returns null for a failed decrypt, empty output or a spawn error", () => {
|
|
181
|
-
f.existsSync.mockReturnValue(true);
|
|
182
|
-
route({ sh: ok(), "systemd-creds": fail(1) });
|
|
183
|
-
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
184
|
-
route({ sh: ok(), "systemd-creds": ok("\n") });
|
|
185
|
-
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
186
|
-
route({ sh: ok(), "systemd-creds": () => ({ error: new Error("spawn failed") }) });
|
|
187
|
-
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("systemdCredsEncrypt feeds the plaintext on stdin and locks the file down", () => {
|
|
191
|
-
route({});
|
|
192
|
-
sys.systemdCredsEncrypt("n", "secret", "/etc/x/n.cred");
|
|
193
|
-
expect(h.spawnSync).toHaveBeenCalledWith(
|
|
194
|
-
"systemd-creds",
|
|
195
|
-
["encrypt", "--name=n", "-", "/etc/x/n.cred"],
|
|
196
|
-
expect.objectContaining({ input: "secret" })
|
|
197
|
-
);
|
|
198
|
-
expect(f.chmodSync).toHaveBeenCalledWith("/etc/x/n.cred", 0o400);
|
|
199
|
-
expect(f.chownSync).toHaveBeenCalledWith("/etc/x/n.cred", 0, 0);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("ensureCredsDir creates <configDir>/creds as 0750 root:group", () => {
|
|
203
|
-
route({ getent: ok("g:x:42:\n") });
|
|
204
|
-
f.existsSync.mockReturnValue(false);
|
|
205
|
-
sys.ensureCredsDir("/etc/gh-broker", "g");
|
|
206
|
-
expect(f.mkdirSync).toHaveBeenCalledWith("/etc/gh-broker/creds", { recursive: true, mode: 0o750 });
|
|
207
|
-
expect(f.chmodSync).toHaveBeenCalledWith("/etc/gh-broker/creds", 0o750);
|
|
208
|
-
expect(f.chownSync).toHaveBeenCalledWith("/etc/gh-broker/creds", 0, 42);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("ensureCredsDir leaves an existing directory in place but re-applies mode and owner", () => {
|
|
212
|
-
route({ getent: ok("g:x:42:\n") });
|
|
213
|
-
f.existsSync.mockReturnValue(true);
|
|
214
|
-
sys.ensureCredsDir("/etc/gh-broker", "g");
|
|
215
|
-
expect(f.mkdirSync).not.toHaveBeenCalled();
|
|
216
|
-
expect(f.chownSync).toHaveBeenCalled();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("writeCredsDropin writes one LoadCredentialEncrypted line per name and reloads", () => {
|
|
220
|
-
route({});
|
|
221
|
-
f.existsSync.mockReturnValue(true);
|
|
222
|
-
sys.writeCredsDropin(["a", "b"], "/etc/gh-broker/creds");
|
|
223
|
-
const [path, body, opts] = f.writeFileSync.mock.calls[0];
|
|
224
|
-
expect(path).toBe("/etc/systemd/system/gh-broker.service.d/creds.conf");
|
|
225
|
-
expect(body).toBe(
|
|
226
|
-
"[Service]\nLoadCredentialEncrypted=a:/etc/gh-broker/creds/a.cred\nLoadCredentialEncrypted=b:/etc/gh-broker/creds/b.cred\n"
|
|
227
|
-
);
|
|
228
|
-
expect(opts).toEqual({ mode: 0o644 });
|
|
229
|
-
expect(calls()).toContain("systemctl daemon-reload");
|
|
230
|
-
sys.writeCredsDropin([], "/c", "other.service");
|
|
231
|
-
expect(f.writeFileSync.mock.calls[1][0]).toBe("/etc/systemd/system/other.service.d/creds.conf");
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("removeCredsDropin removes the drop-in and reloads only when it exists", () => {
|
|
235
|
-
route({});
|
|
236
|
-
f.existsSync.mockReturnValue(false);
|
|
237
|
-
sys.removeCredsDropin();
|
|
238
|
-
expect(f.rmSync).not.toHaveBeenCalled();
|
|
239
|
-
expect(calls()).toEqual([]);
|
|
240
|
-
f.existsSync.mockReturnValue(true);
|
|
241
|
-
sys.removeCredsDropin("x.service");
|
|
242
|
-
expect(f.rmSync).toHaveBeenCalledWith("/etc/systemd/system/x.service.d/creds.conf", { force: true });
|
|
243
|
-
expect(calls()).toContain("systemctl daemon-reload");
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
describe("node symlinks", () => {
|
|
248
|
-
it("links only the binaries that exist next to the given node", () => {
|
|
249
|
-
f.existsSync.mockImplementation((p: string) => p !== "/opt/node/bin/npx");
|
|
250
|
-
expect(sys.pointNodeSymlinks("/opt/node/bin/node")).toEqual(["node", "npm"]);
|
|
251
|
-
expect(f.rmSync).toHaveBeenCalledWith("/usr/local/bin/node", { force: true });
|
|
252
|
-
expect(f.symlinkSync).toHaveBeenCalledWith("/opt/node/bin/node", "/usr/local/bin/node");
|
|
253
|
-
expect(f.symlinkSync).not.toHaveBeenCalledWith("/opt/node/bin/npx", "/usr/local/bin/npx");
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it("currentNodeBin resolves the running node's real path", () => {
|
|
257
|
-
f.realpathSync.mockReturnValue("/real/node");
|
|
258
|
-
expect(sys.currentNodeBin()).toBe("/real/node");
|
|
259
|
-
expect(f.realpathSync).toHaveBeenCalledWith(process.execPath);
|
|
260
|
-
});
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
describe("ensureDropDir", () => {
|
|
264
|
-
const st = (kind: "dir" | "link" | "file") => ({
|
|
265
|
-
isSymbolicLink: () => kind === "link",
|
|
266
|
-
isDirectory: () => kind === "dir"
|
|
267
|
-
});
|
|
268
|
-
beforeEach(() => route({ getent: ok("g:x:42:\n"), id: ok("1001\n") }));
|
|
269
|
-
|
|
270
|
-
it("creates a missing parent (0755) and a missing dir (0750), then sets owner and 2750", () => {
|
|
271
|
-
f.existsSync.mockReturnValue(false);
|
|
272
|
-
f.lstatSync.mockImplementation(() => {
|
|
273
|
-
throw new Error("ENOENT");
|
|
274
|
-
});
|
|
275
|
-
sys.ensureDropDir("/var/lib/drop/in", "agent", "g");
|
|
276
|
-
expect(f.mkdirSync).toHaveBeenCalledWith("/var/lib/drop", { recursive: true, mode: 0o755 });
|
|
277
|
-
expect(f.mkdirSync).toHaveBeenCalledWith("/var/lib/drop/in", { mode: 0o750 });
|
|
278
|
-
expect(f.chownSync).toHaveBeenCalledWith("/var/lib/drop/in", 1001, 42);
|
|
279
|
-
expect(f.chmodSync).toHaveBeenCalledWith("/var/lib/drop/in", 0o2750);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it("reuses an existing parent and directory", () => {
|
|
283
|
-
f.existsSync.mockReturnValue(true);
|
|
284
|
-
f.lstatSync.mockReturnValue(st("dir"));
|
|
285
|
-
sys.ensureDropDir("/var/lib/drop/in", "agent", "g");
|
|
286
|
-
expect(f.mkdirSync).not.toHaveBeenCalled();
|
|
287
|
-
expect(f.chmodSync).toHaveBeenCalledWith("/var/lib/drop/in", 0o2750);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("refuses a symlink and a non-directory", () => {
|
|
291
|
-
f.existsSync.mockReturnValue(true);
|
|
292
|
-
f.lstatSync.mockReturnValue(st("link"));
|
|
293
|
-
expect(() => sys.ensureDropDir("/d/x", "agent", "g")).toThrow("is a symlink");
|
|
294
|
-
f.lstatSync.mockReturnValue(st("file"));
|
|
295
|
-
expect(() => sys.ensureDropDir("/d/x", "agent", "g")).toThrow("is not a directory");
|
|
296
|
-
expect(f.chownSync).not.toHaveBeenCalled();
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
describe("directories, unit and service", () => {
|
|
301
|
-
beforeEach(() => route({ getent: ok("g:x:42:\n"), id: ok("1001\n") }));
|
|
302
|
-
|
|
303
|
-
it("ensureDirs creates config (0750 root:group) and app (0755 svc:group) dirs", () => {
|
|
304
|
-
f.existsSync.mockReturnValue(false);
|
|
305
|
-
sys.ensureDirs("/etc/gh-broker", "/opt/gh-broker", "svc", "g");
|
|
306
|
-
expect(f.chownSync).toHaveBeenCalledWith("/etc/gh-broker", 0, 42);
|
|
307
|
-
expect(f.chownSync).toHaveBeenCalledWith("/opt/gh-broker", 1001, 42);
|
|
308
|
-
expect(f.chmodSync).toHaveBeenCalledWith("/opt/gh-broker", 0o755);
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
it("installUnit copies, chmods and reloads (default and explicit destination)", () => {
|
|
312
|
-
sys.installUnit("/src/u.service");
|
|
313
|
-
expect(f.cpSync).toHaveBeenCalledWith("/src/u.service", "/etc/systemd/system/gh-broker.service");
|
|
314
|
-
sys.installUnit("/src/u.service", "/x/y.service");
|
|
315
|
-
expect(f.chmodSync).toHaveBeenCalledWith("/x/y.service", 0o644);
|
|
316
|
-
expect(calls().filter((c) => c === "systemctl daemon-reload")).toHaveLength(2);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("enable / restart / serviceActive drive systemctl", () => {
|
|
320
|
-
route({ "systemctl enable": fail(1), "systemctl is-active": ok("active\n") });
|
|
321
|
-
sys.enableService(); // allowFail: a failing enable does not throw
|
|
322
|
-
sys.restartService();
|
|
323
|
-
expect(sys.serviceActive()).toBe("active");
|
|
324
|
-
expect(calls()).toContain("systemctl restart gh-broker.service");
|
|
325
|
-
route({ "systemctl restart": fail(1, "", "boom") });
|
|
326
|
-
expect(() => sys.restartService()).toThrow("boom");
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it("deployDist rsyncs when rsync exists", () => {
|
|
330
|
-
route({ getent: ok("g:x:42:\n"), id: ok("1001\n"), sh: ok() });
|
|
331
|
-
f.existsSync.mockReturnValue(true);
|
|
332
|
-
sys.deployDist("/src/dist", "/opt/gh-broker", "svc", "g");
|
|
333
|
-
expect(calls()).toContain("rsync -a --delete /src/dist/ /opt/gh-broker/dist/");
|
|
334
|
-
expect(calls()).toContain("chown -R 1001:42 /opt/gh-broker");
|
|
335
|
-
expect(f.cpSync).not.toHaveBeenCalled();
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it("deployDist falls back to rm + cp without rsync", () => {
|
|
339
|
-
route({ getent: ok("g:x:42:\n"), id: ok("1001\n"), sh: fail(1) });
|
|
340
|
-
f.existsSync.mockReturnValue(true);
|
|
341
|
-
sys.deployDist("/src/dist", "/opt/gh-broker", "svc", "g");
|
|
342
|
-
expect(f.rmSync).toHaveBeenCalledWith("/opt/gh-broker/dist", { recursive: true, force: true });
|
|
343
|
-
expect(f.cpSync).toHaveBeenCalledWith("/src/dist", "/opt/gh-broker/dist", { recursive: true });
|
|
344
|
-
expect(calls().some((c) => c.startsWith("rsync"))).toBe(false);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it("writeConfigFile writes tab-indented JSON as 0640 root:group", () => {
|
|
348
|
-
sys.writeConfigFile("/etc/gh-broker/config.json", { a: 1 }, "g");
|
|
349
|
-
expect(f.writeFileSync).toHaveBeenCalledWith("/etc/gh-broker/config.json", '{\n\t"a": 1\n}\n', { mode: 0o640 });
|
|
350
|
-
expect(f.chmodSync).toHaveBeenCalledWith("/etc/gh-broker/config.json", 0o640);
|
|
351
|
-
expect(f.chownSync).toHaveBeenCalledWith("/etc/gh-broker/config.json", 0, 42);
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
afterEach(() => vi.clearAllMocks());
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
const { connectMock, callToolMock, listToolsMock } = vi.hoisted(() => ({
|
|
4
|
-
connectMock: vi.fn(),
|
|
5
|
-
callToolMock: vi.fn(),
|
|
6
|
-
listToolsMock: vi.fn()
|
|
7
|
-
}));
|
|
8
|
-
|
|
9
|
-
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|
10
|
-
// Constructable mock (arrow fns can't be `new`-ed) — instances share the op mocks.
|
|
11
|
-
Client: vi.fn(function (this: any) {
|
|
12
|
-
this.connect = connectMock;
|
|
13
|
-
this.callTool = callToolMock;
|
|
14
|
-
this.listTools = listToolsMock;
|
|
15
|
-
})
|
|
16
|
-
}));
|
|
17
|
-
vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
18
|
-
StreamableHTTPClientTransport: vi.fn(function () {})
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
22
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
23
|
-
import { UpstreamPool } from "../src/upstream.ts";
|
|
24
|
-
|
|
25
|
-
const config: any = { listen: "unix:/x", upstream: "https://api.githubcopilot.com/mcp/", tools: [], owners: {} };
|
|
26
|
-
|
|
27
|
-
describe("UpstreamPool", () => {
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
connectMock.mockReset();
|
|
30
|
-
callToolMock.mockReset();
|
|
31
|
-
listToolsMock.mockReset();
|
|
32
|
-
vi.mocked(Client).mockClear();
|
|
33
|
-
vi.mocked(StreamableHTTPClientTransport).mockClear();
|
|
34
|
-
connectMock.mockResolvedValue(undefined);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("forward connects with a Bearer token and calls the tool", async () => {
|
|
38
|
-
callToolMock.mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
|
|
39
|
-
const pool = new UpstreamPool(config);
|
|
40
|
-
const out = await pool.forward("get_me", { a: 1 }, "TOK");
|
|
41
|
-
|
|
42
|
-
expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
|
|
43
|
-
expect(callToolMock).toHaveBeenCalledWith({ name: "get_me", arguments: { a: 1 } });
|
|
44
|
-
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(new URL(config.upstream), {
|
|
45
|
-
requestInit: { headers: { Authorization: "Bearer TOK" } }
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("listToolsFor returns the upstream tools array", async () => {
|
|
50
|
-
listToolsMock.mockResolvedValue({ tools: [{ name: "get_me" }, { name: "list_issues" }] });
|
|
51
|
-
const pool = new UpstreamPool(config);
|
|
52
|
-
expect(await pool.listToolsFor("TOK")).toEqual([{ name: "get_me" }, { name: "list_issues" }]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("reuses a pooled client for the same token", async () => {
|
|
56
|
-
callToolMock.mockResolvedValue({ content: [] });
|
|
57
|
-
const pool = new UpstreamPool(config);
|
|
58
|
-
await pool.forward("get_me", {}, "TOK");
|
|
59
|
-
await pool.forward("list_issues", {}, "TOK");
|
|
60
|
-
expect(vi.mocked(Client)).toHaveBeenCalledTimes(1); // connection created once, then reused
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("evicts the stale client and retries ONCE on a failed op", async () => {
|
|
64
|
-
callToolMock.mockRejectedValueOnce(new Error("invalid session")).mockResolvedValueOnce({ content: [{ type: "text", text: "recovered" }] });
|
|
65
|
-
const pool = new UpstreamPool(config);
|
|
66
|
-
const out = await pool.forward("get_me", {}, "TOK");
|
|
67
|
-
|
|
68
|
-
expect(out).toEqual({ content: [{ type: "text", text: "recovered" }] });
|
|
69
|
-
expect(callToolMock).toHaveBeenCalledTimes(2);
|
|
70
|
-
expect(vi.mocked(Client)).toHaveBeenCalledTimes(2); // reconnected with a fresh client
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("propagates after exactly one retry on a persistently-failing op", async () => {
|
|
74
|
-
callToolMock.mockRejectedValue(new Error("bad token"));
|
|
75
|
-
const pool = new UpstreamPool(config);
|
|
76
|
-
await expect(pool.forward("get_me", {}, "TOK")).rejects.toThrow(/bad token/);
|
|
77
|
-
expect(callToolMock).toHaveBeenCalledTimes(2); // initial + one retry, then throws
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("does not cache a failed connect", async () => {
|
|
81
|
-
connectMock.mockRejectedValueOnce(new Error("connect fail"));
|
|
82
|
-
callToolMock.mockResolvedValue({ content: [] });
|
|
83
|
-
const pool = new UpstreamPool(config);
|
|
84
|
-
|
|
85
|
-
await expect(pool.forward("get_me", {}, "TOK")).rejects.toThrow(/connect fail/);
|
|
86
|
-
// connectMock now resolves (default) → a fresh connect is attempted, proving the
|
|
87
|
-
// failed one was evicted rather than cached.
|
|
88
|
-
await expect(pool.forward("get_me", {}, "TOK")).resolves.toEqual({ content: [] });
|
|
89
|
-
expect(vi.mocked(Client)).toHaveBeenCalledTimes(2);
|
|
90
|
-
});
|
|
91
|
-
});
|