@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.
- package/e2e/marketplaces-section.e2e.ts +158 -0
- package/e2e/test-server.ts +53 -0
- package/package.json +2 -2
- package/src/admin-boot.ts +1 -0
- package/src/admin-ui-pages.ts +77 -1
- package/src/admin-ui-pages.unit.test.ts +59 -0
- package/src/admin-ui.smoke.test.ts +123 -0
- package/src/admin-ui.ts +78 -2
- package/src/agents.baked-marketplaces.integration.test.ts +5 -1
|
@@ -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
|
+
});
|
package/e2e/test-server.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
package/src/admin-ui-pages.ts
CHANGED
|
@@ -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 & 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("<b>x</b>");
|
|
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",
|