@app-vitals/agent-admin 1.13.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "0.135.0",
45
- "@app-vitals/agent-lib": "1.13.0",
45
+ "@app-vitals/agent-lib": "1.14.0",
46
46
  "@hono/zod-openapi": "^1.0.0",
47
47
  "@prisma/adapter-pg": "^7.10.0",
48
48
  "@prisma/client": "^7.10.0",
package/src/admin-boot.ts CHANGED
@@ -99,6 +99,8 @@ import { CronRunNowService } from "./cron-run-now.ts";
99
99
  import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
100
100
  import { CronRunTranscriptService } from "./cron-run-transcripts.ts";
101
101
  import { isDevAuthAllowed } from "./dev-auth-guard.ts";
102
+ import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
103
+ import { GitHubEnterpriseAppService } from "./github-enterprise-apps.ts";
102
104
  import { HttpGithubAppProvisioningClient } from "./github-app-provisioning-client.ts";
103
105
  import { HttpGoogleAuthClient } from "./google-auth-client.ts";
104
106
  import { HttpChatClient } from "./http-chat-client.ts";
@@ -113,7 +115,7 @@ import { parseSelfServeConfig } from "./self-serve-config.ts";
113
115
  import { HttpSlackProvisioningClient } from "./slack-provisioning-client.ts";
114
116
  import { SpecialtyTypes } from "./specialty-types.ts";
115
117
  import { runStartupPolicyReconcile } from "./startup-policy-reconcile.ts";
116
- import { makeTokenCrypto } from "./token-crypto.ts";
118
+ import { makeStrictTokenCrypto, makeTokenCrypto } from "./token-crypto.ts";
117
119
  import { parseTranscriptConfig } from "./transcript-config.ts";
118
120
  import {
119
121
  resolveTrialExpirySweepIntervalMs,
@@ -530,6 +532,24 @@ export async function startAdmin(
530
532
  }),
531
533
  );
532
534
 
535
+ // 3b'. GitHub Enterprise Apps API — /github-enterprise/* — admin-only,
536
+ // optional (GHE-1.3). With no rows configured it reports unconfigured
537
+ // and does nothing else. Strict crypto: no plain-text key storage.
538
+ root.route(
539
+ "/",
540
+ createGithubEnterpriseApp({
541
+ appService: new GitHubEnterpriseAppService(
542
+ prisma,
543
+ makeStrictTokenCrypto(),
544
+ ),
545
+ agentService,
546
+ agentEnvService,
547
+ sessionSecret,
548
+ adminApiKeys,
549
+ agentTokenService,
550
+ }),
551
+ );
552
+
533
553
  // 3c. Extension API routes — after every platform API, before the admin UI
534
554
  // (same no-shadowing rule as the admin CRUD API above).
535
555
  mountRouteContributions(root, resolvedExtensions.apiRoutes);
package/src/admin-spec.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  parseAdminApiKeys,
25
25
  } from "./agents-api.ts";
26
26
  import { createAgentRuntimeApp } from "./api.ts";
27
+ import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
27
28
 
28
29
  /** The admin OpenAPI document shape the spec builders and generators share. */
29
30
  export interface AdminOpenApiDocument {
@@ -172,6 +173,23 @@ export function buildPlatformAdminSpec(): AdminOpenApiDocument {
172
173
  }
173
174
  }
174
175
 
176
+ // GHE-1.3: the /github-enterprise routes live in their own app (mounted at
177
+ // the root in admin-boot.ts); fold their paths and schemas in.
178
+ const gheSpec = createGithubEnterpriseApp({
179
+ appService: {} as never,
180
+ agentService: {} as never,
181
+ agentEnvService: {} as never,
182
+ sessionSecret: SESSION_SECRET,
183
+ adminApiKeys,
184
+ agentTokenService: { validate: async () => null },
185
+ }).getOpenAPI31Document({
186
+ openapi: "3.1.0",
187
+ info: { title: "github-enterprise (internal)", version: "0.1.0" },
188
+ });
189
+ for (const [path, def] of Object.entries(gheSpec.paths ?? {})) {
190
+ mergedPaths[path] = def as Record<string, unknown>;
191
+ }
192
+
175
193
  return {
176
194
  ...adminSpec,
177
195
  paths: mergedPaths,
@@ -180,6 +198,7 @@ export function buildPlatformAdminSpec(): AdminOpenApiDocument {
180
198
  schemas: {
181
199
  ...(adminSpec.components?.schemas ?? {}),
182
200
  ...(runtimeSpec.components?.schemas ?? {}),
201
+ ...(gheSpec.components?.schemas ?? {}),
183
202
  },
184
203
  },
185
204
  };
@@ -606,5 +606,70 @@
606
606
  "body": {
607
607
  "message": "Service Unavailable"
608
608
  }
609
+ },
610
+ "public_app_success": {
611
+ "status": 200,
612
+ "body": {
613
+ "id": 777,
614
+ "slug": "example-agent-app",
615
+ "client_id": "Iv1.targetapp0001",
616
+ "name": "Example Agent App",
617
+ "permissions": {
618
+ "contents": "read",
619
+ "metadata": "read"
620
+ }
621
+ }
622
+ },
623
+ "public_app_private_404": {
624
+ "status": 404,
625
+ "body": {
626
+ "message": "Not Found",
627
+ "documentation_url": "https://docs.github.com/rest/apps/apps#get-an-app"
628
+ }
629
+ },
630
+ "public_app_no_client_id": {
631
+ "status": 200,
632
+ "body": {
633
+ "id": 777,
634
+ "slug": "example-agent-app"
635
+ }
636
+ },
637
+ "app_get_success": {
638
+ "status": 200,
639
+ "body": {
640
+ "id": 555,
641
+ "slug": "example-lookup-app",
642
+ "permissions": {
643
+ "metadata": "read"
644
+ }
645
+ }
646
+ },
647
+ "app_get_no_metadata": {
648
+ "status": 200,
649
+ "body": {
650
+ "id": 555,
651
+ "slug": "example-lookup-app",
652
+ "permissions": {
653
+ "issues": "read"
654
+ }
655
+ }
656
+ },
657
+ "app_get_401": {
658
+ "status": 401,
659
+ "body": {
660
+ "message": "A JSON web token could not be decoded",
661
+ "documentation_url": "https://docs.github.com/rest"
662
+ }
663
+ },
664
+ "access_token_no_installer_permission": {
665
+ "status": 201,
666
+ "body": {
667
+ "token": "ghs_cassetteInstallationToken0000000000",
668
+ "expires_at": "2026-11-09T13:00:00Z",
669
+ "permissions": {
670
+ "metadata": "read"
671
+ },
672
+ "repository_selection": "all"
673
+ }
609
674
  }
610
675
  }
@@ -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,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
+ });