@app-vitals/agent-admin 1.12.1 → 1.14.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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * admin/src/github-app-api.ts
3
+ * Two small GitHub App reads that sit beside GithubEnterpriseClient:
4
+ *
5
+ * - fetchPublicApp(slug): GET /apps/{slug}, unauthenticated. Returns the App's
6
+ * client id. A private App is 404 here (no credentials are sent).
7
+ * - fetchAuthenticatedApp(): GET /app with an app JWT. Returns the App's
8
+ * declared permissions; used to validate the 'lookup' role.
9
+ *
10
+ * Errors are GithubEnterpriseError (message redacted of the key); the key
11
+ * never appears in a result or an error.
12
+ */
13
+
14
+ import {
15
+ GithubEnterpriseError,
16
+ mapGithubEnterpriseError,
17
+ } from "./github-enterprise-client.ts";
18
+ import { signGithubAppJwt } from "./github-app-jwt.ts";
19
+
20
+ interface FetchOpts {
21
+ apiBase?: string;
22
+ fetchFn?: typeof fetch;
23
+ }
24
+
25
+ export interface PublicApp {
26
+ slug: string;
27
+ clientId: string | null;
28
+ }
29
+
30
+ async function getJson(
31
+ url: string,
32
+ headers: Record<string, string>,
33
+ context: string,
34
+ fetchFn: typeof fetch,
35
+ secrets: readonly string[],
36
+ ): Promise<unknown> {
37
+ let resp: Response;
38
+ try {
39
+ resp = await fetchFn(url, {
40
+ method: "GET",
41
+ headers: {
42
+ Accept: "application/vnd.github+json",
43
+ "X-GitHub-Api-Version": "2022-11-28",
44
+ ...headers,
45
+ },
46
+ });
47
+ } catch {
48
+ throw new GithubEnterpriseError("network", `${context}: network error`);
49
+ }
50
+ let data: unknown;
51
+ try {
52
+ data = await resp.json();
53
+ } catch {
54
+ data = undefined;
55
+ }
56
+ if (!resp.ok) {
57
+ throw mapGithubEnterpriseError(
58
+ resp.status,
59
+ resp.headers,
60
+ data,
61
+ context,
62
+ secrets,
63
+ );
64
+ }
65
+ return data;
66
+ }
67
+
68
+ /** GET /apps/{slug} (public). `slug` must already be validated. */
69
+ export async function fetchPublicApp(
70
+ slug: string,
71
+ opts: FetchOpts = {},
72
+ ): Promise<PublicApp> {
73
+ const data = (await getJson(
74
+ `${opts.apiBase ?? "https://api.github.com"}/apps/${encodeURIComponent(slug)}`,
75
+ {},
76
+ "GitHub public App lookup",
77
+ opts.fetchFn ?? fetch,
78
+ [],
79
+ )) as { slug?: unknown; client_id?: unknown } | undefined;
80
+ return {
81
+ slug: typeof data?.slug === "string" ? data.slug : slug,
82
+ clientId:
83
+ typeof data?.client_id === "string" && data.client_id
84
+ ? data.client_id
85
+ : null,
86
+ };
87
+ }
88
+
89
+ /** GET /app with an app JWT: the App's own declared permissions. */
90
+ export async function fetchAuthenticatedApp(
91
+ opts: FetchOpts & { appId: string; privateKey: string; now?: () => Date },
92
+ ): Promise<{ permissions: Record<string, string> }> {
93
+ const jwt = signGithubAppJwt({
94
+ appId: opts.appId,
95
+ privateKey: opts.privateKey,
96
+ now: opts.now?.(),
97
+ });
98
+ const data = (await getJson(
99
+ `${opts.apiBase ?? "https://api.github.com"}/app`,
100
+ { Authorization: `Bearer ${jwt}` },
101
+ "GitHub App lookup",
102
+ opts.fetchFn ?? fetch,
103
+ [jwt, opts.privateKey],
104
+ )) as { permissions?: Record<string, string> } | undefined;
105
+ return {
106
+ permissions:
107
+ data?.permissions && typeof data.permissions === "object"
108
+ ? data.permissions
109
+ : {},
110
+ };
111
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * admin/src/github-app-jwt.ts
3
+ * RS256 signer for GitHub App JWTs (node:crypto only, no dependencies).
4
+ * https://docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
5
+ *
6
+ * Errors never include the private key material.
7
+ */
8
+
9
+ import { createPrivateKey, sign } from "node:crypto";
10
+
11
+ /** Backdate iat to absorb clock drift (GitHub's recommendation). */
12
+ const IAT_BACKDATE_SECONDS = 60;
13
+ /** exp - now. GitHub rejects a JWT whose exp is more than 10 minutes out. */
14
+ const EXP_SECONDS = 540;
15
+
16
+ function b64url(input: string | Buffer): string {
17
+ return Buffer.from(input).toString("base64url");
18
+ }
19
+
20
+ export function signGithubAppJwt(opts: {
21
+ appId: string;
22
+ privateKey: string;
23
+ now?: Date;
24
+ }): string {
25
+ if (!opts.appId) throw new Error("GitHub App JWT: appId is required");
26
+ const nowS = Math.floor((opts.now ?? new Date()).getTime() / 1000);
27
+ const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
28
+ const payload = b64url(
29
+ JSON.stringify({
30
+ iss: opts.appId,
31
+ iat: nowS - IAT_BACKDATE_SECONDS,
32
+ exp: nowS + EXP_SECONDS,
33
+ }),
34
+ );
35
+ const signingInput = `${header}.${payload}`;
36
+ try {
37
+ const key = createPrivateKey(opts.privateKey);
38
+ const sig = sign("RSA-SHA256", Buffer.from(signingInput), key);
39
+ return `${signingInput}.${b64url(sig)}`;
40
+ } catch {
41
+ // Deliberately drop the underlying error: it can carry key details.
42
+ throw new Error("GitHub App JWT: invalid or unusable RSA private key");
43
+ }
44
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * admin/src/github-app-jwt.unit.test.ts
3
+ * Unit tests for the RS256 GitHub App JWT signer. The RSA key is generated
4
+ * in-test; no network, no fixtures.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import { createPublicKey, generateKeyPairSync, verify } from "node:crypto";
9
+ import { signGithubAppJwt } from "./github-app-jwt.ts";
10
+
11
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", {
12
+ modulusLength: 2048,
13
+ privateKeyEncoding: { type: "pkcs1", format: "pem" },
14
+ publicKeyEncoding: { type: "spki", format: "pem" },
15
+ });
16
+ const pkcs8 = generateKeyPairSync("rsa", {
17
+ modulusLength: 2048,
18
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
19
+ publicKeyEncoding: { type: "spki", format: "pem" },
20
+ });
21
+
22
+ const NOW = new Date("2026-11-09T12:00:00Z");
23
+ const NOW_S = Math.floor(NOW.getTime() / 1000);
24
+
25
+ function decode(part: string): Record<string, unknown> {
26
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf-8"));
27
+ }
28
+
29
+ describe("signGithubAppJwt", () => {
30
+ const jwt = signGithubAppJwt({ appId: "12345", privateKey, now: NOW });
31
+ const [h, p, s] = jwt.split(".") as [string, string, string];
32
+
33
+ it("has three base64url segments with an RS256 header", () => {
34
+ expect(jwt.split(".")).toHaveLength(3);
35
+ expect(jwt).not.toMatch(/[+/=]/);
36
+ expect(decode(h)).toEqual({ alg: "RS256", typ: "JWT" });
37
+ });
38
+
39
+ it("sets iss to the app id, iat backdated 60s and exp within GitHub's 10 minute cap", () => {
40
+ const claims = decode(p) as { iss: string; iat: number; exp: number };
41
+ expect(claims.iss).toBe("12345");
42
+ expect(claims.iat).toBe(NOW_S - 60);
43
+ expect(claims.exp).toBe(NOW_S + 540);
44
+ expect(claims.exp - NOW_S).toBeLessThanOrEqual(600);
45
+ });
46
+
47
+ it("verifies against the matching public key", () => {
48
+ expect(
49
+ verify(
50
+ "RSA-SHA256",
51
+ Buffer.from(`${h}.${p}`),
52
+ createPublicKey(publicKey),
53
+ Buffer.from(s, "base64url"),
54
+ ),
55
+ ).toBe(true);
56
+ });
57
+
58
+ it("fails verification against a different key", () => {
59
+ expect(
60
+ verify(
61
+ "RSA-SHA256",
62
+ Buffer.from(`${h}.${p}`),
63
+ createPublicKey(pkcs8.publicKey),
64
+ Buffer.from(s, "base64url"),
65
+ ),
66
+ ).toBe(false);
67
+ });
68
+
69
+ it("accepts PKCS#8 keys", () => {
70
+ const j = signGithubAppJwt({
71
+ appId: "1",
72
+ privateKey: pkcs8.privateKey,
73
+ now: NOW,
74
+ });
75
+ expect(j.split(".")).toHaveLength(3);
76
+ });
77
+
78
+ it("throws a key-free error for an invalid key", () => {
79
+ const bad = "-----BEGIN RSA PRIVATE KEY-----\nSECRETGARBAGE\n-----END RSA PRIVATE KEY-----";
80
+ let msg = "";
81
+ try {
82
+ signGithubAppJwt({ appId: "1", privateKey: bad, now: NOW });
83
+ } catch (e) {
84
+ msg = (e as Error).message;
85
+ }
86
+ expect(msg).toContain("private key");
87
+ expect(msg).not.toContain("SECRETGARBAGE");
88
+ });
89
+
90
+ it("rejects an empty app id", () => {
91
+ expect(() =>
92
+ signGithubAppJwt({ appId: "", privateKey, now: NOW }),
93
+ ).toThrow();
94
+ });
95
+ });
@@ -0,0 +1,264 @@
1
+ /**
2
+ * admin/src/github-enterprise-api.integration.test.ts
3
+ * Integration tests for the /github-enterprise routes against a real
4
+ * PostgreSQL DB and the GHE-1.2 cassettes (replayed through an injected
5
+ * fetchFn): the validate flow end to end, plus the isolation guarantee that
6
+ * no agent-facing path can return a stored key.
7
+ *
8
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
12
+ import { generateKeyPairSync } from "node:crypto";
13
+ import { readFileSync } from "node:fs";
14
+ import type { PrismaClient } from "../prisma/client/client.ts";
15
+ import { AgentEnvService } from "./agent-envs.ts";
16
+ import { AgentPluginService } from "./agent-plugins.ts";
17
+ import { AgentService } from "./agents.ts";
18
+ import { createAgentRuntimeApp } from "./api.ts";
19
+ import { parseAdminApiKeys } from "./api-auth.ts";
20
+ import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
21
+ import { GitHubEnterpriseAppService } from "./github-enterprise-apps.ts";
22
+ import { createAdminPrismaClient } from "./prisma-client.ts";
23
+ import { identityCrypto, makeStrictTokenCrypto } from "./token-crypto.ts";
24
+
25
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
26
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
27
+
28
+ const cassette: Record<
29
+ string,
30
+ { status: number; headers?: Record<string, string>; body: unknown }
31
+ > = JSON.parse(
32
+ readFileSync(
33
+ new URL("./fixtures/github-enterprise-cassette.json", import.meta.url)
34
+ .pathname,
35
+ "utf-8",
36
+ ),
37
+ );
38
+
39
+ const { privateKey: PEM } = generateKeyPairSync("rsa", {
40
+ modulusLength: 2048,
41
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
42
+ publicKeyEncoding: { type: "spki", format: "pem" },
43
+ });
44
+ const PEM_BODY = PEM.split("\n")[1] as string;
45
+ const ENC_KEY = "c".repeat(64);
46
+ const ADMIN = { Authorization: "Bearer tok-admin" };
47
+
48
+ function replay(routes: Record<string, string>): typeof fetch {
49
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
50
+ const u = new URL(input.toString());
51
+ const key = routes[`${init?.method ?? "GET"} ${u.pathname}`];
52
+ if (!key) throw new Error(`no route for ${init?.method} ${u.pathname}`);
53
+ const e = cassette[key];
54
+ return new Response(JSON.stringify(e?.body), {
55
+ status: e?.status,
56
+ headers: { "Content-Type": "application/json" },
57
+ });
58
+ }) as typeof fetch;
59
+ }
60
+
61
+ describeOrSkip("github-enterprise API (integration)", () => {
62
+ let prisma: PrismaClient;
63
+ let appService: GitHubEnterpriseAppService;
64
+
65
+ const build = (routes: Record<string, string> = {}) =>
66
+ createGithubEnterpriseApp({
67
+ appService,
68
+ agentService: new AgentService(prisma),
69
+ agentEnvService: new AgentEnvService(prisma, identityCrypto),
70
+ sessionSecret: "test-secret-32chars-for-ghe-api!!",
71
+ adminApiKeys: parseAdminApiKeys("root:tok-admin:*"),
72
+ agentTokenService: { validate: async () => null },
73
+ fetchFn: replay({
74
+ "GET /enterprises/acme/installation": "enterprise_installation_success",
75
+ "POST /app/installations/4242/access_tokens": "access_token_success",
76
+ ...routes,
77
+ }),
78
+ apiBase: "https://ghe.test",
79
+ now: () => new Date("2026-11-09T12:00:00Z"),
80
+ });
81
+
82
+ const put = (app: ReturnType<typeof build>) =>
83
+ app.request("/github-enterprise/apps/installer", {
84
+ method: "PUT",
85
+ headers: { ...ADMIN, "Content-Type": "application/json" },
86
+ body: JSON.stringify({
87
+ appId: "12345",
88
+ slug: "example-installer",
89
+ enterpriseSlug: "acme",
90
+ privateKey: PEM,
91
+ }),
92
+ });
93
+
94
+ beforeEach(async () => {
95
+ prisma = createAdminPrismaClient(TEST_DB as string);
96
+ await prisma.gitHubEnterpriseApp.deleteMany();
97
+ await prisma.agentEnv.deleteMany();
98
+ await prisma.agentToken.deleteMany();
99
+ await prisma.agentCronJob.deleteMany();
100
+ await prisma.agentTool.deleteMany();
101
+ await prisma.agent.deleteMany();
102
+ appService = new GitHubEnterpriseAppService(
103
+ prisma,
104
+ makeStrictTokenCrypto(ENC_KEY),
105
+ );
106
+ });
107
+
108
+ afterEach(async () => {
109
+ await prisma.$disconnect();
110
+ });
111
+
112
+ it("unconfigured: status is empty and nothing is written", async () => {
113
+ const app = build();
114
+ const res = await app.request("/github-enterprise/apps", {
115
+ headers: ADMIN,
116
+ });
117
+ expect(await res.json()).toEqual({
118
+ configured: false,
119
+ installer: null,
120
+ lookup: null,
121
+ });
122
+ expect(await prisma.gitHubEnterpriseApp.count()).toBe(0);
123
+ });
124
+
125
+ it("PUT then validate persists the result; the key is encrypted and never returned", async () => {
126
+ const app = build();
127
+ const saved = await put(app);
128
+ expect(saved.status).toBe(200);
129
+ expect(await saved.text()).not.toContain(PEM_BODY);
130
+
131
+ const res = await app.request(
132
+ "/github-enterprise/apps/installer/validate",
133
+ {
134
+ method: "POST",
135
+ headers: ADMIN,
136
+ },
137
+ );
138
+ expect(res.status).toBe(200);
139
+ const text = await res.text();
140
+ expect(text).not.toContain(PEM_BODY);
141
+ expect(text).not.toContain("ghs_cassette");
142
+ expect(JSON.parse(text)).toMatchObject({ ok: true, status: "valid" });
143
+
144
+ const row = await prisma.gitHubEnterpriseApp.findUnique({
145
+ where: { role: "installer" },
146
+ });
147
+ expect(row?.lastValidationStatus).toBe("valid");
148
+ expect(row?.lastValidatedAt).toBeInstanceOf(Date);
149
+ expect(row?.privateKeyEncrypted).not.toContain(PEM_BODY);
150
+
151
+ const status = await app.request("/github-enterprise/apps", {
152
+ headers: ADMIN,
153
+ });
154
+ const st = await status.text();
155
+ expect(st).not.toContain(PEM_BODY);
156
+ expect(st).not.toContain(row?.privateKeyEncrypted as string);
157
+ expect(JSON.parse(st).installer.lastValidationStatus).toBe("valid");
158
+ });
159
+
160
+ it("validate against a cassette 403 records an error status", async () => {
161
+ const app = build({
162
+ "GET /enterprises/acme/installation": "enterprise_installation_403",
163
+ });
164
+ await put(app);
165
+ const res = await app.request(
166
+ "/github-enterprise/apps/installer/validate",
167
+ {
168
+ method: "POST",
169
+ headers: ADMIN,
170
+ },
171
+ );
172
+ expect(await res.json()).toMatchObject({ ok: false, status: "error" });
173
+ const row = await prisma.gitHubEnterpriseApp.findUnique({
174
+ where: { role: "installer" },
175
+ });
176
+ expect(row?.lastValidationStatus).toBe("error");
177
+ expect(row?.lastValidationError).not.toContain("Resource not accessible");
178
+ });
179
+
180
+ it("PUT without SHIPWRIGHT_ENCRYPTION_KEY is refused with 422 and stores nothing", async () => {
181
+ appService = new GitHubEnterpriseAppService(
182
+ prisma,
183
+ makeStrictTokenCrypto(undefined),
184
+ );
185
+ const res = await put(build());
186
+ expect(res.status).toBe(422);
187
+ expect(await prisma.gitHubEnterpriseApp.count()).toBe(0);
188
+ });
189
+
190
+ it("install resolves the agent's slug from its stored env against the cassettes", async () => {
191
+ const agent = await prisma.agent.create({ data: { name: "Ghe Agent" } });
192
+ await new AgentEnvService(prisma, identityCrypto).upsert(agent.id, {
193
+ GH_APP_SLUG: "example-agent-app",
194
+ });
195
+ const app = build({
196
+ "GET /apps/example-agent-app": "public_app_success",
197
+ "POST /enterprises/acme/apps/organizations/org-1/installations":
198
+ "install_created",
199
+ });
200
+ await put(app);
201
+ const res = await app.request("/github-enterprise/orgs/org-1/install", {
202
+ method: "POST",
203
+ headers: { ...ADMIN, "Content-Type": "application/json" },
204
+ body: JSON.stringify({ agentId: agent.id }),
205
+ });
206
+ expect(res.status).toBe(200);
207
+ expect(await res.json()).toMatchObject({ created: true, org: "org-1" });
208
+ });
209
+
210
+ it("isolation: no agent-facing path returns the stored key", async () => {
211
+ const agent = await prisma.agent.create({ data: { name: "Iso Agent" } });
212
+ const envService = new AgentEnvService(prisma, identityCrypto);
213
+ await envService.upsert(agent.id, { GH_APP_SLUG: "example-agent-app" });
214
+ await put(build());
215
+ const row = await prisma.gitHubEnterpriseApp.findUnique({
216
+ where: { role: "installer" },
217
+ });
218
+ const needles = [
219
+ PEM_BODY,
220
+ "PRIVATE KEY",
221
+ row?.privateKeyEncrypted as string,
222
+ ];
223
+
224
+ const surfaces: unknown[] = [
225
+ await envService.getConfigBundle(agent.id),
226
+ await envService.getByAgentId(agent.id),
227
+ ];
228
+ const runtime = createAgentRuntimeApp({
229
+ agentEnvService: envService,
230
+ agentCronJobService: { list: async () => [] },
231
+ agentService: new AgentService(prisma),
232
+ agentPluginService: new AgentPluginService(prisma),
233
+ adminApiKeys: parseAdminApiKeys(`agent-key:agent-tok:${agent.id}`),
234
+ agentTokenService: { validate: async () => null },
235
+ sessionSecret: "test-secret-32chars-for-ghe-api!!",
236
+ });
237
+ for (const path of [`/${agent.id}/config`, `/${agent.id}/crons`]) {
238
+ const res = await runtime.request(path, {
239
+ headers: { Authorization: "Bearer agent-tok" },
240
+ });
241
+ expect(res.status).toBe(200);
242
+ surfaces.push(await res.text());
243
+ }
244
+ for (const surface of surfaces) {
245
+ const text =
246
+ typeof surface === "string" ? surface : JSON.stringify(surface);
247
+ for (const needle of needles) expect(text).not.toContain(needle);
248
+ }
249
+
250
+ // The agent-bearer key cannot reach the key-bearing admin routes either.
251
+ const app = createGithubEnterpriseApp({
252
+ appService,
253
+ agentService: new AgentService(prisma),
254
+ agentEnvService: envService,
255
+ sessionSecret: "test-secret-32chars-for-ghe-api!!",
256
+ adminApiKeys: parseAdminApiKeys(`agent-key:agent-tok:${agent.id}`),
257
+ agentTokenService: { validate: async () => null },
258
+ });
259
+ const denied = await app.request("/github-enterprise/apps", {
260
+ headers: { Authorization: "Bearer agent-tok" },
261
+ });
262
+ expect(denied.status).toBe(403);
263
+ });
264
+ });