@app-vitals/agent-admin 1.3.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/prisma/client/browser.ts +10 -0
- package/prisma/client/client.ts +10 -0
- package/prisma/client/internal/class.ts +24 -4
- package/prisma/client/internal/prismaNamespace.ts +183 -2
- package/prisma/client/internal/prismaNamespaceBrowser.ts +32 -1
- package/prisma/client/models/Agent.ts +282 -0
- package/prisma/client/models/AgentMarketplace.ts +1421 -0
- package/prisma/client/models/GitHubEnterpriseApp.ts +1289 -0
- package/prisma/client/models.ts +2 -0
- package/prisma/migrations/20261101000000_add_agent_marketplace/migration.sql +19 -0
- package/prisma/migrations/20261109000000_add_github_enterprise_app/migration.sql +16 -0
- package/prisma/schema/schema.prisma +37 -0
- package/src/admin-boot.ts +5 -1
- package/src/agent-marketplaces-api.integration.test.ts +263 -0
- package/src/agent-marketplaces.integration.test.ts +82 -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/github-enterprise-apps.integration.test.ts +152 -0
- package/src/github-enterprise-apps.ts +204 -0
- package/src/github-enterprise-apps.unit.test.ts +42 -0
- package/src/openapi-schemas.ts +56 -0
- package/src/openapi-schemas.unit.test.ts +29 -0
- package/src/token-crypto.ts +25 -0
- package/src/token-crypto.unit.test.ts +24 -1
package/prisma/client/models.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type * from './models/SessionFollow.ts'
|
|
|
14
14
|
export type * from './models/SessionAlertState.ts'
|
|
15
15
|
export type * from './models/Agent.ts'
|
|
16
16
|
export type * from './models/AgentEnv.ts'
|
|
17
|
+
export type * from './models/GitHubEnterpriseApp.ts'
|
|
17
18
|
export type * from './models/AgentCronJob.ts'
|
|
18
19
|
export type * from './models/AgentTool.ts'
|
|
19
20
|
export type * from './models/AgentToken.ts'
|
|
@@ -34,4 +35,5 @@ export type * from './models/AgentGitHubInstallationsSnapshot.ts'
|
|
|
34
35
|
export type * from './models/PushSubscription.ts'
|
|
35
36
|
export type * from './models/ChatThreadWatch.ts'
|
|
36
37
|
export type * from './models/UserNotificationPrefs.ts'
|
|
38
|
+
export type * from './models/AgentMarketplace.ts'
|
|
37
39
|
export type * from './commonInputTypes.ts'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- PMK-1.1: AgentMarketplace table for per-agent plugin marketplaces (additive only).
|
|
2
|
+
-- CreateTable
|
|
3
|
+
CREATE TABLE "AgentMarketplace" (
|
|
4
|
+
"id" TEXT NOT NULL,
|
|
5
|
+
"agentId" TEXT NOT NULL,
|
|
6
|
+
"name" TEXT NOT NULL,
|
|
7
|
+
"source" TEXT NOT NULL,
|
|
8
|
+
"ref" TEXT,
|
|
9
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
11
|
+
|
|
12
|
+
CONSTRAINT "AgentMarketplace_pkey" PRIMARY KEY ("id")
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
-- CreateIndex
|
|
16
|
+
CREATE UNIQUE INDEX "AgentMarketplace_agentId_name_key" ON "AgentMarketplace"("agentId", "name");
|
|
17
|
+
|
|
18
|
+
-- AddForeignKey
|
|
19
|
+
ALTER TABLE "AgentMarketplace" ADD CONSTRAINT "AgentMarketplace_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- GHE-1.1: optional admin-level GitHub Apps (installer / lookup). Additive only: new table.
|
|
2
|
+
-- CreateTable
|
|
3
|
+
CREATE TABLE "GitHubEnterpriseApp" (
|
|
4
|
+
"role" TEXT NOT NULL,
|
|
5
|
+
"appId" TEXT NOT NULL,
|
|
6
|
+
"slug" TEXT NOT NULL,
|
|
7
|
+
"enterpriseSlug" TEXT,
|
|
8
|
+
"privateKeyEncrypted" TEXT NOT NULL,
|
|
9
|
+
"lastValidationStatus" TEXT,
|
|
10
|
+
"lastValidatedAt" TIMESTAMP(3),
|
|
11
|
+
"lastValidationError" TEXT,
|
|
12
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
13
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
14
|
+
|
|
15
|
+
CONSTRAINT "GitHubEnterpriseApp_pkey" PRIMARY KEY ("role")
|
|
16
|
+
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Shipwright — Admin Service Schema
|
|
2
2
|
// Owns: Agent, AgentEnv, AgentCronJob, AgentTool, AgentToken, AgentPlugin,
|
|
3
|
+
// AgentMarketplace,
|
|
3
4
|
// plus the coding-owned models in coding.prisma (AgentWorkQueueSnapshot,
|
|
4
5
|
// AgentPhaseMethodology, SessionFollow, SessionAlertState).
|
|
5
6
|
// Agent is a first-class model (standalone, not embedded in a User model).
|
|
@@ -88,6 +89,7 @@ model Agent {
|
|
|
88
89
|
tools AgentTool[]
|
|
89
90
|
tokens AgentToken[]
|
|
90
91
|
plugins AgentPlugin[]
|
|
92
|
+
marketplaces AgentMarketplace[]
|
|
91
93
|
members AgentMember[]
|
|
92
94
|
chatTokenUsage AgentChatTokenUsageDailyByModel[]
|
|
93
95
|
workQueueSnapshot AgentWorkQueueSnapshot?
|
|
@@ -114,6 +116,24 @@ model AgentEnv {
|
|
|
114
116
|
@@unique([agentId, key])
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
// ─── GitHub Enterprise Apps ───────────────────────────────────────────────────
|
|
120
|
+
// Optional admin-level GitHub Apps, one per role ('installer' | 'lookup'), at most
|
|
121
|
+
// two rows per deployment; no rows means the feature is off. Deliberately NOT in
|
|
122
|
+
// AgentEnv so the private key can never reach the agent config bundle.
|
|
123
|
+
|
|
124
|
+
model GitHubEnterpriseApp {
|
|
125
|
+
role String @id // 'installer' | 'lookup'
|
|
126
|
+
appId String
|
|
127
|
+
slug String
|
|
128
|
+
enterpriseSlug String? // installer only
|
|
129
|
+
privateKeyEncrypted String // AES-256-GCM (iv:ciphertext:authTag); never returned by any read path
|
|
130
|
+
lastValidationStatus String?
|
|
131
|
+
lastValidatedAt DateTime?
|
|
132
|
+
lastValidationError String?
|
|
133
|
+
createdAt DateTime @default(now())
|
|
134
|
+
updatedAt DateTime @updatedAt
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
// ─── Agent Cron Jobs ──────────────────────────────────────────────────────────
|
|
118
138
|
// Scheduled prompts per agent.
|
|
119
139
|
// Delivery target is either a Slack channel ID or a Slack user ID (DM).
|
|
@@ -569,3 +589,20 @@ model UserNotificationPrefs {
|
|
|
569
589
|
updatedAt DateTime @updatedAt
|
|
570
590
|
}
|
|
571
591
|
|
|
592
|
+
|
|
593
|
+
// ─── AgentMarketplace ─────────────────────────────────────────────────────────
|
|
594
|
+
// Plugin marketplaces registered per agent. `name` is the marketplace name that
|
|
595
|
+
// AgentPlugin specs reference after the `@` (e.g. "plugin@<name>").
|
|
596
|
+
|
|
597
|
+
model AgentMarketplace {
|
|
598
|
+
id String @id @default(cuid())
|
|
599
|
+
agentId String
|
|
600
|
+
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
|
601
|
+
name String
|
|
602
|
+
source String
|
|
603
|
+
ref String?
|
|
604
|
+
createdAt DateTime @default(now())
|
|
605
|
+
updatedAt DateTime @updatedAt
|
|
606
|
+
|
|
607
|
+
@@unique([agentId, name])
|
|
608
|
+
}
|
package/src/admin-boot.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* They use non-overlapping sub-paths so Hono resolves them correctly:
|
|
24
24
|
* runtime → GET /agents/:id/config, GET /agents/:id/crons
|
|
25
25
|
* admin → GET /agents/:id/crons/summary (enriched with run stats)
|
|
26
|
-
* POST/PATCH/DELETE /agents/:id/envs, /crons, /tools, /tokens, /plugins
|
|
26
|
+
* POST/PATCH/DELETE /agents/:id/envs, /crons, /tools, /tokens, /plugins, /marketplaces
|
|
27
27
|
*
|
|
28
28
|
* This file must not import any coding module — main.ts is the only file
|
|
29
29
|
* that wires a concrete extension in.
|
|
@@ -80,6 +80,7 @@ import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
|
|
|
80
80
|
import { AgentCronRunService } from "./agent-cron-runs.ts";
|
|
81
81
|
import { AgentEnvService } from "./agent-envs.ts";
|
|
82
82
|
import { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
|
|
83
|
+
import { AgentMarketplaceService } from "./agent-marketplaces.ts";
|
|
83
84
|
import { AgentMemberService } from "./agent-members.ts";
|
|
84
85
|
import { AgentPluginService } from "./agent-plugins.ts";
|
|
85
86
|
import { AgentTokenService } from "./agent-tokens.ts";
|
|
@@ -197,6 +198,7 @@ export async function startAdmin(
|
|
|
197
198
|
const agentToolService = new AgentToolService(prisma);
|
|
198
199
|
const agentTokenService = new AgentTokenService(prisma);
|
|
199
200
|
const agentPluginService = new AgentPluginService(prisma);
|
|
201
|
+
const agentMarketplaceService = new AgentMarketplaceService(prisma);
|
|
200
202
|
const agentMemberService = new AgentMemberService(prisma);
|
|
201
203
|
// Account-aware caller scope (SSP-2.1). Account membership contributes
|
|
202
204
|
// agents only when the self-serve flag is on; off → AgentMember-only.
|
|
@@ -433,6 +435,7 @@ export async function startAdmin(
|
|
|
433
435
|
agentCronJobService,
|
|
434
436
|
agentService,
|
|
435
437
|
agentPluginService,
|
|
438
|
+
agentMarketplaceService,
|
|
436
439
|
...(resolvedExtensions.agentPhaseMethodologyService
|
|
437
440
|
? {
|
|
438
441
|
agentPhaseMethodologyService:
|
|
@@ -461,6 +464,7 @@ export async function startAdmin(
|
|
|
461
464
|
agentToolService,
|
|
462
465
|
agentTokenService,
|
|
463
466
|
agentPluginService,
|
|
467
|
+
agentMarketplaceService,
|
|
464
468
|
agentMemberService,
|
|
465
469
|
callerScopeResolver,
|
|
466
470
|
specialtyRegistry,
|
|
@@ -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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/agent-marketplaces.integration.test.ts
|
|
3
|
+
* Integration tests for the AgentMarketplace model against a real PostgreSQL DB.
|
|
4
|
+
*
|
|
5
|
+
* Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
10
|
+
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
11
|
+
|
|
12
|
+
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
13
|
+
|
|
14
|
+
const describeOrSkip = TEST_DB ? describe : describe.skip;
|
|
15
|
+
|
|
16
|
+
describeOrSkip("AgentMarketplace (integration)", () => {
|
|
17
|
+
let prisma: PrismaClient;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
prisma = createAdminPrismaClient(TEST_DB as string);
|
|
21
|
+
await prisma.agentMarketplace.deleteMany();
|
|
22
|
+
await prisma.agentPlugin.deleteMany();
|
|
23
|
+
await prisma.agentToken.deleteMany();
|
|
24
|
+
await prisma.agentCronJob.deleteMany();
|
|
25
|
+
await prisma.agentTool.deleteMany();
|
|
26
|
+
await prisma.agentEnv.deleteMany();
|
|
27
|
+
await prisma.agent.deleteMany();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await prisma.$disconnect();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function createAgent(): Promise<string> {
|
|
35
|
+
return (await prisma.agent.create({ data: { name: "Test Agent" } })).id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it("inserts a row and reads it back", async () => {
|
|
39
|
+
const agentId = await createAgent();
|
|
40
|
+
const created = await prisma.agentMarketplace.create({
|
|
41
|
+
data: {
|
|
42
|
+
agentId,
|
|
43
|
+
name: "shipwright",
|
|
44
|
+
source: "github:app-vitals/shipwright",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const found = await prisma.agentMarketplace.findUnique({
|
|
48
|
+
where: { id: created.id },
|
|
49
|
+
});
|
|
50
|
+
expect(found?.agentId).toBe(agentId);
|
|
51
|
+
expect(found?.name).toBe("shipwright");
|
|
52
|
+
expect(found?.source).toBe("github:app-vitals/shipwright");
|
|
53
|
+
expect(found?.ref).toBeNull();
|
|
54
|
+
expect(found?.createdAt).toBeInstanceOf(Date);
|
|
55
|
+
expect(found?.updatedAt).toBeInstanceOf(Date);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects a duplicate (agentId, name)", async () => {
|
|
59
|
+
const agentId = await createAgent();
|
|
60
|
+
await prisma.agentMarketplace.create({
|
|
61
|
+
data: { agentId, name: "dup", source: "a" },
|
|
62
|
+
});
|
|
63
|
+
await expect(
|
|
64
|
+
(async () =>
|
|
65
|
+
prisma.agentMarketplace.create({
|
|
66
|
+
data: { agentId, name: "dup", source: "b", ref: "main" },
|
|
67
|
+
}))(),
|
|
68
|
+
).rejects.toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("cascades when the agent is deleted", async () => {
|
|
72
|
+
const agentId = await createAgent();
|
|
73
|
+
await prisma.agentMarketplace.create({
|
|
74
|
+
data: { agentId, name: "m1", source: "a" },
|
|
75
|
+
});
|
|
76
|
+
await prisma.agentMarketplace.create({
|
|
77
|
+
data: { agentId, name: "m2", source: "b", ref: "v1" },
|
|
78
|
+
});
|
|
79
|
+
await prisma.agent.delete({ where: { id: agentId } });
|
|
80
|
+
expect(await prisma.agentMarketplace.count({ where: { agentId } })).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -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
|
+
}
|