@app-vitals/agent-admin 1.11.0 → 1.12.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,158 @@
1
+ /**
2
+ * admin/e2e/marketplaces-section.e2e.ts
3
+ * Admin UI — Marketplaces section on the agent page (PMK-1.5)
4
+ *
5
+ * Covers: remote rows listed, add and remove of a remote row, and baked rows
6
+ * (reported by the runtime) rendered read-only with no controls.
7
+ *
8
+ * Spawns admin/e2e/test-server.ts on its own port; the server's marketplace
9
+ * rows are in memory, so tests run serially against one server.
10
+ */
11
+
12
+ import { type ChildProcess, spawn } from "node:child_process";
13
+ import { dirname, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { type BrowserContext, type Page, expect, test } from "@playwright/test";
16
+ import { sign } from "hono/jwt";
17
+
18
+ const PORT = 3496;
19
+ const BASE_URL = `http://localhost:${PORT}`;
20
+ const SESSION_SECRET = "e2e-admin-test-secret-32chars!!!";
21
+ const AGENT_ID = "agent-e2e-1";
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+
25
+ test.describe.configure({ mode: "serial" });
26
+
27
+ let serverProcess: ChildProcess | null = null;
28
+
29
+ async function waitForServer(url: string, maxWaitMs: number): Promise<void> {
30
+ const start = Date.now();
31
+ while (Date.now() - start < maxWaitMs) {
32
+ try {
33
+ const res = await fetch(url);
34
+ if (res.status < 500) return;
35
+ } catch {
36
+ // Not ready yet
37
+ }
38
+ await new Promise((r) => setTimeout(r, 300));
39
+ }
40
+ throw new Error(`Server at ${url} did not start within ${maxWaitMs}ms`);
41
+ }
42
+
43
+ test.beforeAll(async () => {
44
+ serverProcess = spawn("bun", ["run", resolve(__dirname, "test-server.ts")], {
45
+ env: {
46
+ ...process.env,
47
+ ADMIN_E2E_PORT: String(PORT),
48
+ ADMIN_E2E_SESSION_SECRET: SESSION_SECRET,
49
+ },
50
+ stdio: "pipe",
51
+ cwd: resolve(__dirname, "../.."),
52
+ });
53
+ serverProcess.stderr?.on("data", (d: Buffer) => {
54
+ if (d.toString().trim())
55
+ console.error("[admin-e2e-marketplaces-server]", d.toString().trim());
56
+ });
57
+ await waitForServer(`${BASE_URL}/health`, 15_000);
58
+ });
59
+
60
+ test.afterAll(() => {
61
+ serverProcess?.kill("SIGTERM");
62
+ serverProcess = null;
63
+ });
64
+
65
+ const table = (page: Page) => page.locator("#marketplaces-table");
66
+
67
+ async function loadAgentPage(page: Page, context: BrowserContext) {
68
+ const nowSec = Math.floor(Date.now() / 1000);
69
+ const token = await sign(
70
+ {
71
+ userId: "google-sub-e2e",
72
+ email: "admin@example.com",
73
+ isAdmin: true,
74
+ iat: nowSec,
75
+ exp: nowSec + 3600,
76
+ },
77
+ SESSION_SECRET,
78
+ "HS256",
79
+ );
80
+ await context.addCookies([
81
+ {
82
+ name: "admin_session",
83
+ value: token,
84
+ domain: "localhost",
85
+ path: "/",
86
+ httpOnly: true,
87
+ sameSite: "Lax",
88
+ },
89
+ ]);
90
+ await page.goto(`${BASE_URL}/admin/agents/${AGENT_ID}`);
91
+ await openGroup(page);
92
+ }
93
+
94
+ // The section lives in the "Plugins & Tools" accordion group, collapsed on
95
+ // every page load (including the redirect after each add/remove).
96
+ async function openGroup(page: Page) {
97
+ // Set `open` rather than clicking the summary, so a still-old (already
98
+ // open) page can't be toggled shut; the table locator retries across the
99
+ // navigation.
100
+ await expect(async () => {
101
+ await page
102
+ .locator("details", { has: page.locator("#marketplaces-table") })
103
+ .evaluate((el) => {
104
+ (el as HTMLDetailsElement).open = true;
105
+ });
106
+ await expect(table(page)).toBeVisible({ timeout: 1000 });
107
+ }).toPass();
108
+ }
109
+
110
+ test("lists the remote row with source and ref, and the baked row", async ({
111
+ page,
112
+ context,
113
+ }) => {
114
+ await loadAgentPage(page, context);
115
+ const remote = table(page).locator("tbody tr", { hasText: "seeded-remote" });
116
+ await expect(remote).toContainText("app-vitals/seeded");
117
+ await expect(remote).toContainText("v1.2.3");
118
+ await expect(remote).toContainText("remote");
119
+ await expect(
120
+ table(page).locator('tr[data-baked-marketplace="baked-e2e"]'),
121
+ ).toContainText("from image");
122
+ });
123
+
124
+ test("baked rows have no controls", async ({ page, context }) => {
125
+ await loadAgentPage(page, context);
126
+ const baked = table(page).locator('tr[data-baked-marketplace="baked-e2e"]');
127
+ await expect(baked).toHaveCount(1);
128
+ await expect(baked.locator("form, button, input, a")).toHaveCount(0);
129
+ });
130
+
131
+ test("adds a remote row and then removes it", async ({ page, context }) => {
132
+ await loadAgentPage(page, context);
133
+ const form = page.locator("#add-marketplace-form");
134
+ await form.locator('input[name="name"]').fill("added-remote");
135
+ await form.locator('input[name="source"]').fill("owner/added");
136
+ await form.locator('input[name="ref"]').fill("main");
137
+ await form.getByRole("button", { name: "Add marketplace" }).click();
138
+ await page.waitForURL(`${BASE_URL}/admin/agents/${AGENT_ID}`);
139
+ await openGroup(page);
140
+
141
+ const added = table(page).locator("tbody tr", { hasText: "added-remote" });
142
+ await expect(added).toContainText("owner/added");
143
+ await expect(added).toContainText("main");
144
+
145
+ await added.getByRole("button", { name: "Remove" }).click();
146
+ await page.waitForURL(`${BASE_URL}/admin/agents/${AGENT_ID}`);
147
+ await openGroup(page);
148
+ await expect(
149
+ table(page).locator("tbody tr", { hasText: "added-remote" }),
150
+ ).toHaveCount(0);
151
+ // The seeded row and the baked row are untouched.
152
+ await expect(
153
+ table(page).locator("tbody tr", { hasText: "seeded-remote" }),
154
+ ).toHaveCount(1);
155
+ await expect(table(page).locator("tr[data-baked-marketplace]")).toHaveCount(
156
+ 1,
157
+ );
158
+ });
@@ -84,6 +84,29 @@ const MOCK_TOKEN = {
84
84
  revokedAt: null,
85
85
  };
86
86
 
87
+ // In-memory AgentMarketplace rows (PMK-1.5): one seeded remote row, mutated by
88
+ // the add/remove routes so marketplaces-section.e2e.ts sees real round trips.
89
+ // Each spec file spawns its own server process, so state never leaks across files.
90
+ const marketplaceRows: Array<{
91
+ id: string;
92
+ agentId: string;
93
+ name: string;
94
+ source: string;
95
+ ref: string | null;
96
+ createdAt: Date;
97
+ updatedAt: Date;
98
+ }> = [
99
+ {
100
+ id: "mk-e2e-1",
101
+ agentId: ADMIN_E2E_AGENT.id,
102
+ name: "seeded-remote",
103
+ source: "app-vitals/seeded",
104
+ ref: "v1.2.3",
105
+ createdAt: new Date("2024-01-01"),
106
+ updatedAt: new Date("2024-01-01"),
107
+ },
108
+ ];
109
+
87
110
  // ─── Chat fixtures (CFB-1.3 — chat thread page e2e) ──────────────────────────
88
111
 
89
112
  const MOCK_CHAT_THREAD: ChatThread = {
@@ -306,6 +329,35 @@ function buildMockDeps(chatClient: ChatClient | undefined): AdminUIDeps {
306
329
  updatedAt: new Date("2024-01-01"),
307
330
  }),
308
331
  },
332
+ agentMarketplaceService: {
333
+ list: async () => [...marketplaceRows],
334
+ upsert: async (
335
+ agentId: string,
336
+ input: { name: string; source: string; ref?: string | null },
337
+ ) => {
338
+ const existing = marketplaceRows.find((m) => m.name === input.name);
339
+ if (existing) {
340
+ existing.source = input.source;
341
+ existing.ref = input.ref ?? null;
342
+ return existing;
343
+ }
344
+ const row = {
345
+ id: `mk-e2e-${marketplaceRows.length + 1}-${input.name}`,
346
+ agentId,
347
+ name: input.name,
348
+ source: input.source,
349
+ ref: input.ref ?? null,
350
+ createdAt: new Date(),
351
+ updatedAt: new Date(),
352
+ };
353
+ marketplaceRows.push(row);
354
+ return row;
355
+ },
356
+ remove: async (_agentId: string, marketplaceId: string) => {
357
+ const i = marketplaceRows.findIndex((m) => m.id === marketplaceId);
358
+ if (i >= 0) marketplaceRows.splice(i, 1);
359
+ },
360
+ },
309
361
  agentMemberService: {
310
362
  listByEmail: async () => [],
311
363
  exists: async () => false,
@@ -355,6 +407,7 @@ function buildMockDeps(chatClient: ChatClient | undefined): AdminUIDeps {
355
407
  repos: [],
356
408
  reviewAuthorAllowlist: [],
357
409
  missingRequiredEnv: [],
410
+ bakedMarketplaces: ["baked-e2e"],
358
411
  }),
359
412
  updateFields: async () => ({
360
413
  id: ADMIN_E2E_AGENT.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "1.11.0",
3
+ "version": "1.12.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.11.0",
45
+ "@app-vitals/agent-lib": "1.12.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
@@ -555,6 +555,7 @@ export async function startAdmin(
555
555
  agentToolService,
556
556
  agentTokenService,
557
557
  agentPluginService,
558
+ agentMarketplaceService,
558
559
  agentMemberService,
559
560
  callerScopeResolver,
560
561
  agentService,
@@ -111,6 +111,12 @@ export interface AgentDetail {
111
111
  anthropicIdentityName?: string | null;
112
112
  anthropicServiceAccountId?: string | null;
113
113
  anthropicFederationRuleId?: string | null;
114
+ /**
115
+ * PMK-1.5: marketplace names baked into the agent's image, as reported by
116
+ * the runtime at boot. Rendered read-only. Absent or empty shows the empty
117
+ * state.
118
+ */
119
+ bakedMarketplaces?: string[];
114
120
  }
115
121
 
116
122
  export interface CronJobItem {
@@ -180,6 +186,14 @@ export interface PluginItem {
180
186
  createdAt: Date;
181
187
  }
182
188
 
189
+ export interface MarketplaceItem {
190
+ id: string;
191
+ name: string;
192
+ source: string;
193
+ ref: string | null;
194
+ createdAt: Date;
195
+ }
196
+
183
197
  export interface MemberItem {
184
198
  id: string;
185
199
  email: string;
@@ -957,6 +971,8 @@ export function renderAgentDetailPage(
957
971
  * /admin/agents/:id/queue-activity; absent/false omits the link.
958
972
  */
959
973
  queueActivityLinks?: boolean;
974
+ /** PMK-1.5: remote AgentMarketplace rows for the Marketplaces card. */
975
+ marketplaces?: MarketplaceItem[];
960
976
  },
961
977
  ): string {
962
978
  // Reference time for relative timestamps — injected by tests for determinism,
@@ -1271,6 +1287,39 @@ export function renderAgentDetailPage(
1271
1287
  )
1272
1288
  .join("\n");
1273
1289
 
1290
+ const marketplaces = opts?.marketplaces ?? [];
1291
+ const bakedMarketplaces = agent.bakedMarketplaces ?? [];
1292
+ const marketplaceRows = marketplaces
1293
+ .map(
1294
+ (m) => `<tr>
1295
+ <td class="mono">${escapeHtml(m.name)}</td>
1296
+ <td class="mono">${escapeHtml(m.source)}</td>
1297
+ <td class="mono">${m.ref ? escapeHtml(m.ref) : "default"}</td>
1298
+ <td><span class="badge badge-green">remote</span></td>
1299
+ <td>
1300
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/marketplaces/delete" style="display:inline">
1301
+ <input type="hidden" name="marketplaceId" value="${escapeHtml(m.id)}" />
1302
+ <button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
1303
+ </form>
1304
+ </td>
1305
+ </tr>`,
1306
+ )
1307
+ .join("\n");
1308
+ const bakedMarketplaceRows = bakedMarketplaces
1309
+ .map(
1310
+ (name) => `<tr data-baked-marketplace="${escapeHtml(name)}">
1311
+ <td class="mono">${escapeHtml(name)}</td>
1312
+ <td class="mono" style="color:#6b7280">—</td>
1313
+ <td class="mono" style="color:#6b7280">—</td>
1314
+ <td><span class="badge badge-gray" title="Comes from the agent image; only an image update changes it">from image</span></td>
1315
+ <td></td>
1316
+ </tr>`,
1317
+ )
1318
+ .join("\n");
1319
+ const marketplaceTableRows =
1320
+ marketplaceRows + bakedMarketplaceRows ||
1321
+ `<tr><td colspan="5" class="empty-state">No marketplaces.</td></tr>`;
1322
+
1274
1323
  const memberRows =
1275
1324
  members.length === 0
1276
1325
  ? `<tr><td colspan="3" class="empty-state">No members yet.</td></tr>`
@@ -1683,6 +1732,33 @@ export function renderAgentDetailPage(
1683
1732
  </div>
1684
1733
  </div>`;
1685
1734
 
1735
+ const marketplacesSection = `<div class="card">
1736
+ <div class="card-title">Marketplaces</div>
1737
+ <div class="data-table-wrapper">
1738
+ <table class="data-table" id="marketplaces-table">
1739
+ <thead>
1740
+ <tr>
1741
+ <th>Name</th>
1742
+ <th>Source</th>
1743
+ <th>Ref</th>
1744
+ <th>Origin</th>
1745
+ <th></th>
1746
+ </tr>
1747
+ </thead>
1748
+ <tbody>
1749
+ ${marketplaceTableRows}
1750
+ </tbody>
1751
+ </table>
1752
+ </div>
1753
+ <form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/marketplaces" id="add-marketplace-form" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
1754
+ <input name="name" type="text" class="form-input mono" placeholder="name (e.g. shipwright)" required />
1755
+ <input name="source" type="text" class="form-input mono" placeholder="owner/repo or https git URL" required />
1756
+ <input name="ref" type="text" class="form-input mono" placeholder="ref (optional)" />
1757
+ <button type="submit" class="btn btn-primary">Add marketplace</button>
1758
+ </form>
1759
+ <p style="color:#6b7280;font-size:12px;margin-top:8px">Rows marked "from image" are baked into the agent's image and can't be edited here.</p>
1760
+ </div>`;
1761
+
1686
1762
  // PAI-2.5: read-only trace from this agent to its Anthropic identity. Values
1687
1763
  // are user data (owner email) or provider IDs, so every one is escaped.
1688
1764
  const identityPlaceholder = '<span style="color:#9ca3af">—</span>';
@@ -1874,7 +1950,7 @@ export function renderAgentDetailPage(
1874
1950
  const pluginsToolsGroup = renderGroup(
1875
1951
  "Plugins &amp; Tools",
1876
1952
  pluginsToolsStat,
1877
- `${toolsSection}${pluginsSection}`,
1953
+ `${toolsSection}${pluginsSection}${marketplacesSection}`,
1878
1954
  );
1879
1955
 
1880
1956
  const expandCollapseControls = `
@@ -4527,3 +4527,62 @@ describe("renderAgentDetailPage — signed commits toggle", () => {
4527
4527
  expect(checkbox).toContain("checked");
4528
4528
  });
4529
4529
  });
4530
+
4531
+ describe("renderAgentDetailPage — marketplaces section (PMK-1.5)", () => {
4532
+ const render = (
4533
+ marketplaces: Array<{
4534
+ id: string;
4535
+ name: string;
4536
+ source: string;
4537
+ ref: string | null;
4538
+ createdAt: Date;
4539
+ }>,
4540
+ bakedMarketplaces?: string[],
4541
+ ) =>
4542
+ renderAgentDetailPage(
4543
+ { ...AGENT, bakedMarketplaces },
4544
+ {},
4545
+ [],
4546
+ [],
4547
+ [],
4548
+ [],
4549
+ [],
4550
+ USER_NAME,
4551
+ true,
4552
+ { timezone: "UTC", marketplaces },
4553
+ );
4554
+ const remote = {
4555
+ id: "mk1",
4556
+ name: "shipwright",
4557
+ source: "app-vitals/shipwright",
4558
+ ref: "v1",
4559
+ createdAt: new Date("2024-01-01"),
4560
+ };
4561
+
4562
+ test("remote rows carry a remove form; the add form is present", () => {
4563
+ const html = render([remote]);
4564
+ expect(html).toContain("app-vitals/shipwright");
4565
+ expect(html).toContain(`/admin/agents/${AGENT.id}/marketplaces/delete`);
4566
+ expect(html).toContain('name="marketplaceId" value="mk1"');
4567
+ expect(html).toContain(`/admin/agents/${AGENT.id}/marketplaces"`);
4568
+ });
4569
+
4570
+ test("baked rows are labelled from image and have no form or button", () => {
4571
+ const html = render([], ["baked-one"]);
4572
+ const row = html.match(
4573
+ /<tr data-baked-marketplace="baked-one">.*?<\/tr>/s,
4574
+ )?.[0];
4575
+ expect(row).toContain("from image");
4576
+ expect(row).not.toMatch(/<form|<button|<input/);
4577
+ });
4578
+
4579
+ test("shows the empty state with no remote or baked rows", () => {
4580
+ expect(render([])).toContain("No marketplaces.");
4581
+ });
4582
+
4583
+ test("escapes marketplace values", () => {
4584
+ const html = render([{ ...remote, name: "<b>x</b>" }]);
4585
+ expect(html).not.toContain("<b>x</b>");
4586
+ expect(html).toContain("&lt;b&gt;x&lt;/b&gt;");
4587
+ });
4588
+ });
@@ -34,6 +34,7 @@ import {
34
34
  SPECIALIST_TYPE,
35
35
  SpecialtyTypes,
36
36
  } from "./specialty-types.ts";
37
+ import { UnprocessableEntityError } from "./errors.ts";
37
38
 
38
39
  // The specialty the create-agent flows use: the configured default type (the
39
40
  // platform test run points it at the fixture specialty; the monorepo falls
@@ -3717,6 +3718,128 @@ describe("admin UI — member management routes", () => {
3717
3718
  });
3718
3719
  });
3719
3720
 
3721
+ describe("admin UI — marketplaces (PMK-1.5)", () => {
3722
+ const MEMBER = "member@example.com";
3723
+ const form = (fields: Record<string, string>, cookie: string) => ({
3724
+ method: "POST",
3725
+ body: new URLSearchParams(fields).toString(),
3726
+ headers: {
3727
+ "Content-Type": "application/x-www-form-urlencoded",
3728
+ Cookie: `admin_session=${cookie}`,
3729
+ },
3730
+ });
3731
+ const memberDeps = () => ({
3732
+ agentMemberService: {
3733
+ listByEmail: async (email: string) =>
3734
+ email === MEMBER
3735
+ ? [{ id: "m1", agentId: AGENT_ID, email, createdAt: new Date() }]
3736
+ : [],
3737
+ exists: async (_agentId: string, email: string) => email === MEMBER,
3738
+ add: async () => ({
3739
+ id: "m1",
3740
+ agentId: AGENT_ID,
3741
+ email: MEMBER,
3742
+ createdAt: new Date(),
3743
+ }),
3744
+ remove: async () => {},
3745
+ listByAgentId: async () => [],
3746
+ },
3747
+ });
3748
+
3749
+ it("admin add upserts the row with the trimmed ref and redirects", async () => {
3750
+ const calls: unknown[] = [];
3751
+ const app = createAdminUIApp(
3752
+ makeMockDeps({
3753
+ agentMarketplaceService: {
3754
+ list: async () => [],
3755
+ upsert: async (_id: string, input: unknown) => {
3756
+ calls.push(input);
3757
+ return {} as never;
3758
+ },
3759
+ remove: async () => {},
3760
+ },
3761
+ }),
3762
+ );
3763
+ const res = await app.request(
3764
+ `/admin/agents/${AGENT_ID}/marketplaces`,
3765
+ form(
3766
+ { name: "shipwright", source: "app-vitals/shipwright", ref: " v1 " },
3767
+ await makeSessionCookie(),
3768
+ ),
3769
+ );
3770
+ expect(res.status).toBe(302);
3771
+ expect(calls).toEqual([
3772
+ { name: "shipwright", source: "app-vitals/shipwright", ref: "v1" },
3773
+ ]);
3774
+ });
3775
+
3776
+ it("add with an invalid source redirects with the validation message", async () => {
3777
+ const app = createAdminUIApp(
3778
+ makeMockDeps({
3779
+ agentMarketplaceService: {
3780
+ list: async () => [],
3781
+ upsert: async () => {
3782
+ throw new UnprocessableEntityError("source must be bad");
3783
+ },
3784
+ remove: async () => {},
3785
+ },
3786
+ }),
3787
+ );
3788
+ const res = await app.request(
3789
+ `/admin/agents/${AGENT_ID}/marketplaces`,
3790
+ form({ name: "x", source: "/etc" }, await makeSessionCookie()),
3791
+ );
3792
+ expect(res.status).toBe(302);
3793
+ expect(res.headers.get("location")).toContain("error=source%20must%20be");
3794
+ });
3795
+
3796
+ it("an agent member can add and remove; a non-member gets 403", async () => {
3797
+ let removed: string | null = null;
3798
+ const service = {
3799
+ list: async () => [],
3800
+ upsert: async () => ({}) as never,
3801
+ remove: async (_id: string, mid: string) => {
3802
+ removed = mid;
3803
+ },
3804
+ };
3805
+ const memberCookie = await makeSessionCookie(
3806
+ SESSION_SECRET,
3807
+ "google-sub-member",
3808
+ MEMBER,
3809
+ false,
3810
+ );
3811
+ const outsiderCookie = await makeSessionCookie(
3812
+ SESSION_SECRET,
3813
+ "google-sub-outsider",
3814
+ "outsider@example.com",
3815
+ false,
3816
+ );
3817
+ const app = createAdminUIApp(
3818
+ makeMockDeps({ ...memberDeps(), agentMarketplaceService: service }),
3819
+ );
3820
+ const add = await app.request(
3821
+ `/admin/agents/${AGENT_ID}/marketplaces`,
3822
+ form({ name: "x", source: "a/b" }, memberCookie),
3823
+ );
3824
+ const del = await app.request(
3825
+ `/admin/agents/${AGENT_ID}/marketplaces/delete`,
3826
+ form({ marketplaceId: "mk1" }, memberCookie),
3827
+ );
3828
+ expect([add.status, del.status]).toEqual([302, 302]);
3829
+ expect(removed as string | null).toBe("mk1");
3830
+ for (const path of ["marketplaces", "marketplaces/delete"]) {
3831
+ const res = await app.request(
3832
+ `/admin/agents/${AGENT_ID}/${path}`,
3833
+ form(
3834
+ { name: "x", source: "a/b", marketplaceId: "mk1" },
3835
+ outsiderCookie,
3836
+ ),
3837
+ );
3838
+ expect(res.status).toBe(403);
3839
+ }
3840
+ });
3841
+ });
3842
+
3720
3843
  describe("admin UI — manifest sync route", () => {
3721
3844
  let adminCookie: string;
3722
3845
 
package/src/admin-ui.ts CHANGED
@@ -70,6 +70,7 @@ import { deleteAgentFully } from "./agent-deletion.ts";
70
70
  import type { ManualStep } from "./agent-deletion-checklist.ts";
71
71
  import type { AgentEnvService } from "./agent-envs.ts";
72
72
  import type { AgentMemberService } from "./agent-members.ts";
73
+ import type { AgentMarketplaceService } from "./agent-marketplaces.ts";
73
74
  import type { AgentPluginService } from "./agent-plugins.ts";
74
75
  import type { AgentProvisioner } from "./agent-provisioner.ts";
75
76
  import type { AgentTokenService } from "./agent-tokens.ts";
@@ -304,6 +305,15 @@ export interface AdminUIDeps {
304
305
  "listForAgent" | "create" | "revoke"
305
306
  >;
306
307
  agentPluginService: Pick<AgentPluginService, "list" | "add">;
308
+ /**
309
+ * PMK-1.5: remote marketplaces for the agent page. Optional (additive to the
310
+ * exported AdminUIDeps); absent hides the add/remove routes' effect and
311
+ * lists no remote rows.
312
+ */
313
+ agentMarketplaceService?: Pick<
314
+ AgentMarketplaceService,
315
+ "list" | "upsert" | "remove"
316
+ >;
307
317
  agentMemberService: Pick<
308
318
  AgentMemberService,
309
319
  "listByEmail" | "exists" | "add" | "remove" | "listByAgentId"
@@ -776,6 +786,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
776
786
  agentToolService,
777
787
  agentTokenService,
778
788
  agentPluginService,
789
+ agentMarketplaceService,
779
790
  agentMemberService,
780
791
  agentService,
781
792
  provisioner,
@@ -1753,6 +1764,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1753
1764
  anthropicIdentityName: agent.anthropicIdentityName ?? null,
1754
1765
  anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
1755
1766
  anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
1767
+ bakedMarketplaces: agent.bakedMarketplaces ?? [],
1756
1768
  };
1757
1769
 
1758
1770
  if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
@@ -1778,7 +1790,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1778
1790
  // verbatim rather than mapped to a fixed key.
1779
1791
  (warningParam ?? undefined);
1780
1792
 
1781
- const [envResult, crons, tools, tokens, plugins, members] =
1793
+ const [envResult, crons, tools, tokens, plugins, members, marketplaces] =
1782
1794
  await Promise.all([
1783
1795
  agentEnvService
1784
1796
  .getByAgentId(agentId)
@@ -1790,6 +1802,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1790
1802
  canManageAgents(c.var.isAdmin)
1791
1803
  ? agentMemberService.listByAgentId(agentId)
1792
1804
  : Promise.resolve([]),
1805
+ agentMarketplaceService?.list(agentId) ?? Promise.resolve([]),
1793
1806
  ]);
1794
1807
 
1795
1808
  // Extension card slots (AEP-2.1/AEP-3.2), grouped by placement; a failing
@@ -1826,6 +1839,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1826
1839
  extensionCardsHtml: cards.header,
1827
1840
  configurationCardsHtml: cards.configuration,
1828
1841
  queueActivityLinks: deps.queueActivityLinks,
1842
+ marketplaces,
1829
1843
  },
1830
1844
  ),
1831
1845
  );
@@ -2401,6 +2415,65 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
2401
2415
  return c.redirect(`/admin/agents/${agentId}`, 302);
2402
2416
  });
2403
2417
 
2418
+ // ─── Marketplace mutations (PMK-1.5) ──────────────────────────────────────
2419
+ // Same access rule as the tool routes (and the /agents/:id/marketplaces API):
2420
+ // an admin or a member of the agent. The baked list has no route here.
2421
+
2422
+ app.post("/admin/agents/:id/marketplaces", requireAuth, async (c) => {
2423
+ const agentId = c.req.param("id");
2424
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2425
+ return new Response("Forbidden", { status: 403 });
2426
+ }
2427
+ let name: string | undefined;
2428
+ let source: string | undefined;
2429
+ let ref: string | undefined;
2430
+ try {
2431
+ const formData = await c.req.formData();
2432
+ name = formData.get("name")?.toString().trim();
2433
+ source = formData.get("source")?.toString().trim();
2434
+ ref = formData.get("ref")?.toString().trim() || undefined;
2435
+ } catch {
2436
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2437
+ }
2438
+ if (!name || !source) {
2439
+ return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
2440
+ }
2441
+ try {
2442
+ await agentMarketplaceService?.upsert(agentId, { name, source, ref });
2443
+ } catch (err) {
2444
+ if (err instanceof UnprocessableEntityError) {
2445
+ return c.redirect(
2446
+ `/admin/agents/${agentId}?error=${encodeURIComponent(err.message)}`,
2447
+ 302,
2448
+ );
2449
+ }
2450
+ // other errors — redirect back silently, like the tool routes
2451
+ }
2452
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2453
+ });
2454
+
2455
+ app.post("/admin/agents/:id/marketplaces/delete", requireAuth, async (c) => {
2456
+ const agentId = c.req.param("id");
2457
+ if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
2458
+ return new Response("Forbidden", { status: 403 });
2459
+ }
2460
+ let marketplaceId: string | undefined;
2461
+ try {
2462
+ const formData = await c.req.formData();
2463
+ marketplaceId = formData.get("marketplaceId")?.toString();
2464
+ } catch {
2465
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2466
+ }
2467
+ if (marketplaceId) {
2468
+ try {
2469
+ await agentMarketplaceService?.remove(agentId, marketplaceId);
2470
+ } catch {
2471
+ // already gone, ignore
2472
+ }
2473
+ }
2474
+ return c.redirect(`/admin/agents/${agentId}`, 302);
2475
+ });
2476
+
2404
2477
  // ─── Token mutations ──────────────────────────────────────────────────────
2405
2478
 
2406
2479
  app.post("/admin/agents/:id/tokens", requireAuth, async (c) => {
@@ -2442,12 +2515,13 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
2442
2515
  anthropicIdentityName: agent.anthropicIdentityName ?? null,
2443
2516
  anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
2444
2517
  anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
2518
+ bakedMarketplaces: agent.bakedMarketplaces ?? [],
2445
2519
  };
2446
2520
  try {
2447
2521
  const { rawToken } = await agentTokenService.create(agentId, label);
2448
2522
  // Render the page directly (200) rather than redirecting with the token in the URL.
2449
2523
  // A redirect would expose the raw token in server access logs and browser history.
2450
- const [envResult, crons, tools, tokens, plugins, members] =
2524
+ const [envResult, crons, tools, tokens, plugins, members, marketplaces] =
2451
2525
  await Promise.all([
2452
2526
  agentEnvService
2453
2527
  .getByAgentId(agentId)
@@ -2459,6 +2533,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
2459
2533
  canManageAgents(c.var.isAdmin)
2460
2534
  ? agentMemberService.listByAgentId(agentId)
2461
2535
  : Promise.resolve([]),
2536
+ agentMarketplaceService?.list(agentId) ?? Promise.resolve([]),
2462
2537
  ]);
2463
2538
  // The token page renders only the Configuration-group extension cards;
2464
2539
  // header cards keep their place empty, as before the slot split.
@@ -2491,6 +2566,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
2491
2566
  extensionCardsHtml: cards.header,
2492
2567
  configurationCardsHtml: cards.configuration,
2493
2568
  queueActivityLinks: deps.queueActivityLinks,
2569
+ marketplaces,
2494
2570
  },
2495
2571
  ),
2496
2572
  );
@@ -111,8 +111,12 @@ describeOrSkip("baked marketplaces store (integration)", () => {
111
111
  )
112
112
  .sort();
113
113
  // agents.ts: writer + read select; agents-api.ts: report route + read
114
- // serialization; openapi-schemas.ts: response/read schemas only.
114
+ // serialization; openapi-schemas.ts: response/read schemas only;
115
+ // admin-ui.ts / admin-ui-pages.ts: read-only display on the agent page
116
+ // (PMK-1.5), no write path.
115
117
  expect(writers).toEqual([
118
+ "admin-ui-pages.ts",
119
+ "admin-ui.ts",
116
120
  "agents-api.ts",
117
121
  "agents.ts",
118
122
  "openapi-schemas.ts",