@app-vitals/agent-admin 1.6.0 → 1.8.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.
@@ -6485,3 +6485,117 @@ describe("admin API — PATCH /agents/:id specialty (SAL-1.2)", () => {
6485
6485
  ]);
6486
6486
  });
6487
6487
  });
6488
+
6489
+ // ─── PUT /agents/:id/baked-marketplaces (PMK-1.4) ─────────────────────────────
6490
+
6491
+ describe("admin API — baked marketplaces report", () => {
6492
+ const BAKED_URL = `/agents/${AGENT_ID}/baked-marketplaces`;
6493
+ let cookie: string;
6494
+
6495
+ beforeAll(async () => {
6496
+ cookie = await makeSessionCookie();
6497
+ });
6498
+
6499
+ /** Deps whose agentService stores/serves the baked list in memory. */
6500
+ function makeBakedDeps(tokenAgentId: string) {
6501
+ let baked: string[] = [];
6502
+ const base = makeDepsWithTokenValidation(async () => ({
6503
+ agentId: tokenAgentId,
6504
+ }));
6505
+ const deps: AdminDeps = {
6506
+ ...base,
6507
+ agentService: {
6508
+ ...base.agentService,
6509
+ getDetail: async (id: string) => {
6510
+ const d = await base.agentService.getDetail(id);
6511
+ return d ? { ...d, bakedMarketplaces: baked } : null;
6512
+ },
6513
+ setBakedMarketplaces: async (_id: string, names: string[]) => {
6514
+ baked = names;
6515
+ return names;
6516
+ },
6517
+ },
6518
+ };
6519
+ return { deps, stored: () => baked };
6520
+ }
6521
+
6522
+ const bearerPut = (app: ReturnType<typeof createAdminApp>, body: unknown) =>
6523
+ app.request(BAKED_URL, {
6524
+ method: "PUT",
6525
+ body: JSON.stringify(body),
6526
+ headers: {
6527
+ "Content-Type": "application/json",
6528
+ Authorization: `Bearer ${VALID_BEARER_TOKEN}`,
6529
+ },
6530
+ });
6531
+ const bearerGet = (app: ReturnType<typeof createAdminApp>) =>
6532
+ app.request(`/agents/${AGENT_ID}`, {
6533
+ headers: { Cookie: `admin_session=${cookie}` },
6534
+ });
6535
+
6536
+ it("stores the names for the agent's own key and GET shows them", async () => {
6537
+ const app = createAdminApp(makeBakedDeps(AGENT_ID).deps);
6538
+ const res = await bearerPut(app, { names: ["vitals-os", "shipwright"] });
6539
+ expect(res.status).toBe(200);
6540
+ expect((await res.json()).bakedMarketplaces).toEqual([
6541
+ "vitals-os",
6542
+ "shipwright",
6543
+ ]);
6544
+ expect((await (await bearerGet(app)).json()).bakedMarketplaces).toEqual([
6545
+ "vitals-os",
6546
+ "shipwright",
6547
+ ]);
6548
+ });
6549
+
6550
+ it("overwrites rather than appends", async () => {
6551
+ const app = createAdminApp(makeBakedDeps(AGENT_ID).deps);
6552
+ await bearerPut(app, { names: ["vitals-os", "shipwright"] });
6553
+ await bearerPut(app, { names: ["vitals-os"] });
6554
+ expect((await (await bearerGet(app)).json()).bakedMarketplaces).toEqual([
6555
+ "vitals-os",
6556
+ ]);
6557
+ });
6558
+
6559
+ it("another agent's key is rejected (403) and stores nothing", async () => {
6560
+ const { deps, stored } = makeBakedDeps("other-agent");
6561
+ const res = await bearerPut(createAdminApp(deps), { names: ["x"] });
6562
+ expect(res.status).toBe(403);
6563
+ expect(stored()).toEqual([]);
6564
+ });
6565
+
6566
+ it("an admin session is rejected (403): only the agent's own key reports", async () => {
6567
+ const { deps, stored } = makeBakedDeps(AGENT_ID);
6568
+ const res = await createAdminApp(deps).request(BAKED_URL, {
6569
+ method: "PUT",
6570
+ body: JSON.stringify({ names: ["x"] }),
6571
+ headers: {
6572
+ "Content-Type": "application/json",
6573
+ Cookie: `admin_session=${cookie}`,
6574
+ },
6575
+ });
6576
+ expect(res.status).toBe(403);
6577
+ expect(stored()).toEqual([]);
6578
+ });
6579
+
6580
+ it("rejects unknown body fields (400)", async () => {
6581
+ const { deps } = makeBakedDeps(AGENT_ID);
6582
+ const res = await bearerPut(createAdminApp(deps), {
6583
+ names: [],
6584
+ extra: 1,
6585
+ });
6586
+ expect(res.status).toBe(400);
6587
+ });
6588
+
6589
+ it("PATCH /agents/:id cannot set the baked list", async () => {
6590
+ const { deps, stored } = makeBakedDeps(AGENT_ID);
6591
+ await createAdminApp(deps).request(`/agents/${AGENT_ID}`, {
6592
+ method: "PATCH",
6593
+ body: JSON.stringify({ bakedMarketplaces: ["evil"] }),
6594
+ headers: {
6595
+ "Content-Type": "application/json",
6596
+ Cookie: `admin_session=${cookie}`,
6597
+ },
6598
+ });
6599
+ expect(stored()).toEqual([]);
6600
+ });
6601
+ });
package/src/agents-api.ts CHANGED
@@ -123,6 +123,7 @@ import {
123
123
  AgentSummarySchema,
124
124
  AgentTokenSchema,
125
125
  AgentToolSchema,
126
+ BakedMarketplacesResultSchema,
126
127
  ChatCredentialsWrapperSchema,
127
128
  ChatTokenStatsSchema,
128
129
  CreateAgentBodySchema,
@@ -154,6 +155,7 @@ import {
154
155
  PatchAgentPluginBodySchema,
155
156
  PatchAgentToolBodySchema,
156
157
  PluginNameQuerySchema,
158
+ PutBakedMarketplacesBodySchema,
157
159
  PutGitHubInstallationsBodySchema,
158
160
  TokenIdParamSchema,
159
161
  ToolIdParamSchema,
@@ -182,7 +184,10 @@ export interface AdminDeps {
182
184
  // through the narrower Pick above.
183
185
  | "updateFields"
184
186
  | "runTransaction"
185
- >;
187
+ > &
188
+ // PMK-1.4: optional so existing AdminDeps consumers stay valid; the
189
+ // baked-marketplaces report route fails with a 500 when it is not wired.
190
+ Partial<Pick<AgentService, "setBakedMarketplaces">>;
186
191
  agentEnvService: Pick<
187
192
  AgentEnvService,
188
193
  "upsert" | "patch" | "getByAgentId" | "deleteKey"
@@ -453,6 +458,15 @@ const GetAgentResultSchema = z
453
458
  */
454
459
  trialExpiresAt: z.string().datetime().nullable().optional(),
455
460
  trialExpiryWarnedAt: z.string().datetime().nullable().optional(),
461
+ /**
462
+ * PMK-1.4: names of the marketplaces baked into the agent's image, as last
463
+ * reported by the runtime at boot. Read-only: only the runtime-authenticated
464
+ * PUT /agents/:id/baked-marketplaces route writes it.
465
+ */
466
+ bakedMarketplaces: z
467
+ .array(z.string())
468
+ .optional()
469
+ .openapi({ example: ["vitals-os", "shipwright"] }),
456
470
  /** APM-1.3: the six real agent-policy fields (APM-1.1 DB columns). */
457
471
  autoPostReviews: z.boolean().openapi({ example: true }),
458
472
  allowSelfReview: z.boolean().openapi({ example: false }),
@@ -1341,6 +1355,37 @@ const putGitHubInstallationsRoute = createRoute({
1341
1355
  },
1342
1356
  });
1343
1357
 
1358
+ const putBakedMarketplacesRoute = createRoute({
1359
+ method: "put",
1360
+ path: "/agents/{id}/baked-marketplaces",
1361
+ summary: "Report the marketplaces baked into the agent's image",
1362
+ description:
1363
+ "Runtime-only report, authenticated with the agent's own key. Overwrites the stored list of baked marketplace names (the image's current state); it is exposed read-only as `bakedMarketplaces` on GET /agents/{id}. Other callers, including another agent's key, get 403.",
1364
+ request: {
1365
+ params: AgentIdParamSchema,
1366
+ body: {
1367
+ content: {
1368
+ "application/json": { schema: PutBakedMarketplacesBodySchema },
1369
+ },
1370
+ },
1371
+ },
1372
+ responses: {
1373
+ 200: {
1374
+ description: "Baked marketplaces stored",
1375
+ content: {
1376
+ "application/json": { schema: BakedMarketplacesResultSchema },
1377
+ },
1378
+ },
1379
+ 400: { description: "Bad request", ...jsonError },
1380
+ 401: { description: "Unauthorized", ...jsonError },
1381
+ 403: {
1382
+ description: "Forbidden — only the agent's own key may report",
1383
+ ...jsonError,
1384
+ },
1385
+ 404: { description: "Agent not found", ...jsonError },
1386
+ },
1387
+ });
1388
+
1344
1389
  const getGitHubInstallationsRoute = createRoute({
1345
1390
  method: "get",
1346
1391
  path: "/agents/{id}/github-installations",
@@ -2747,6 +2792,28 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
2747
2792
  );
2748
2793
  });
2749
2794
 
2795
+ // PUT /agents/:id/baked-marketplaces — runtime report (PMK-1.4), overwrite.
2796
+ // Restricted to the agent's own key: admins, members and other agents' keys
2797
+ // never write this value.
2798
+ app.openapi(putBakedMarketplacesRoute, async (c) => {
2799
+ const { id: agentId } = c.req.valid("param");
2800
+ const scope = c.get("scope") as AdminScope | undefined;
2801
+ if (scope?.kind !== "agent" || scope.agentId !== agentId) {
2802
+ throw new ForbiddenError(
2803
+ "Only the agent's own key may report baked marketplaces",
2804
+ );
2805
+ }
2806
+ if (!agentService.setBakedMarketplaces) {
2807
+ throw new Error("agentService.setBakedMarketplaces is not wired");
2808
+ }
2809
+ const { names } = c.req.valid("json");
2810
+ const stored = await agentService.setBakedMarketplaces(agentId, [
2811
+ ...new Set(names),
2812
+ ]);
2813
+ if (!stored) throw new NotFoundError(`agent ${agentId} not found`);
2814
+ return c.json({ bakedMarketplaces: stored }, 200);
2815
+ });
2816
+
2750
2817
  // GET /agents/:id/github-installations — latest snapshot, 404 if none yet
2751
2818
  app.openapi(getGitHubInstallationsRoute, async (c) => {
2752
2819
  const { id: agentId } = c.req.valid("param");
@@ -2990,6 +3057,7 @@ function serializeAgent(
2990
3057
  missingRequiredEnv?: string[];
2991
3058
  trialExpiresAt?: Date | null;
2992
3059
  trialExpiryWarnedAt?: Date | null;
3060
+ bakedMarketplaces?: string[];
2993
3061
  autoPostReviews?: boolean;
2994
3062
  allowSelfReview?: boolean;
2995
3063
  minConfidence?: number;
@@ -3024,6 +3092,7 @@ function serializeAgent(
3024
3092
  trialExpiryWarnedAt: agent.trialExpiryWarnedAt
3025
3093
  ? agent.trialExpiryWarnedAt.toISOString()
3026
3094
  : null,
3095
+ bakedMarketplaces: agent.bakedMarketplaces ?? [],
3027
3096
  autoPostReviews: agent.autoPostReviews ?? true,
3028
3097
  allowSelfReview: agent.allowSelfReview ?? false,
3029
3098
  minConfidence: agent.minConfidence ?? 75,
@@ -0,0 +1,123 @@
1
+ /**
2
+ * PMK-1.4: integration tests (real Postgres) for the baked-marketplaces store:
3
+ * AgentService.setBakedMarketplaces, overwrite semantics, GET detail exposure,
4
+ * and the absence of any other write path.
5
+ *
6
+ * Requires DATABASE_URL_ADMIN_TEST; skips otherwise.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
10
+ import { readdirSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import type { PrismaClient } from "../prisma/client/client.ts";
13
+ import { AgentService } from "./agents.ts";
14
+ import { PatchAgentBodySchema } from "./openapi-schemas.ts";
15
+ import { createAdminPrismaClient } from "./prisma-client.ts";
16
+
17
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
18
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
19
+
20
+ describeOrSkip("baked marketplaces store (integration)", () => {
21
+ let prisma: PrismaClient;
22
+ let service: AgentService;
23
+
24
+ beforeEach(async () => {
25
+ prisma = createAdminPrismaClient(TEST_DB as string);
26
+ await prisma.agentGitHubInstallationsSnapshot.deleteMany();
27
+ await prisma.agentWorkQueueSnapshot.deleteMany();
28
+ await prisma.agentChatTokenUsageDailyByModel.deleteMany();
29
+ await prisma.agentCronRun.deleteMany();
30
+ await prisma.agentToken.deleteMany();
31
+ await prisma.agentCronJob.deleteMany();
32
+ await prisma.agentTool.deleteMany();
33
+ await prisma.agentEnv.deleteMany();
34
+ await prisma.agentMember.deleteMany();
35
+ await prisma.agent.deleteMany();
36
+ service = new AgentService(prisma);
37
+ });
38
+
39
+ afterEach(async () => {
40
+ await prisma.$disconnect();
41
+ });
42
+
43
+ it("defaults to an empty list", async () => {
44
+ const { id } = await prisma.agent.create({ data: { name: "a" } });
45
+ expect((await service.getDetail(id))?.bakedMarketplaces).toEqual([]);
46
+ });
47
+
48
+ it("stores the reported names and getDetail exposes them", async () => {
49
+ const { id } = await prisma.agent.create({ data: { name: "a" } });
50
+ const stored = await service.setBakedMarketplaces(id, [
51
+ "vitals-os",
52
+ "shipwright",
53
+ ]);
54
+ expect(stored).toEqual(["vitals-os", "shipwright"]);
55
+ expect((await service.getDetail(id))?.bakedMarketplaces).toEqual([
56
+ "vitals-os",
57
+ "shipwright",
58
+ ]);
59
+ });
60
+
61
+ it("overwrites on every report instead of appending", async () => {
62
+ const { id } = await prisma.agent.create({ data: { name: "a" } });
63
+ await service.setBakedMarketplaces(id, ["vitals-os", "shipwright"]);
64
+ await service.setBakedMarketplaces(id, ["vitals-os"]);
65
+ expect((await service.getDetail(id))?.bakedMarketplaces).toEqual([
66
+ "vitals-os",
67
+ ]);
68
+ await service.setBakedMarketplaces(id, []);
69
+ expect((await service.getDetail(id))?.bakedMarketplaces).toEqual([]);
70
+ });
71
+
72
+ it("only touches the reporting agent's row", async () => {
73
+ const a = await prisma.agent.create({ data: { name: "a" } });
74
+ const b = await prisma.agent.create({ data: { name: "b" } });
75
+ await service.setBakedMarketplaces(a.id, ["x"]);
76
+ expect((await service.getDetail(b.id))?.bakedMarketplaces).toEqual([]);
77
+ });
78
+
79
+ it("returns null for an unknown agent", async () => {
80
+ expect(await service.setBakedMarketplaces("nope", ["x"])).toBeNull();
81
+ });
82
+
83
+ it("updateSelfHosted (the PATCH writer) never writes the baked list", async () => {
84
+ const { id } = await prisma.agent.create({ data: { name: "a" } });
85
+ await service.setBakedMarketplaces(id, ["vitals-os"]);
86
+ await service.updateSelfHosted(id, {
87
+ selfHosted: true,
88
+ // biome-ignore lint/suspicious/noExplicitAny: proving an off-contract key is ignored
89
+ ...({ bakedMarketplaces: ["evil"] } as any),
90
+ });
91
+ expect((await service.getDetail(id))?.bakedMarketplaces).toEqual([
92
+ "vitals-os",
93
+ ]);
94
+ });
95
+
96
+ it("PatchAgentBodySchema does not carry the field through", () => {
97
+ const parsed = PatchAgentBodySchema.safeParse({
98
+ bakedMarketplaces: ["evil"],
99
+ });
100
+ if (parsed.success) {
101
+ expect("bakedMarketplaces" in parsed.data).toBe(false);
102
+ }
103
+ });
104
+
105
+ it("no admin source other than the report path writes bakedMarketplaces", () => {
106
+ const dir = import.meta.dir;
107
+ const writers = readdirSync(dir)
108
+ .filter((f) => f.endsWith(".ts") && !/\.test\.ts$/.test(f))
109
+ .filter((f) =>
110
+ /bakedMarketplaces/.test(readFileSync(join(dir, f), "utf8")),
111
+ )
112
+ .sort();
113
+ // agents.ts: writer + read select; agents-api.ts: report route + read
114
+ // serialization; openapi-schemas.ts: response/read schemas only.
115
+ expect(writers).toEqual([
116
+ "agents-api.ts",
117
+ "agents.ts",
118
+ "openapi-schemas.ts",
119
+ ]);
120
+ const api = readFileSync(join(dir, "agents-api.ts"), "utf8");
121
+ expect(api.match(/setBakedMarketplaces\(/g)?.length).toBe(1);
122
+ });
123
+ });
package/src/agents.ts CHANGED
@@ -125,6 +125,12 @@ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
125
125
  */
126
126
  trialExpiresAt?: Date | null;
127
127
  trialExpiryWarnedAt?: Date | null;
128
+ /**
129
+ * PMK-1.4: marketplace names baked into the agent's image, as last reported
130
+ * by the runtime. Read-only here; written only by setBakedMarketplaces().
131
+ * Optional so existing AgentDetail test doubles stay valid.
132
+ */
133
+ bakedMarketplaces?: string[];
128
134
  /**
129
135
  * APM-1.3: the six real agent-policy fields (APM-1.1 DB columns,
130
136
  * Prisma-column defaults noted per field below). Optional here (like
@@ -281,6 +287,7 @@ const DETAIL_SELECT = {
281
287
  updatedAt: true,
282
288
  trialExpiresAt: true,
283
289
  trialExpiryWarnedAt: true,
290
+ bakedMarketplaces: true,
284
291
  autoPostReviews: true,
285
292
  allowSelfReview: true,
286
293
  minConfidence: true,
@@ -454,6 +461,23 @@ export class AgentService {
454
461
  return { ...row, missingRequiredEnv };
455
462
  }
456
463
 
464
+ /**
465
+ * PMK-1.4: overwrite the agent's reported baked-marketplace names. Called
466
+ * only by the runtime-authenticated PUT /agents/:id/baked-marketplaces
467
+ * route; no PATCH field or UI control reaches it. Returns the stored list,
468
+ * or null when the agent does not exist.
469
+ */
470
+ async setBakedMarketplaces(
471
+ id: string,
472
+ names: string[],
473
+ ): Promise<string[] | null> {
474
+ const res = await this.prisma.agent.updateMany({
475
+ where: { id },
476
+ data: { bakedMarketplaces: names },
477
+ });
478
+ return res.count === 0 ? null : names;
479
+ }
480
+
457
481
  /**
458
482
  * Returns whether an agent with the given id exists.
459
483
  */
@@ -1078,6 +1078,26 @@ export const AgentGitHubInstallationsSnapshotSchema = z
1078
1078
 
1079
1079
  // ─── Path param schemas ───────────────────────────────────────────────────────
1080
1080
 
1081
+ /**
1082
+ * PUT /agents/:id/baked-marketplaces body (PMK-1.4). Runtime-only report:
1083
+ * `names` overwrites the stored list.
1084
+ */
1085
+ export const PutBakedMarketplacesBodySchema = z
1086
+ .object({
1087
+ names: z
1088
+ .array(z.string().min(1).max(255))
1089
+ .max(100)
1090
+ .openapi({ example: ["vitals-os", "shipwright"] }),
1091
+ })
1092
+ .strict()
1093
+ .openapi("PutBakedMarketplacesBody");
1094
+
1095
+ export const BakedMarketplacesResultSchema = z
1096
+ .object({
1097
+ bakedMarketplaces: z.array(z.string()).openapi({ example: ["vitals-os"] }),
1098
+ })
1099
+ .openapi("BakedMarketplacesResult");
1100
+
1081
1101
  export const AgentIdParamSchema = z.object({
1082
1102
  id: z.string().openapi({ example: "clx1234567890" }),
1083
1103
  });