@app-vitals/agent-admin 1.4.0 → 1.5.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/openapi.json +254 -0
- package/openapi.platform.json +254 -0
- package/package.json +2 -2
- package/src/admin-boot.ts +5 -1
- package/src/agent-marketplaces-api.integration.test.ts +263 -0
- package/src/agent-marketplaces.ts +120 -0
- package/src/agent-marketplaces.unit.test.ts +72 -0
- package/src/agents-api.ts +117 -1
- package/src/api.ts +29 -5
- package/src/openapi-schemas.ts +56 -0
- package/src/openapi-schemas.unit.test.ts +29 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/agent-marketplaces-api.integration.test.ts
|
|
3
|
+
* Integration tests for AgentMarketplaceService, the /agents/{id}/marketplaces
|
|
4
|
+
* routes and the `marketplaces` field of GET /agents/{id}/config, against a
|
|
5
|
+
* real PostgreSQL DB.
|
|
6
|
+
*
|
|
7
|
+
* Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
11
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
12
|
+
import { AgentMarketplaceService } from "./agent-marketplaces.ts";
|
|
13
|
+
import { AgentTokenService } from "./agent-tokens.ts";
|
|
14
|
+
import { AgentService } from "./agents.ts";
|
|
15
|
+
import {
|
|
16
|
+
type AdminDeps,
|
|
17
|
+
createAdminApp,
|
|
18
|
+
parseAdminApiKeys,
|
|
19
|
+
} from "./agents-api.ts";
|
|
20
|
+
import { createAgentRuntimeApp } from "./api.ts";
|
|
21
|
+
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
22
|
+
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
23
|
+
|
|
24
|
+
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
25
|
+
const describeOrSkip = TEST_DB ? describe : describe.skip;
|
|
26
|
+
|
|
27
|
+
const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
|
|
28
|
+
const ADMIN_KEY = "admin-key-token";
|
|
29
|
+
|
|
30
|
+
describeOrSkip("AgentMarketplaceService + routes (integration)", () => {
|
|
31
|
+
let prisma: PrismaClient;
|
|
32
|
+
let service: AgentMarketplaceService;
|
|
33
|
+
let tokens: AgentTokenService;
|
|
34
|
+
let adminApp: ReturnType<typeof createAdminApp>;
|
|
35
|
+
let runtimeApp: ReturnType<typeof createAgentRuntimeApp>;
|
|
36
|
+
let agentA: string;
|
|
37
|
+
let agentB: string;
|
|
38
|
+
let tokenA: string;
|
|
39
|
+
let tokenB: string;
|
|
40
|
+
|
|
41
|
+
const adminKeys = parseAdminApiKeys(`root:${ADMIN_KEY}:*`);
|
|
42
|
+
const json = (token: string, body?: unknown, method = "POST") => ({
|
|
43
|
+
method,
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${token}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
49
|
+
});
|
|
50
|
+
const get = (token: string) => ({
|
|
51
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
prisma = createAdminPrismaClient(TEST_DB as string);
|
|
56
|
+
await prisma.agentMarketplace.deleteMany();
|
|
57
|
+
await prisma.agentPlugin.deleteMany();
|
|
58
|
+
await prisma.agentToken.deleteMany();
|
|
59
|
+
await prisma.agentCronJob.deleteMany();
|
|
60
|
+
await prisma.agentTool.deleteMany();
|
|
61
|
+
await prisma.agentEnv.deleteMany();
|
|
62
|
+
await prisma.agentMember.deleteMany();
|
|
63
|
+
await prisma.agent.deleteMany();
|
|
64
|
+
|
|
65
|
+
agentA = (await prisma.agent.create({ data: { name: "A" } })).id;
|
|
66
|
+
agentB = (await prisma.agent.create({ data: { name: "B" } })).id;
|
|
67
|
+
service = new AgentMarketplaceService(prisma);
|
|
68
|
+
tokens = new AgentTokenService(prisma);
|
|
69
|
+
tokenA = (await tokens.create(agentA)).rawToken;
|
|
70
|
+
tokenB = (await tokens.create(agentB)).rawToken;
|
|
71
|
+
|
|
72
|
+
adminApp = createAdminApp({
|
|
73
|
+
agentTokenService: tokens,
|
|
74
|
+
agentMemberService: { exists: async () => false },
|
|
75
|
+
agentMarketplaceService: service,
|
|
76
|
+
prisma,
|
|
77
|
+
sessionSecret: SESSION_SECRET,
|
|
78
|
+
adminApiKeys: adminKeys,
|
|
79
|
+
} as unknown as AdminDeps);
|
|
80
|
+
|
|
81
|
+
runtimeApp = createAgentRuntimeApp({
|
|
82
|
+
agentEnvService: { getConfigBundle: async () => null },
|
|
83
|
+
agentCronJobService: { list: async () => [] },
|
|
84
|
+
agentService: new AgentService(prisma),
|
|
85
|
+
agentPluginService: { listEnabled: async () => [] },
|
|
86
|
+
agentMarketplaceService: service,
|
|
87
|
+
sessionSecret: SESSION_SECRET,
|
|
88
|
+
adminApiKeys: adminKeys,
|
|
89
|
+
agentTokenService: tokens,
|
|
90
|
+
} as never);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
await prisma.$disconnect();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("service", () => {
|
|
98
|
+
it("upserts, lists in creation order, and re-points an existing name", async () => {
|
|
99
|
+
const first = await service.upsert(agentA, {
|
|
100
|
+
name: "shipwright",
|
|
101
|
+
source: "app-vitals/shipwright",
|
|
102
|
+
});
|
|
103
|
+
expect(first.ref).toBeNull();
|
|
104
|
+
await service.upsert(agentA, {
|
|
105
|
+
name: "other",
|
|
106
|
+
source: "https://github.com/o/r.git",
|
|
107
|
+
ref: "v1",
|
|
108
|
+
});
|
|
109
|
+
const again = await service.upsert(agentA, {
|
|
110
|
+
name: "shipwright",
|
|
111
|
+
source: "app-vitals/shipwright-fork",
|
|
112
|
+
ref: "main",
|
|
113
|
+
});
|
|
114
|
+
expect(again.id).toBe(first.id);
|
|
115
|
+
|
|
116
|
+
const rows = await service.list(agentA);
|
|
117
|
+
expect(rows.map((r) => r.name)).toEqual(["shipwright", "other"]);
|
|
118
|
+
expect(rows[0]?.source).toBe("app-vitals/shipwright-fork");
|
|
119
|
+
expect(rows[0]?.ref).toBe("main");
|
|
120
|
+
expect(await service.list(agentB)).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("same name on two agents are independent rows", async () => {
|
|
124
|
+
await service.upsert(agentA, { name: "m", source: "o/a" });
|
|
125
|
+
await service.upsert(agentB, { name: "m", source: "o/b" });
|
|
126
|
+
expect((await service.list(agentA))[0]?.source).toBe("o/a");
|
|
127
|
+
expect((await service.list(agentB))[0]?.source).toBe("o/b");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects an invalid source without writing", async () => {
|
|
131
|
+
await expect(
|
|
132
|
+
service.upsert(agentA, { name: "m", source: "http://x.com/o/r" }),
|
|
133
|
+
).rejects.toBeInstanceOf(UnprocessableEntityError);
|
|
134
|
+
expect(await service.list(agentA)).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("remove deletes an owned row and 404s on another agent's row", async () => {
|
|
138
|
+
const row = await service.upsert(agentA, { name: "m", source: "o/r" });
|
|
139
|
+
await expect(service.remove(agentB, row.id)).rejects.toBeInstanceOf(
|
|
140
|
+
NotFoundError,
|
|
141
|
+
);
|
|
142
|
+
expect(await service.list(agentA)).toHaveLength(1);
|
|
143
|
+
await service.remove(agentA, row.id);
|
|
144
|
+
expect(await service.list(agentA)).toEqual([]);
|
|
145
|
+
await expect(service.remove(agentA, row.id)).rejects.toBeInstanceOf(
|
|
146
|
+
NotFoundError,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("routes", () => {
|
|
152
|
+
it("POST upserts (201), GET lists, DELETE removes (204)", async () => {
|
|
153
|
+
const created = await adminApp.request(
|
|
154
|
+
`/agents/${agentA}/marketplaces`,
|
|
155
|
+
json(tokenA, {
|
|
156
|
+
name: "shipwright",
|
|
157
|
+
source: "app-vitals/shipwright",
|
|
158
|
+
ref: "main",
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
expect(created.status).toBe(201);
|
|
162
|
+
const { marketplace } = await created.json();
|
|
163
|
+
expect(marketplace).toMatchObject({
|
|
164
|
+
agentId: agentA,
|
|
165
|
+
name: "shipwright",
|
|
166
|
+
source: "app-vitals/shipwright",
|
|
167
|
+
ref: "main",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const listed = await adminApp.request(
|
|
171
|
+
`/agents/${agentA}/marketplaces`,
|
|
172
|
+
get(ADMIN_KEY),
|
|
173
|
+
);
|
|
174
|
+
expect(listed.status).toBe(200);
|
|
175
|
+
expect((await listed.json()).marketplaces).toHaveLength(1);
|
|
176
|
+
|
|
177
|
+
const del = await adminApp.request(
|
|
178
|
+
`/agents/${agentA}/marketplaces/${marketplace.id}`,
|
|
179
|
+
json(tokenA, undefined, "DELETE"),
|
|
180
|
+
);
|
|
181
|
+
expect(del.status).toBe(204);
|
|
182
|
+
expect(await service.list(agentA)).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("platform admin key can write and read any agent", async () => {
|
|
186
|
+
const res = await adminApp.request(
|
|
187
|
+
`/agents/${agentB}/marketplaces`,
|
|
188
|
+
json(ADMIN_KEY, { name: "m", source: "o/r" }),
|
|
189
|
+
);
|
|
190
|
+
expect(res.status).toBe(201);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("invalid source returns 422 and writes nothing", async () => {
|
|
194
|
+
for (const source of [
|
|
195
|
+
"http://x.com/o/r",
|
|
196
|
+
"not-a-source",
|
|
197
|
+
"git@github.com:o/r.git",
|
|
198
|
+
"",
|
|
199
|
+
]) {
|
|
200
|
+
const res = await adminApp.request(
|
|
201
|
+
`/agents/${agentA}/marketplaces`,
|
|
202
|
+
json(tokenA, { name: "m", source }),
|
|
203
|
+
);
|
|
204
|
+
expect(res.status).toBe(422);
|
|
205
|
+
}
|
|
206
|
+
expect(await service.list(agentA)).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("deleting another agent's row returns 404 and leaves it", async () => {
|
|
210
|
+
const row = await service.upsert(agentB, { name: "m", source: "o/r" });
|
|
211
|
+
const res = await adminApp.request(
|
|
212
|
+
`/agents/${agentA}/marketplaces/${row.id}`,
|
|
213
|
+
json(tokenA, undefined, "DELETE"),
|
|
214
|
+
);
|
|
215
|
+
expect(res.status).toBe(404);
|
|
216
|
+
expect(await service.list(agentB)).toHaveLength(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("another agent's key is 403 on every method; no credential is 401", async () => {
|
|
220
|
+
const row = await service.upsert(agentA, { name: "m", source: "o/r" });
|
|
221
|
+
const post = await adminApp.request(
|
|
222
|
+
`/agents/${agentA}/marketplaces`,
|
|
223
|
+
json(tokenB, { name: "x", source: "o/r" }),
|
|
224
|
+
);
|
|
225
|
+
const list = await adminApp.request(
|
|
226
|
+
`/agents/${agentA}/marketplaces`,
|
|
227
|
+
get(tokenB),
|
|
228
|
+
);
|
|
229
|
+
const del = await adminApp.request(
|
|
230
|
+
`/agents/${agentA}/marketplaces/${row.id}`,
|
|
231
|
+
json(tokenB, undefined, "DELETE"),
|
|
232
|
+
);
|
|
233
|
+
expect([post.status, list.status, del.status]).toEqual([403, 403, 403]);
|
|
234
|
+
expect(
|
|
235
|
+
(await adminApp.request(`/agents/${agentA}/marketplaces`)).status,
|
|
236
|
+
).toBe(401);
|
|
237
|
+
expect(await service.list(agentA)).toHaveLength(1);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("GET /agents/:id/config", () => {
|
|
242
|
+
it("includes marketplaces: [] when none", async () => {
|
|
243
|
+
const res = await runtimeApp.request(`/${agentA}/config`, get(tokenA));
|
|
244
|
+
expect(res.status).toBe(200);
|
|
245
|
+
expect((await res.json()).marketplaces).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("includes name/source/ref for the agent's own rows only", async () => {
|
|
249
|
+
await service.upsert(agentA, { name: "one", source: "o/r", ref: "v1" });
|
|
250
|
+
await service.upsert(agentA, {
|
|
251
|
+
name: "two",
|
|
252
|
+
source: "https://h.example/o/r",
|
|
253
|
+
});
|
|
254
|
+
await service.upsert(agentB, { name: "theirs", source: "o/x" });
|
|
255
|
+
const res = await runtimeApp.request(`/${agentA}/config`, get(tokenA));
|
|
256
|
+
expect(res.status).toBe(200);
|
|
257
|
+
expect((await res.json()).marketplaces).toEqual([
|
|
258
|
+
{ name: "one", source: "o/r", ref: "v1" },
|
|
259
|
+
{ name: "two", source: "https://h.example/o/r", ref: null },
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/agent-marketplaces.ts
|
|
3
|
+
* AgentMarketplaceService — CRUD for remote Claude Code plugin marketplaces
|
|
4
|
+
* per agent.
|
|
5
|
+
*
|
|
6
|
+
* A marketplace is identified by `name` (the name plugins reference after the
|
|
7
|
+
* "@" in "<plugin>@<marketplace>") and points at a `source`: a GitHub
|
|
8
|
+
* "owner/repo" or an https git URL, optionally pinned to a `ref`. The unique
|
|
9
|
+
* constraint on [agentId, name] makes two versions of one marketplace
|
|
10
|
+
* impossible; upsert() re-points an existing name.
|
|
11
|
+
*
|
|
12
|
+
* Input is validated here (422) rather than in the Zod request schema, which
|
|
13
|
+
* would answer 400: `source` and `ref` end up as arguments to `git`/`claude`
|
|
14
|
+
* in the runtime, so anything that could be read as an option, a local path,
|
|
15
|
+
* or a credential-bearing URL is refused.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AgentMarketplace,
|
|
20
|
+
PrismaClient,
|
|
21
|
+
} from "../prisma/client/client.ts";
|
|
22
|
+
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
23
|
+
|
|
24
|
+
export type { AgentMarketplace };
|
|
25
|
+
|
|
26
|
+
const GITHUB_SHORTHAND =
|
|
27
|
+
/^[A-Za-z0-9_][A-Za-z0-9_.-]*\/[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
28
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
29
|
+
const REF = /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/;
|
|
30
|
+
const HOST = /^[A-Za-z0-9][A-Za-z0-9.-]*$/;
|
|
31
|
+
|
|
32
|
+
/** Throws UnprocessableEntityError unless `source` is `owner/repo` or an https git URL. */
|
|
33
|
+
export function validateMarketplaceSource(source: string): void {
|
|
34
|
+
if (GITHUB_SHORTHAND.test(source)) return;
|
|
35
|
+
if (source.startsWith("https://") && !/\s/.test(source)) {
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(source);
|
|
38
|
+
if (
|
|
39
|
+
url.protocol === "https:" &&
|
|
40
|
+
HOST.test(url.hostname) &&
|
|
41
|
+
url.username === "" &&
|
|
42
|
+
url.password === "" &&
|
|
43
|
+
url.pathname.length > 1
|
|
44
|
+
) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// fall through to the 422 below
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw new UnprocessableEntityError(
|
|
52
|
+
"source must be a GitHub 'owner/repo' or an https git URL without credentials",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Throws UnprocessableEntityError unless `name` is a plain marketplace name. */
|
|
57
|
+
export function validateMarketplaceName(name: string): void {
|
|
58
|
+
if (!NAME.test(name)) {
|
|
59
|
+
throw new UnprocessableEntityError(
|
|
60
|
+
"name must start with a letter or digit and contain only letters, digits, '.', '_' and '-'",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Throws UnprocessableEntityError for a ref that is not a plain branch, tag or sha. */
|
|
66
|
+
export function validateMarketplaceRef(ref: string | null | undefined): void {
|
|
67
|
+
if (ref === undefined || ref === null) return;
|
|
68
|
+
if (!REF.test(ref) || ref.includes("..")) {
|
|
69
|
+
throw new UnprocessableEntityError(
|
|
70
|
+
"ref must be a plain branch, tag or sha",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class AgentMarketplaceService {
|
|
76
|
+
constructor(private prisma: PrismaClient) {}
|
|
77
|
+
|
|
78
|
+
/** List all marketplaces for a given agent, ordered by createdAt. */
|
|
79
|
+
async list(agentId: string): Promise<AgentMarketplace[]> {
|
|
80
|
+
return this.prisma.agentMarketplace.findMany({
|
|
81
|
+
where: { agentId },
|
|
82
|
+
orderBy: { createdAt: "asc" },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Add a marketplace for the agent, or re-point the existing one with the
|
|
88
|
+
* same name at a new source/ref. Throws UnprocessableEntityError on invalid
|
|
89
|
+
* input.
|
|
90
|
+
*/
|
|
91
|
+
async upsert(
|
|
92
|
+
agentId: string,
|
|
93
|
+
input: { name: string; source: string; ref?: string | null },
|
|
94
|
+
): Promise<AgentMarketplace> {
|
|
95
|
+
const { name, source } = input;
|
|
96
|
+
const ref = input.ref ?? null;
|
|
97
|
+
validateMarketplaceName(name);
|
|
98
|
+
validateMarketplaceSource(source);
|
|
99
|
+
validateMarketplaceRef(ref);
|
|
100
|
+
return this.prisma.agentMarketplace.upsert({
|
|
101
|
+
where: { agentId_name: { agentId, name } },
|
|
102
|
+
create: { agentId, name, source, ref },
|
|
103
|
+
update: { source, ref },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remove a marketplace by ID. Verifies agentId ownership before deleting.
|
|
109
|
+
* Throws NotFoundError if the id doesn't exist or belongs to another agent.
|
|
110
|
+
*/
|
|
111
|
+
async remove(agentId: string, marketplaceId: string): Promise<void> {
|
|
112
|
+
const existing = await this.prisma.agentMarketplace.findUnique({
|
|
113
|
+
where: { id: marketplaceId },
|
|
114
|
+
});
|
|
115
|
+
if (!existing || existing.agentId !== agentId) {
|
|
116
|
+
throw new NotFoundError(`marketplace ${marketplaceId} not found`);
|
|
117
|
+
}
|
|
118
|
+
await this.prisma.agentMarketplace.delete({ where: { id: marketplaceId } });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/agent-marketplaces.unit.test.ts
|
|
3
|
+
* Unit tests for marketplace input validation (source, name, ref).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
validateMarketplaceName,
|
|
9
|
+
validateMarketplaceRef,
|
|
10
|
+
validateMarketplaceSource,
|
|
11
|
+
} from "./agent-marketplaces.ts";
|
|
12
|
+
import { UnprocessableEntityError } from "./errors.ts";
|
|
13
|
+
|
|
14
|
+
describe("validateMarketplaceSource", () => {
|
|
15
|
+
it.each([
|
|
16
|
+
"app-vitals/shipwright",
|
|
17
|
+
"owner/repo.name",
|
|
18
|
+
"Org_1/repo-2",
|
|
19
|
+
"https://github.com/app-vitals/shipwright.git",
|
|
20
|
+
"https://gitlab.example.com/group/sub/repo",
|
|
21
|
+
])("accepts %s", (source) => {
|
|
22
|
+
expect(() => validateMarketplaceSource(source)).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it.each([
|
|
26
|
+
"",
|
|
27
|
+
" ",
|
|
28
|
+
"repo",
|
|
29
|
+
"owner/",
|
|
30
|
+
"/repo",
|
|
31
|
+
"owner/repo/extra",
|
|
32
|
+
"owner/re po",
|
|
33
|
+
"http://github.com/o/r",
|
|
34
|
+
"git@github.com:o/r.git",
|
|
35
|
+
"ssh://git@github.com/o/r",
|
|
36
|
+
"file:///etc/passwd",
|
|
37
|
+
"../etc",
|
|
38
|
+
"./local/path",
|
|
39
|
+
"-owner/repo",
|
|
40
|
+
"https://",
|
|
41
|
+
"https://user:pass@github.com/o/r",
|
|
42
|
+
"https://github.com/o/r with space",
|
|
43
|
+
"https://-evil.com/x",
|
|
44
|
+
])("rejects %p with a 422", (source) => {
|
|
45
|
+
expect(() => validateMarketplaceSource(source)).toThrow(
|
|
46
|
+
UnprocessableEntityError,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("validateMarketplaceName", () => {
|
|
52
|
+
it("accepts simple names", () => {
|
|
53
|
+
expect(() => validateMarketplaceName("shipwright")).not.toThrow();
|
|
54
|
+
expect(() => validateMarketplaceName("my.market_place-2")).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
it.each(["", "a b", "a@b", "a/b", "-x"])("rejects %p", (name) => {
|
|
57
|
+
expect(() => validateMarketplaceName(name)).toThrow(
|
|
58
|
+
UnprocessableEntityError,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("validateMarketplaceRef", () => {
|
|
64
|
+
it("accepts branches, tags, shas and absent refs", () => {
|
|
65
|
+
for (const ref of [undefined, null, "main", "v1.2.3", "feat/x", "abc123"]) {
|
|
66
|
+
expect(() => validateMarketplaceRef(ref)).not.toThrow();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it.each(["", "-x", "a b", "a..b", "x;rm"])("rejects %p", (ref) => {
|
|
70
|
+
expect(() => validateMarketplaceRef(ref)).toThrow(UnprocessableEntityError);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/agents-api.ts
CHANGED
|
@@ -41,8 +41,8 @@ import {
|
|
|
41
41
|
} from "./account-agent-create.ts";
|
|
42
42
|
import {
|
|
43
43
|
reconcileAgentAfterAccountChange,
|
|
44
|
-
reconcileAgentAfterSpecialtyChange,
|
|
45
44
|
reconcileAgentAfterPodServiceAccountChange,
|
|
45
|
+
reconcileAgentAfterSpecialtyChange,
|
|
46
46
|
} from "./agent-account-assignment.ts";
|
|
47
47
|
import {
|
|
48
48
|
type AgentAskDeps,
|
|
@@ -60,6 +60,7 @@ import type { DeleteAgentFullyDeps } from "./agent-deletion.ts";
|
|
|
60
60
|
import { deleteAgentFully } from "./agent-deletion.ts";
|
|
61
61
|
import type { AgentEnvService } from "./agent-envs.ts";
|
|
62
62
|
import type { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
|
|
63
|
+
import { AgentMarketplaceService } from "./agent-marketplaces.ts";
|
|
63
64
|
import type { AgentMemberService } from "./agent-members.ts";
|
|
64
65
|
import type { AgentPluginService } from "./agent-plugins.ts";
|
|
65
66
|
import {
|
|
@@ -116,6 +117,7 @@ import {
|
|
|
116
117
|
AgentEnvResponseSchema,
|
|
117
118
|
AgentGitHubInstallationsSnapshotSchema,
|
|
118
119
|
AgentIdParamSchema,
|
|
120
|
+
AgentMarketplaceSchema,
|
|
119
121
|
AgentPluginSchema,
|
|
120
122
|
AgentRepoParamSchema,
|
|
121
123
|
AgentSummarySchema,
|
|
@@ -143,6 +145,7 @@ import {
|
|
|
143
145
|
EnvKeyParamSchema,
|
|
144
146
|
ErrorSchema,
|
|
145
147
|
ListCronRunsQuerySchema,
|
|
148
|
+
MarketplaceIdParamSchema,
|
|
146
149
|
type ModelBreakdownEntry,
|
|
147
150
|
OkSchema,
|
|
148
151
|
PatchAgentBodySchema,
|
|
@@ -154,6 +157,7 @@ import {
|
|
|
154
157
|
PutGitHubInstallationsBodySchema,
|
|
155
158
|
TokenIdParamSchema,
|
|
156
159
|
ToolIdParamSchema,
|
|
160
|
+
UpsertAgentMarketplaceBodySchema,
|
|
157
161
|
UpsertChatTokenDailyBodySchema,
|
|
158
162
|
} from "./openapi-schemas.ts";
|
|
159
163
|
import type { SelfServeConfig } from "./self-serve-config.ts";
|
|
@@ -212,6 +216,14 @@ export interface AdminDeps {
|
|
|
212
216
|
AgentPluginService,
|
|
213
217
|
"list" | "add" | "remove" | "removeByName"
|
|
214
218
|
>;
|
|
219
|
+
/**
|
|
220
|
+
* PMK-1.2: remote plugin marketplaces. Optional (additive to the exported
|
|
221
|
+
* AdminDeps): defaults to an AgentMarketplaceService over `prisma`.
|
|
222
|
+
*/
|
|
223
|
+
agentMarketplaceService?: Pick<
|
|
224
|
+
AgentMarketplaceService,
|
|
225
|
+
"list" | "upsert" | "remove"
|
|
226
|
+
>;
|
|
215
227
|
agentMemberService: Pick<
|
|
216
228
|
AgentMemberService,
|
|
217
229
|
"add" | "listByAgentId" | "exists" | "listByEmail"
|
|
@@ -492,6 +504,14 @@ const PluginWrapperSchema = z
|
|
|
492
504
|
.object({ plugin: AgentPluginSchema })
|
|
493
505
|
.openapi("PluginWrapper");
|
|
494
506
|
|
|
507
|
+
const MarketplaceWrapperSchema = z
|
|
508
|
+
.object({ marketplace: AgentMarketplaceSchema })
|
|
509
|
+
.openapi("MarketplaceWrapper");
|
|
510
|
+
|
|
511
|
+
const MarketplacesWrapperSchema = z
|
|
512
|
+
.object({ marketplaces: z.array(AgentMarketplaceSchema) })
|
|
513
|
+
.openapi("MarketplacesWrapper");
|
|
514
|
+
|
|
495
515
|
const PluginsWrapperSchema = z
|
|
496
516
|
.object({ plugins: z.array(AgentPluginSchema) })
|
|
497
517
|
.openapi("PluginsWrapper");
|
|
@@ -1211,6 +1231,58 @@ const deletePluginRoute = createRoute({
|
|
|
1211
1231
|
},
|
|
1212
1232
|
});
|
|
1213
1233
|
|
|
1234
|
+
const listMarketplacesRoute = createRoute({
|
|
1235
|
+
method: "get",
|
|
1236
|
+
path: "/agents/{id}/marketplaces",
|
|
1237
|
+
summary: "List marketplaces",
|
|
1238
|
+
description:
|
|
1239
|
+
"Returns every remote plugin marketplace configured for the agent.",
|
|
1240
|
+
request: { params: AgentIdParamSchema },
|
|
1241
|
+
responses: {
|
|
1242
|
+
200: {
|
|
1243
|
+
description: "List of marketplaces",
|
|
1244
|
+
content: { "application/json": { schema: MarketplacesWrapperSchema } },
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
const upsertMarketplaceRoute = createRoute({
|
|
1250
|
+
method: "post",
|
|
1251
|
+
path: "/agents/{id}/marketplaces",
|
|
1252
|
+
summary: "Add or update a marketplace",
|
|
1253
|
+
description:
|
|
1254
|
+
"Upserts a remote plugin marketplace by `name`. `source` must be a GitHub `owner/repo` or an https git URL (no credentials); `ref` optionally pins a branch, tag or sha. 422 on an invalid `name`, `source` or `ref`.",
|
|
1255
|
+
request: {
|
|
1256
|
+
params: AgentIdParamSchema,
|
|
1257
|
+
body: {
|
|
1258
|
+
content: {
|
|
1259
|
+
"application/json": { schema: UpsertAgentMarketplaceBodySchema },
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1262
|
+
},
|
|
1263
|
+
responses: {
|
|
1264
|
+
201: {
|
|
1265
|
+
description: "Marketplace added or updated",
|
|
1266
|
+
content: { "application/json": { schema: MarketplaceWrapperSchema } },
|
|
1267
|
+
},
|
|
1268
|
+
400: { description: "Bad request", ...jsonError },
|
|
1269
|
+
422: { description: "Invalid name, source or ref", ...jsonError },
|
|
1270
|
+
},
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
const deleteMarketplaceRoute = createRoute({
|
|
1274
|
+
method: "delete",
|
|
1275
|
+
path: "/agents/{id}/marketplaces/{marketplaceId}",
|
|
1276
|
+
summary: "Remove a marketplace",
|
|
1277
|
+
description:
|
|
1278
|
+
"Removes the marketplace row by id. 404 when the id does not exist or belongs to a different agent.",
|
|
1279
|
+
request: { params: MarketplaceIdParamSchema },
|
|
1280
|
+
responses: {
|
|
1281
|
+
204: { description: "Marketplace removed" },
|
|
1282
|
+
404: { description: "Marketplace not found", ...jsonError },
|
|
1283
|
+
},
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1214
1286
|
const upsertChatTokenDailyRoute = createRoute({
|
|
1215
1287
|
method: "post",
|
|
1216
1288
|
path: "/agents/{id}/chat-tokens/daily",
|
|
@@ -2532,6 +2604,34 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
2532
2604
|
return c.body(null, 204);
|
|
2533
2605
|
});
|
|
2534
2606
|
|
|
2607
|
+
// ─── Marketplaces (PMK-1.2) ────────────────────────────────────────────────
|
|
2608
|
+
// Auth is the shared /agents/* middleware: the agent's own key, a platform
|
|
2609
|
+
// admin and authorised session members pass; other agents' keys get 403.
|
|
2610
|
+
// No route here writes the runtime-reported baked-marketplace list (PMK-1.4).
|
|
2611
|
+
|
|
2612
|
+
const agentMarketplaceService =
|
|
2613
|
+
deps.agentMarketplaceService ??
|
|
2614
|
+
new AgentMarketplaceService(prisma as PrismaClient);
|
|
2615
|
+
|
|
2616
|
+
app.openapi(listMarketplacesRoute, async (c) => {
|
|
2617
|
+
const { id: agentId } = c.req.valid("param");
|
|
2618
|
+
const rows = await agentMarketplaceService.list(agentId);
|
|
2619
|
+
return c.json({ marketplaces: rows.map(serializeMarketplace) }, 200);
|
|
2620
|
+
});
|
|
2621
|
+
|
|
2622
|
+
app.openapi(upsertMarketplaceRoute, async (c) => {
|
|
2623
|
+
const { id: agentId } = c.req.valid("param");
|
|
2624
|
+
const body = c.req.valid("json");
|
|
2625
|
+
const row = await agentMarketplaceService.upsert(agentId, body);
|
|
2626
|
+
return c.json({ marketplace: serializeMarketplace(row) }, 201);
|
|
2627
|
+
});
|
|
2628
|
+
|
|
2629
|
+
app.openapi(deleteMarketplaceRoute, async (c) => {
|
|
2630
|
+
const { id: agentId, marketplaceId } = c.req.valid("param");
|
|
2631
|
+
await agentMarketplaceService.remove(agentId, marketplaceId);
|
|
2632
|
+
return c.body(null, 204);
|
|
2633
|
+
});
|
|
2634
|
+
|
|
2535
2635
|
// ─── Cron-run token stats ──────────────────────────────────────────────────
|
|
2536
2636
|
|
|
2537
2637
|
// GET /agents/all/cron-runs/stats — aggregated token stats across all agents
|
|
@@ -2844,6 +2944,22 @@ function serializeTool(tool: {
|
|
|
2844
2944
|
} as z.infer<typeof AgentToolSchema>;
|
|
2845
2945
|
}
|
|
2846
2946
|
|
|
2947
|
+
function serializeMarketplace(row: {
|
|
2948
|
+
id: string;
|
|
2949
|
+
agentId: string;
|
|
2950
|
+
name: string;
|
|
2951
|
+
source: string;
|
|
2952
|
+
ref: string | null;
|
|
2953
|
+
createdAt: Date;
|
|
2954
|
+
updatedAt: Date;
|
|
2955
|
+
}): z.infer<typeof AgentMarketplaceSchema> {
|
|
2956
|
+
return {
|
|
2957
|
+
...row,
|
|
2958
|
+
createdAt: row.createdAt.toISOString(),
|
|
2959
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2847
2963
|
function serializePlugin(plugin: {
|
|
2848
2964
|
createdAt: Date;
|
|
2849
2965
|
updatedAt: Date;
|