@app-vitals/agent-admin 1.14.0 → 1.16.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 +15 -1
- package/openapi.platform.json +15 -1
- package/package.json +2 -2
- package/prisma/client/internal/class.ts +4 -4
- package/prisma/client/internal/prismaNamespace.ts +1 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +1 -0
- package/prisma/client/models/Agent.ts +80 -1
- package/prisma/migrations/20261110010000_add_agent_github_username/migration.sql +3 -0
- package/prisma/schema/schema.prisma +4 -0
- package/src/admin-boot.ts +11 -14
- package/src/admin-ui-account-nav.ts +19 -2
- package/src/admin-ui-github-enterprise-pages.ts +219 -0
- package/src/admin-ui-github-enterprise-pages.unit.test.ts +183 -0
- package/src/admin-ui-github-enterprise.smoke.test.ts +323 -0
- package/src/admin-ui-github-enterprise.ts +221 -0
- package/src/admin-ui-styles.ts +5 -1
- package/src/admin-ui.ts +35 -1
- package/src/agent-github-username.integration.test.ts +202 -0
- package/src/agents-api.smoke.test.ts +157 -0
- package/src/agents-api.ts +38 -1
- package/src/agents.ts +8 -0
- package/src/github-username.ts +20 -0
- package/src/github-username.unit.test.ts +34 -0
- package/src/openapi-schemas.ts +10 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/agent-github-username.integration.test.ts
|
|
3
|
+
* Integration tests for the Agent.githubUsername column (GHE-2.1) against a
|
|
4
|
+
* real PostgreSQL DB.
|
|
5
|
+
*
|
|
6
|
+
* Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
10
|
+
import { sign } from "hono/jwt";
|
|
11
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
12
|
+
import { AgentChatTokenService } from "./agent-chat-tokens.ts";
|
|
13
|
+
import { AgentCronJobService } from "./agent-cron-jobs.ts";
|
|
14
|
+
import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
|
|
15
|
+
import { AgentCronRunService } from "./agent-cron-runs.ts";
|
|
16
|
+
import { AgentEnvService } from "./agent-envs.ts";
|
|
17
|
+
import { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
|
|
18
|
+
import { AgentMemberService } from "./agent-members.ts";
|
|
19
|
+
import { AgentPluginService } from "./agent-plugins.ts";
|
|
20
|
+
import { NoopAgentProvisioner } from "./agent-provisioner.ts";
|
|
21
|
+
import { AgentTokenService } from "./agent-tokens.ts";
|
|
22
|
+
import { AgentToolService } from "./agent-tools.ts";
|
|
23
|
+
import { AgentService } from "./agents.ts";
|
|
24
|
+
import type { AdminDeps } from "./agents-api.ts";
|
|
25
|
+
import { createAdminApp } from "./agents-api.ts";
|
|
26
|
+
import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
|
|
27
|
+
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
28
|
+
import type {
|
|
29
|
+
SpecialtyType,
|
|
30
|
+
SpecialtyTypeResolver,
|
|
31
|
+
} from "./specialty-types.ts";
|
|
32
|
+
import { makeTokenCrypto } from "./token-crypto.ts";
|
|
33
|
+
|
|
34
|
+
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
35
|
+
const describeOrSkip = TEST_DB ? describe : describe.skip;
|
|
36
|
+
|
|
37
|
+
const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
|
|
38
|
+
const REAL_KEY =
|
|
39
|
+
"0000000000000000000000000000000000000000000000000000000000000001";
|
|
40
|
+
|
|
41
|
+
async function makeSessionCookie(): Promise<string> {
|
|
42
|
+
return sign(
|
|
43
|
+
{
|
|
44
|
+
isAdmin: true,
|
|
45
|
+
userId: "user-123",
|
|
46
|
+
email: "admin@example.com",
|
|
47
|
+
name: "Admin User",
|
|
48
|
+
iat: Math.floor(Date.now() / 1000),
|
|
49
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
50
|
+
},
|
|
51
|
+
SESSION_SECRET,
|
|
52
|
+
"HS256",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makePrisma(): PrismaClient {
|
|
57
|
+
// TEST_DB is guaranteed set — the describe block is skipped otherwise.
|
|
58
|
+
return createAdminPrismaClient(TEST_DB as string);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CODING_TYPE: SpecialtyType = {
|
|
62
|
+
name: "coding",
|
|
63
|
+
displayName: "Coding Agent",
|
|
64
|
+
templatesDir: "workspace/",
|
|
65
|
+
tools: ["Read", "Write", "Edit"],
|
|
66
|
+
requiredEnv: [],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function fakeSpecialtyTypes(): SpecialtyTypeResolver {
|
|
70
|
+
const byType: Record<string, SpecialtyType> = { coding: CODING_TYPE };
|
|
71
|
+
return {
|
|
72
|
+
getType(specialty: string): SpecialtyType {
|
|
73
|
+
return byType[specialty] ?? (byType.coding as SpecialtyType);
|
|
74
|
+
},
|
|
75
|
+
tryGetType(specialty: string): SpecialtyType | undefined {
|
|
76
|
+
return byType[specialty];
|
|
77
|
+
},
|
|
78
|
+
listTypes() {
|
|
79
|
+
return Object.entries(byType).map(([name, manifest]) => ({
|
|
80
|
+
name,
|
|
81
|
+
displayName: manifest.displayName,
|
|
82
|
+
}));
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeDeps(prisma: PrismaClient): AdminDeps {
|
|
88
|
+
const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
89
|
+
process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
|
|
90
|
+
const crypto = makeTokenCrypto();
|
|
91
|
+
if (savedKey === undefined) {
|
|
92
|
+
delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
93
|
+
} else {
|
|
94
|
+
process.env.SHIPWRIGHT_ENCRYPTION_KEY = savedKey;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
agentService: new AgentService(prisma),
|
|
99
|
+
agentEnvService: new AgentEnvService(prisma, crypto),
|
|
100
|
+
agentCronJobService: new AgentCronJobService(prisma),
|
|
101
|
+
agentCronRunService: new AgentCronRunService(prisma),
|
|
102
|
+
agentCronRunStatsService: new AgentCronRunStatsService(prisma),
|
|
103
|
+
agentToolService: new AgentToolService(prisma),
|
|
104
|
+
agentTokenService: new AgentTokenService(prisma),
|
|
105
|
+
agentPluginService: new AgentPluginService(prisma),
|
|
106
|
+
agentMemberService: new AgentMemberService(prisma),
|
|
107
|
+
specialtyRegistry: fakeSpecialtyTypes(),
|
|
108
|
+
agentChatTokenService: new AgentChatTokenService(prisma),
|
|
109
|
+
agentGitHubInstallationsService: new AgentGitHubInstallationsService(
|
|
110
|
+
prisma,
|
|
111
|
+
),
|
|
112
|
+
prisma,
|
|
113
|
+
provisioner: new NoopAgentProvisioner(),
|
|
114
|
+
serviceIntegrations: [
|
|
115
|
+
{
|
|
116
|
+
name: "task-store",
|
|
117
|
+
listTokensForAgent: async () => [],
|
|
118
|
+
revokeToken: async () => {},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
chatService: new NoopChatServiceProvisioningClient(),
|
|
122
|
+
slack: {
|
|
123
|
+
deleteApp: async () => {},
|
|
124
|
+
},
|
|
125
|
+
decrypt: (value: string) => crypto.decrypt(value),
|
|
126
|
+
sessionSecret: SESSION_SECRET,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describeOrSkip("Agent.githubUsername (integration)", () => {
|
|
131
|
+
let prisma: PrismaClient;
|
|
132
|
+
|
|
133
|
+
beforeEach(async () => {
|
|
134
|
+
prisma = makePrisma();
|
|
135
|
+
await prisma.agentWorkQueueSnapshot.deleteMany();
|
|
136
|
+
await prisma.agentPlugin.deleteMany();
|
|
137
|
+
await prisma.agentToken.deleteMany();
|
|
138
|
+
await prisma.agentCronJob.deleteMany();
|
|
139
|
+
await prisma.agentTool.deleteMany();
|
|
140
|
+
await prisma.agentEnv.deleteMany();
|
|
141
|
+
await prisma.agentMember.deleteMany();
|
|
142
|
+
await prisma.agent.deleteMany();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterEach(async () => {
|
|
146
|
+
await prisma.$disconnect();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("migration adds exactly one nullable, non-unique text column; existing rows are NULL", async () => {
|
|
150
|
+
const cols = await prisma.$queryRaw<
|
|
151
|
+
Array<{ data_type: string; is_nullable: string }>
|
|
152
|
+
>`SELECT data_type, is_nullable FROM information_schema.columns WHERE table_name = 'Agent' AND column_name = 'githubUsername'`;
|
|
153
|
+
expect(cols).toEqual([{ data_type: "text", is_nullable: "YES" }]);
|
|
154
|
+
const idx = await prisma.$queryRaw<Array<{ indexdef: string }>>`
|
|
155
|
+
SELECT indexdef FROM pg_indexes WHERE tablename = 'Agent' AND indexdef LIKE '%githubUsername%'`;
|
|
156
|
+
expect(idx).toEqual([]);
|
|
157
|
+
const agent = await prisma.agent.create({ data: { name: "Legacy" } });
|
|
158
|
+
expect(agent.githubUsername).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("set, get, and clear round-trip via PATCH/GET, lower-cased, duplicates allowed", async () => {
|
|
162
|
+
const a = await prisma.agent.create({ data: { name: "A" } });
|
|
163
|
+
const b = await prisma.agent.create({ data: { name: "B" } });
|
|
164
|
+
const cookie = await makeSessionCookie();
|
|
165
|
+
const app = createAdminApp(makeDeps(prisma));
|
|
166
|
+
const send = (id: string, method: string, body?: unknown) =>
|
|
167
|
+
app.request(`/agents/${id}`, {
|
|
168
|
+
method,
|
|
169
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
170
|
+
headers: {
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
Cookie: `admin_session=${cookie}`,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const set = await send(a.id, "PATCH", { githubUsername: "Octo-Cat" });
|
|
177
|
+
expect(set.status).toBe(200);
|
|
178
|
+
expect((await set.json()).githubUsername).toBe("octo-cat");
|
|
179
|
+
expect((await (await send(a.id, "GET")).json()).githubUsername).toBe(
|
|
180
|
+
"octo-cat",
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// No uniqueness: a second agent may use the same login.
|
|
184
|
+
const dup = await send(b.id, "PATCH", { githubUsername: "octo-cat" });
|
|
185
|
+
expect(dup.status).toBe(200);
|
|
186
|
+
|
|
187
|
+
// Unrelated PATCH leaves it unchanged.
|
|
188
|
+
await send(a.id, "PATCH", { selfHosted: false });
|
|
189
|
+
expect((await (await send(a.id, "GET")).json()).githubUsername).toBe(
|
|
190
|
+
"octo-cat",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const bad = await send(a.id, "PATCH", { githubUsername: "a--b" });
|
|
194
|
+
expect(bad.status).toBe(422);
|
|
195
|
+
|
|
196
|
+
const cleared = await send(a.id, "PATCH", { githubUsername: null });
|
|
197
|
+
expect(cleared.status).toBe(200);
|
|
198
|
+
expect((await cleared.json()).githubUsername).toBeNull();
|
|
199
|
+
const row = await prisma.agent.findUnique({ where: { id: a.id } });
|
|
200
|
+
expect(row?.githubUsername).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -6599,3 +6599,160 @@ describe("admin API — baked marketplaces report", () => {
|
|
|
6599
6599
|
expect(stored()).toEqual([]);
|
|
6600
6600
|
});
|
|
6601
6601
|
});
|
|
6602
|
+
|
|
6603
|
+
// ─── GHE-2.1: PATCH /agents/:id githubUsername ───────────────────────────────
|
|
6604
|
+
|
|
6605
|
+
describe("admin API — PATCH /agents/:id githubUsername (GHE-2.1)", () => {
|
|
6606
|
+
const OPS = "ghe-ops-token";
|
|
6607
|
+
const AGENT_KEY = "ghe-agent-token";
|
|
6608
|
+
|
|
6609
|
+
function setup() {
|
|
6610
|
+
const updated: Array<Record<string, unknown>> = [];
|
|
6611
|
+
const base = makeMockDeps();
|
|
6612
|
+
const deps: AdminDeps = {
|
|
6613
|
+
...base,
|
|
6614
|
+
adminApiKeys: parseAdminApiKeys(
|
|
6615
|
+
`root:${ADMIN_API_KEY}:*,operator:${OPS}:platform.ops`,
|
|
6616
|
+
),
|
|
6617
|
+
agentTokenService: {
|
|
6618
|
+
...base.agentTokenService,
|
|
6619
|
+
validate: async (t: string) =>
|
|
6620
|
+
t === AGENT_KEY ? { agentId: AGENT_ID } : null,
|
|
6621
|
+
},
|
|
6622
|
+
agentService: {
|
|
6623
|
+
...base.agentService,
|
|
6624
|
+
updateSelfHosted: async (id, input) => {
|
|
6625
|
+
updated.push(input as Record<string, unknown>);
|
|
6626
|
+
return {
|
|
6627
|
+
...(await base.agentService.updateSelfHosted(id, input)),
|
|
6628
|
+
githubUsername:
|
|
6629
|
+
(input as { githubUsername?: string | null }).githubUsername ??
|
|
6630
|
+
null,
|
|
6631
|
+
};
|
|
6632
|
+
},
|
|
6633
|
+
},
|
|
6634
|
+
};
|
|
6635
|
+
return { app: createAdminApp(deps), updated };
|
|
6636
|
+
}
|
|
6637
|
+
|
|
6638
|
+
const patch = (
|
|
6639
|
+
app: ReturnType<typeof createAdminApp>,
|
|
6640
|
+
body: unknown,
|
|
6641
|
+
headers: Record<string, string>,
|
|
6642
|
+
) =>
|
|
6643
|
+
app.request(`/agents/${AGENT_ID}`, {
|
|
6644
|
+
method: "PATCH",
|
|
6645
|
+
body: JSON.stringify(body),
|
|
6646
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
6647
|
+
});
|
|
6648
|
+
|
|
6649
|
+
it("platform admin sets it: 200, lower-cased, returned", async () => {
|
|
6650
|
+
const { app, updated } = setup();
|
|
6651
|
+
const res = await patch(
|
|
6652
|
+
app,
|
|
6653
|
+
{ githubUsername: "Octo-Cat" },
|
|
6654
|
+
{ Authorization: `Bearer ${ADMIN_API_KEY}` },
|
|
6655
|
+
);
|
|
6656
|
+
expect(res.status).toBe(200);
|
|
6657
|
+
expect(
|
|
6658
|
+
((await res.json()) as { githubUsername: string }).githubUsername,
|
|
6659
|
+
).toBe("octo-cat");
|
|
6660
|
+
expect(updated[0]).toMatchObject({ githubUsername: "octo-cat" });
|
|
6661
|
+
});
|
|
6662
|
+
|
|
6663
|
+
it("platform admin clears it with null", async () => {
|
|
6664
|
+
const { app, updated } = setup();
|
|
6665
|
+
const res = await patch(
|
|
6666
|
+
app,
|
|
6667
|
+
{ githubUsername: null },
|
|
6668
|
+
{ Authorization: `Bearer ${ADMIN_API_KEY}` },
|
|
6669
|
+
);
|
|
6670
|
+
expect(res.status).toBe(200);
|
|
6671
|
+
expect(updated[0]).toMatchObject({ githubUsername: null });
|
|
6672
|
+
});
|
|
6673
|
+
|
|
6674
|
+
it("403 for a non-admin session member", async () => {
|
|
6675
|
+
const base = makeMockDeps();
|
|
6676
|
+
const memberApp = createAdminApp({
|
|
6677
|
+
...base,
|
|
6678
|
+
callerScopeResolver: async () => ({
|
|
6679
|
+
kind: "scoped",
|
|
6680
|
+
accountId: "acct-a",
|
|
6681
|
+
agentIds: [AGENT_ID],
|
|
6682
|
+
}),
|
|
6683
|
+
});
|
|
6684
|
+
const cookie = await sign(
|
|
6685
|
+
{
|
|
6686
|
+
isAdmin: false,
|
|
6687
|
+
userId: "user-acct",
|
|
6688
|
+
email: "owner@acct-a.example.com",
|
|
6689
|
+
iat: Math.floor(Date.now() / 1000),
|
|
6690
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
6691
|
+
},
|
|
6692
|
+
SESSION_SECRET,
|
|
6693
|
+
"HS256",
|
|
6694
|
+
);
|
|
6695
|
+
const res = await patch(
|
|
6696
|
+
memberApp,
|
|
6697
|
+
{ githubUsername: "octocat" },
|
|
6698
|
+
{ Cookie: `admin_session=${cookie}` },
|
|
6699
|
+
);
|
|
6700
|
+
expect(res.status).toBe(403);
|
|
6701
|
+
});
|
|
6702
|
+
|
|
6703
|
+
it("403 for a platform.ops key", async () => {
|
|
6704
|
+
const { app, updated } = setup();
|
|
6705
|
+
const res = await patch(
|
|
6706
|
+
app,
|
|
6707
|
+
{ githubUsername: "octocat" },
|
|
6708
|
+
{ Authorization: `Bearer ${OPS}` },
|
|
6709
|
+
);
|
|
6710
|
+
expect(res.status).toBe(403);
|
|
6711
|
+
expect(updated).toEqual([]);
|
|
6712
|
+
});
|
|
6713
|
+
|
|
6714
|
+
it("403 for an agent-scoped key", async () => {
|
|
6715
|
+
const { app, updated } = setup();
|
|
6716
|
+
const res = await patch(
|
|
6717
|
+
app,
|
|
6718
|
+
{ githubUsername: "octocat" },
|
|
6719
|
+
{ Authorization: `Bearer ${AGENT_KEY}` },
|
|
6720
|
+
);
|
|
6721
|
+
expect(res.status).toBe(403);
|
|
6722
|
+
expect(updated).toEqual([]);
|
|
6723
|
+
});
|
|
6724
|
+
|
|
6725
|
+
it("422 for invalid GitHub login syntax; nothing written", async () => {
|
|
6726
|
+
const { app, updated } = setup();
|
|
6727
|
+
for (const bad of ["-lead", "trail-", "a--b", "a".repeat(40), "", "a b"]) {
|
|
6728
|
+
const res = await patch(
|
|
6729
|
+
app,
|
|
6730
|
+
{ githubUsername: bad },
|
|
6731
|
+
{ Authorization: `Bearer ${ADMIN_API_KEY}` },
|
|
6732
|
+
);
|
|
6733
|
+
expect(res.status).toBe(422);
|
|
6734
|
+
}
|
|
6735
|
+
expect(updated).toEqual([]);
|
|
6736
|
+
});
|
|
6737
|
+
|
|
6738
|
+
it("logs the caller name and agent id but never the username or key", async () => {
|
|
6739
|
+
const { app } = setup();
|
|
6740
|
+
const spy = spyOn(console, "info").mockImplementation(() => {});
|
|
6741
|
+
try {
|
|
6742
|
+
const res = await patch(
|
|
6743
|
+
app,
|
|
6744
|
+
{ githubUsername: "secret-login" },
|
|
6745
|
+
{ Authorization: `Bearer ${ADMIN_API_KEY}` },
|
|
6746
|
+
);
|
|
6747
|
+
expect(res.status).toBe(200);
|
|
6748
|
+
const lines = spy.mock.calls.map((c) => String(c[0]));
|
|
6749
|
+
const line = lines.find((l) => l.includes("githubUsername"));
|
|
6750
|
+
expect(line).toContain(AGENT_ID);
|
|
6751
|
+
expect(line).toContain("root");
|
|
6752
|
+
expect(lines.join("\n")).not.toContain("secret-login");
|
|
6753
|
+
expect(lines.join("\n")).not.toContain(ADMIN_API_KEY);
|
|
6754
|
+
} finally {
|
|
6755
|
+
spy.mockRestore();
|
|
6756
|
+
}
|
|
6757
|
+
});
|
|
6758
|
+
});
|
package/src/agents-api.ts
CHANGED
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
NotFoundError,
|
|
108
108
|
UnprocessableEntityError,
|
|
109
109
|
} from "./errors.ts";
|
|
110
|
+
import { normalizeGithubUsername } from "./github-username.ts";
|
|
110
111
|
import {
|
|
111
112
|
AddAgentReposBodySchema,
|
|
112
113
|
AgentChatTokenUsageDailySchema,
|
|
@@ -458,6 +459,12 @@ const GetAgentResultSchema = z
|
|
|
458
459
|
*/
|
|
459
460
|
trialExpiresAt: z.string().datetime().nullable().optional(),
|
|
460
461
|
trialExpiryWarnedAt: z.string().datetime().nullable().optional(),
|
|
462
|
+
/** GHE-2.1: lower-cased GitHub login; null when unset. */
|
|
463
|
+
githubUsername: z
|
|
464
|
+
.string()
|
|
465
|
+
.nullable()
|
|
466
|
+
.optional()
|
|
467
|
+
.openapi({ example: "octocat" }),
|
|
461
468
|
/**
|
|
462
469
|
* PMK-1.4: names of the marketplaces baked into the agent's image, as last
|
|
463
470
|
* reported by the runtime at boot. Read-only: only the runtime-authenticated
|
|
@@ -658,7 +665,7 @@ const patchAgentRoute = createRoute({
|
|
|
658
665
|
path: "/agents/{id}",
|
|
659
666
|
summary: "Update an agent",
|
|
660
667
|
description:
|
|
661
|
-
"Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, `accountId` (platform admin only — 403 for session members; `null` clears it; reconciles the workload of in-cluster agents; does not move historical task-store rows), and/or `podServiceAccountName` (platform admin only — 403 otherwise; `null` clears it; 409 unless SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED is on; 422 for agents with a per-agent federated identity; reconciles the workload so the pod is recreated under the new ServiceAccount). `specialty` (platform admin only — 403 otherwise; 422 when the type is unknown; a no-op when unchanged; otherwise persists the new type, reconciles the agent's system crons against it — system crons the new type does not declare are deleted, user crons survive — and reconciles the workload of in-cluster agents so the pod runs the image mapped to the new specialty). `trialExpiryWarnedAt` is not updatable via this route. `repos` here REPLACES the whole list; for incremental changes use `POST /agents/{id}/repos` (add) and `DELETE /agents/{id}/repos/{owner}/{name}` (remove). Returns the updated agent, including a `warning` field when `restrictSlackToMembers` is set true on an agent with zero members.",
|
|
668
|
+
"Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, `githubUsername` (platform admin only — 403 otherwise; `null` clears it; lower-cased on save; 422 for invalid GitHub login syntax; no GitHub call), `accountId` (platform admin only — 403 for session members; `null` clears it; reconciles the workload of in-cluster agents; does not move historical task-store rows), and/or `podServiceAccountName` (platform admin only — 403 otherwise; `null` clears it; 409 unless SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED is on; 422 for agents with a per-agent federated identity; reconciles the workload so the pod is recreated under the new ServiceAccount). `specialty` (platform admin only — 403 otherwise; 422 when the type is unknown; a no-op when unchanged; otherwise persists the new type, reconciles the agent's system crons against it — system crons the new type does not declare are deleted, user crons survive — and reconciles the workload of in-cluster agents so the pod runs the image mapped to the new specialty). `trialExpiryWarnedAt` is not updatable via this route. `repos` here REPLACES the whole list; for incremental changes use `POST /agents/{id}/repos` (add) and `DELETE /agents/{id}/repos/{owner}/{name}` (remove). Returns the updated agent, including a `warning` field when `restrictSlackToMembers` is set true on an agent with zero members.",
|
|
662
669
|
request: {
|
|
663
670
|
params: AgentIdParamSchema,
|
|
664
671
|
body: {
|
|
@@ -1806,6 +1813,27 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
1806
1813
|
throw new ForbiddenError("Admin access required to change accountId");
|
|
1807
1814
|
}
|
|
1808
1815
|
}
|
|
1816
|
+
// GHE-2.1: githubUsername — platform-admin only; syntax check only (no
|
|
1817
|
+
// GitHub call). Stored lower-cased.
|
|
1818
|
+
let githubUsername: string | null | undefined;
|
|
1819
|
+
if (body.githubUsername !== undefined) {
|
|
1820
|
+
if (c.get("isAdmin") !== true) {
|
|
1821
|
+
throw new ForbiddenError(
|
|
1822
|
+
"Admin access required to change githubUsername",
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
if (body.githubUsername === null) {
|
|
1826
|
+
githubUsername = null;
|
|
1827
|
+
} else {
|
|
1828
|
+
const normalized = normalizeGithubUsername(body.githubUsername);
|
|
1829
|
+
if (normalized === null) {
|
|
1830
|
+
throw new UnprocessableEntityError(
|
|
1831
|
+
"githubUsername must be a valid GitHub login (alphanumerics and single hyphens, no leading or trailing hyphen, max 39 characters)",
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
githubUsername = normalized;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1809
1837
|
// SAL-1.2: specialty change — platform-admin only, known type only.
|
|
1810
1838
|
let previousSpecialty: string | undefined;
|
|
1811
1839
|
if (body.specialty !== undefined) {
|
|
@@ -1884,6 +1912,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
1884
1912
|
? { restrictSlackToMembers: body.restrictSlackToMembers }
|
|
1885
1913
|
: {}),
|
|
1886
1914
|
...(body.slackId !== undefined ? { slackId: body.slackId } : {}),
|
|
1915
|
+
...(githubUsername !== undefined ? { githubUsername } : {}),
|
|
1887
1916
|
...(body.trialExpiresAt !== undefined
|
|
1888
1917
|
? {
|
|
1889
1918
|
trialExpiresAt: body.trialExpiresAt
|
|
@@ -1926,6 +1955,12 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
1926
1955
|
body.accountId !== undefined
|
|
1927
1956
|
? await reconcileAgentAfterAccountChange(provisioner, agent)
|
|
1928
1957
|
: undefined;
|
|
1958
|
+
if (githubUsername !== undefined) {
|
|
1959
|
+
// Audit: caller name and agent id only — never the username value.
|
|
1960
|
+
console.info(
|
|
1961
|
+
`[agents-api] githubUsername changed: agent=${agentId} caller=${c.get("caller")?.name ?? "anonymous"}`,
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1929
1964
|
let specialtyWarning: string | undefined;
|
|
1930
1965
|
if (previousSpecialty !== undefined) {
|
|
1931
1966
|
// Audit: ids and names only — never the caller's key.
|
|
@@ -3057,6 +3092,7 @@ function serializeAgent(
|
|
|
3057
3092
|
missingRequiredEnv?: string[];
|
|
3058
3093
|
trialExpiresAt?: Date | null;
|
|
3059
3094
|
trialExpiryWarnedAt?: Date | null;
|
|
3095
|
+
githubUsername?: string | null;
|
|
3060
3096
|
bakedMarketplaces?: string[];
|
|
3061
3097
|
autoPostReviews?: boolean;
|
|
3062
3098
|
allowSelfReview?: boolean;
|
|
@@ -3092,6 +3128,7 @@ function serializeAgent(
|
|
|
3092
3128
|
trialExpiryWarnedAt: agent.trialExpiryWarnedAt
|
|
3093
3129
|
? agent.trialExpiryWarnedAt.toISOString()
|
|
3094
3130
|
: null,
|
|
3131
|
+
githubUsername: agent.githubUsername ?? null,
|
|
3095
3132
|
bakedMarketplaces: agent.bakedMarketplaces ?? [],
|
|
3096
3133
|
autoPostReviews: agent.autoPostReviews ?? true,
|
|
3097
3134
|
allowSelfReview: agent.allowSelfReview ?? false,
|
package/src/agents.ts
CHANGED
|
@@ -126,6 +126,8 @@ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
|
|
|
126
126
|
*/
|
|
127
127
|
trialExpiresAt?: Date | null;
|
|
128
128
|
trialExpiryWarnedAt?: Date | null;
|
|
129
|
+
/** GHE-2.1: nullable GitHub login (lower-cased). Optional for fixture compat. */
|
|
130
|
+
githubUsername?: string | null;
|
|
129
131
|
/**
|
|
130
132
|
* PMK-1.4: marketplace names baked into the agent's image, as last reported
|
|
131
133
|
* by the runtime. Read-only here; written only by setBakedMarketplaces().
|
|
@@ -174,6 +176,8 @@ interface UpdateSelfHostedInput {
|
|
|
174
176
|
* PATCH /agents/:id (omitted entirely means "leave unchanged").
|
|
175
177
|
*/
|
|
176
178
|
trialExpiresAt?: Date | null;
|
|
179
|
+
/** GHE-2.1: null clears; omitted leaves unchanged. Already lower-cased. */
|
|
180
|
+
githubUsername?: string | null;
|
|
177
181
|
/** APM-1.3: see AgentDetail's doc comment for these six fields. */
|
|
178
182
|
autoPostReviews?: boolean;
|
|
179
183
|
allowSelfReview?: boolean;
|
|
@@ -288,6 +292,7 @@ const DETAIL_SELECT = {
|
|
|
288
292
|
updatedAt: true,
|
|
289
293
|
trialExpiresAt: true,
|
|
290
294
|
trialExpiryWarnedAt: true,
|
|
295
|
+
githubUsername: true,
|
|
291
296
|
bakedMarketplaces: true,
|
|
292
297
|
autoPostReviews: true,
|
|
293
298
|
allowSelfReview: true,
|
|
@@ -525,6 +530,9 @@ export class AgentService {
|
|
|
525
530
|
...(input.trialExpiresAt !== undefined
|
|
526
531
|
? { trialExpiresAt: input.trialExpiresAt }
|
|
527
532
|
: {}),
|
|
533
|
+
...(input.githubUsername !== undefined
|
|
534
|
+
? { githubUsername: input.githubUsername }
|
|
535
|
+
: {}),
|
|
528
536
|
...(input.autoPostReviews !== undefined
|
|
529
537
|
? { autoPostReviews: input.autoPostReviews }
|
|
530
538
|
: {}),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHE-2.1: GitHub login syntax validation. Syntax only — never calls GitHub.
|
|
3
|
+
* A login is 1-39 chars of alphanumerics and single hyphens, with no leading
|
|
4
|
+
* or trailing hyphen (consecutive hyphens are invalid).
|
|
5
|
+
*/
|
|
6
|
+
const GITHUB_LOGIN_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/i;
|
|
7
|
+
const GITHUB_LOGIN_MAX_LENGTH = 39;
|
|
8
|
+
|
|
9
|
+
export function isValidGithubUsername(value: string): boolean {
|
|
10
|
+
return (
|
|
11
|
+
value.length > 0 &&
|
|
12
|
+
value.length <= GITHUB_LOGIN_MAX_LENGTH &&
|
|
13
|
+
GITHUB_LOGIN_PATTERN.test(value)
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Returns the lower-cased login, or null when the syntax is invalid. */
|
|
18
|
+
export function normalizeGithubUsername(value: string): string | null {
|
|
19
|
+
return isValidGithubUsername(value) ? value.toLowerCase() : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isValidGithubUsername,
|
|
4
|
+
normalizeGithubUsername,
|
|
5
|
+
} from "./github-username.ts";
|
|
6
|
+
|
|
7
|
+
describe("github username syntax (GHE-2.1)", () => {
|
|
8
|
+
it("accepts valid logins", () => {
|
|
9
|
+
for (const ok of [
|
|
10
|
+
"a",
|
|
11
|
+
"octocat",
|
|
12
|
+
"Octo-Cat",
|
|
13
|
+
"a-b-c",
|
|
14
|
+
"a1",
|
|
15
|
+
"a".repeat(39),
|
|
16
|
+
])
|
|
17
|
+
expect(isValidGithubUsername(ok)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects leading/trailing/consecutive hyphens", () => {
|
|
21
|
+
for (const bad of ["-a", "a-", "a--b", "-"])
|
|
22
|
+
expect(isValidGithubUsername(bad)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects too long, empty and illegal characters", () => {
|
|
26
|
+
for (const bad of ["a".repeat(40), "", "a_b", "a b", "a.b", "é"])
|
|
27
|
+
expect(isValidGithubUsername(bad)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("normalize lower-cases valid input and returns null otherwise", () => {
|
|
31
|
+
expect(normalizeGithubUsername("Octo-Cat")).toBe("octo-cat");
|
|
32
|
+
expect(normalizeGithubUsername("-bad")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/openapi-schemas.ts
CHANGED
|
@@ -114,6 +114,16 @@ export const PatchAgentBodySchema = z
|
|
|
114
114
|
.nullable()
|
|
115
115
|
.optional()
|
|
116
116
|
.openapi({ example: "agent-pod-sa" }),
|
|
117
|
+
/**
|
|
118
|
+
* GHE-2.1: GitHub account whose repo access caps the agent (cap applied
|
|
119
|
+
* later). Platform admin only; `null` clears it. Lower-cased on save;
|
|
120
|
+
* syntax-checked (422) with no GitHub call.
|
|
121
|
+
*/
|
|
122
|
+
githubUsername: z
|
|
123
|
+
.string()
|
|
124
|
+
.nullable()
|
|
125
|
+
.optional()
|
|
126
|
+
.openapi({ example: "octocat" }),
|
|
117
127
|
/**
|
|
118
128
|
* ATE-1.1: trial expiry timestamp. Nullable so it can also be explicitly
|
|
119
129
|
* cleared. trialExpiryWarnedAt is intentionally NOT part of this schema —
|