@app-vitals/agent-admin 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
  : {}),