@app-vitals/agent-admin 0.5.0 → 0.6.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 CHANGED
@@ -204,6 +204,12 @@
204
204
  "typeName": {
205
205
  "type": "string"
206
206
  },
207
+ "podServiceAccountName": {
208
+ "type": [
209
+ "string",
210
+ "null"
211
+ ]
212
+ },
207
213
  "accountId": {
208
214
  "type": [
209
215
  "string",
@@ -303,6 +309,7 @@
303
309
  "patchAuthorAllowlist",
304
310
  "restrictSlackToMembers",
305
311
  "typeName",
312
+ "podServiceAccountName",
306
313
  "accountId",
307
314
  "createdAt",
308
315
  "updatedAt",
@@ -372,6 +379,15 @@
372
379
  "minLength": 1,
373
380
  "example": "acct_123"
374
381
  },
382
+ "podServiceAccountName": {
383
+ "type": [
384
+ "string",
385
+ "null"
386
+ ],
387
+ "maxLength": 63,
388
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
389
+ "example": "agent-pod-sa"
390
+ },
375
391
  "trialExpiresAt": {
376
392
  "type": [
377
393
  "string",
@@ -3546,7 +3562,7 @@
3546
3562
  },
3547
3563
  "patch": {
3548
3564
  "summary": "Update an agent",
3549
- "description": "Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, and/or `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). `typeName` and `trialExpiryWarnedAt` are 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.",
3565
+ "description": "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). `typeName` and `trialExpiryWarnedAt` are 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.",
3550
3566
  "parameters": [
3551
3567
  {
3552
3568
  "schema": {
@@ -3607,6 +3623,26 @@
3607
3623
  }
3608
3624
  }
3609
3625
  }
3626
+ },
3627
+ "409": {
3628
+ "description": "podServiceAccountName supplied while the pod ServiceAccount flag is off",
3629
+ "content": {
3630
+ "application/json": {
3631
+ "schema": {
3632
+ "$ref": "#/components/schemas/Error"
3633
+ }
3634
+ }
3635
+ }
3636
+ },
3637
+ "422": {
3638
+ "description": "podServiceAccountName supplied for an agent with a per-agent federated identity",
3639
+ "content": {
3640
+ "application/json": {
3641
+ "schema": {
3642
+ "$ref": "#/components/schemas/Error"
3643
+ }
3644
+ }
3645
+ }
3610
3646
  }
3611
3647
  }
3612
3648
  },
@@ -204,6 +204,12 @@
204
204
  "typeName": {
205
205
  "type": "string"
206
206
  },
207
+ "podServiceAccountName": {
208
+ "type": [
209
+ "string",
210
+ "null"
211
+ ]
212
+ },
207
213
  "accountId": {
208
214
  "type": [
209
215
  "string",
@@ -303,6 +309,7 @@
303
309
  "patchAuthorAllowlist",
304
310
  "restrictSlackToMembers",
305
311
  "typeName",
312
+ "podServiceAccountName",
306
313
  "accountId",
307
314
  "createdAt",
308
315
  "updatedAt",
@@ -372,6 +379,15 @@
372
379
  "minLength": 1,
373
380
  "example": "acct_123"
374
381
  },
382
+ "podServiceAccountName": {
383
+ "type": [
384
+ "string",
385
+ "null"
386
+ ],
387
+ "maxLength": 63,
388
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
389
+ "example": "agent-pod-sa"
390
+ },
375
391
  "trialExpiresAt": {
376
392
  "type": [
377
393
  "string",
@@ -3374,7 +3390,7 @@
3374
3390
  },
3375
3391
  "patch": {
3376
3392
  "summary": "Update an agent",
3377
- "description": "Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, and/or `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). `typeName` and `trialExpiryWarnedAt` are 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.",
3393
+ "description": "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). `typeName` and `trialExpiryWarnedAt` are 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.",
3378
3394
  "parameters": [
3379
3395
  {
3380
3396
  "schema": {
@@ -3435,6 +3451,26 @@
3435
3451
  }
3436
3452
  }
3437
3453
  }
3454
+ },
3455
+ "409": {
3456
+ "description": "podServiceAccountName supplied while the pod ServiceAccount flag is off",
3457
+ "content": {
3458
+ "application/json": {
3459
+ "schema": {
3460
+ "$ref": "#/components/schemas/Error"
3461
+ }
3462
+ }
3463
+ }
3464
+ },
3465
+ "422": {
3466
+ "description": "podServiceAccountName supplied for an agent with a per-agent federated identity",
3467
+ "content": {
3468
+ "application/json": {
3469
+ "schema": {
3470
+ "$ref": "#/components/schemas/Error"
3471
+ }
3472
+ }
3473
+ }
3438
3474
  }
3439
3475
  }
3440
3476
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.5.0",
46
+ "@app-vitals/agent-lib": "0.6.0",
47
47
  "@hono/zod-openapi": "^1.0.0",
48
48
  "@prisma/adapter-pg": "^7.10.0",
49
49
  "@prisma/client": "^7.10.0",
package/src/admin-boot.ts CHANGED
@@ -74,6 +74,7 @@ import { AgentAskService } from "./agent-ask.ts";
74
74
  import { parseAgentAskConfig } from "./agent-ask-config.ts";
75
75
  import { CronRunNowService } from "./cron-run-now.ts";
76
76
  import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
77
+ import { parsePodServiceAccountConfig } from "./pod-service-account-config.ts";
77
78
  import { AgentChatTokenService } from "./agent-chat-tokens.ts";
78
79
  import { AgentCronJobService } from "./agent-cron-jobs.ts";
79
80
  import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
@@ -463,6 +464,7 @@ export async function startAdmin(
463
464
  pool: adminPool,
464
465
  selfServe,
465
466
  anthropicFederationEnabled,
467
+ podServiceAccountEnabled: parsePodServiceAccountConfig(process.env).enabled,
466
468
  chatCredentialsEnabled: parseChatCredentialConfig(process.env).enabled,
467
469
  ...(chatCredentialsClient
468
470
  ? { chatCredentials: chatCredentialsClient }
@@ -15,15 +15,44 @@ import type { AgentProvisioner } from "./agent-provisioner.ts";
15
15
  * already committed, so a reconcile failure is returned as a warning string
16
16
  * (the next full reconcile pass will converge it) instead of failing the call.
17
17
  */
18
- export async function reconcileAgentAfterAccountChange(
18
+ type ReconcilableAgent = Partial<AgentAnthropicIdentity> & {
19
+ id: string;
20
+ name: string;
21
+ selfHosted: boolean;
22
+ accountId?: string | null;
23
+ podServiceAccountName?: string | null;
24
+ };
25
+
26
+ export function reconcileAgentAfterAccountChange(
27
+ provisioner: Pick<AgentProvisioner, "reconcile">,
28
+ agent: ReconcilableAgent,
29
+ ): Promise<string | undefined> {
30
+ return reconcileAgentWorkload(provisioner, agent, "account updated");
31
+ }
32
+
33
+ /**
34
+ * COS-49.1: reconciles the workload after podServiceAccountName changed.
35
+ * `previous` is the column value before the update; the provisioner uses it to
36
+ * clear the live ServiceAccount only when it is still the one it applied.
37
+ */
38
+ export function reconcileAgentAfterPodServiceAccountChange(
39
+ provisioner: Pick<AgentProvisioner, "reconcile">,
40
+ agent: ReconcilableAgent,
41
+ previous: string | null,
42
+ ): Promise<string | undefined> {
43
+ return reconcileAgentWorkload(
44
+ provisioner,
45
+ agent,
46
+ "pod service account updated",
47
+ previous,
48
+ );
49
+ }
50
+
51
+ async function reconcileAgentWorkload(
19
52
  provisioner: Pick<AgentProvisioner, "reconcile">,
20
- agent: Partial<AgentAnthropicIdentity> & {
21
- id: string;
22
- name: string;
23
- selfHosted: boolean;
24
- accountId?: string | null;
25
- podServiceAccountName?: string | null;
26
- },
53
+ agent: ReconcilableAgent,
54
+ what: string,
55
+ previousPodServiceAccountName?: string | null,
27
56
  ): Promise<string | undefined> {
28
57
  if (agent.selfHosted) return undefined;
29
58
  try {
@@ -38,14 +67,17 @@ export async function reconcileAgentAfterAccountChange(
38
67
  anthropicFederationRuleId: agent.anthropicFederationRuleId,
39
68
  k8sServiceAccountName: agent.k8sServiceAccountName,
40
69
  podServiceAccountName: agent.podServiceAccountName,
70
+ ...(previousPodServiceAccountName !== undefined
71
+ ? { previousPodServiceAccountName }
72
+ : {}),
41
73
  },
42
74
  ]);
43
75
  const failure = result.failed.find((f) => f.agentId === agent.id);
44
76
  return failure
45
- ? `account updated, but reconciling the agent workload failed: ${failure.error}`
77
+ ? `${what}, but reconciling the agent workload failed: ${failure.error}`
46
78
  : undefined;
47
79
  } catch (err) {
48
80
  const message = err instanceof Error ? err.message : String(err);
49
- return `account updated, but reconciling the agent workload failed: ${message}`;
81
+ return `${what}, but reconciling the agent workload failed: ${message}`;
50
82
  }
51
83
  }
@@ -151,6 +151,15 @@ export interface ReconcileTarget extends Partial<AgentAnthropicIdentity> {
151
151
  accountId?: string | null;
152
152
  /** The Agent row's pod ServiceAccount (COS-48.1); rendered only when the flag is on. */
153
153
  podServiceAccountName?: string | null;
154
+ /**
155
+ * The pod ServiceAccount this agent had before the change being reconciled
156
+ * (COS-49.1). Only used to clear: when `podServiceAccountName` is now unset,
157
+ * the live ServiceAccount is nulled only if it still equals this value, so a
158
+ * ServiceAccount set by another actor is left alone. The PATCH handler passes
159
+ * the pre-update column value; startup/full passes leave it absent (so they
160
+ * never clear anything).
161
+ */
162
+ previousPodServiceAccountName?: string | null;
154
163
  }
155
164
 
156
165
  export interface AgentProvisioner {
@@ -701,6 +710,44 @@ export function federationPatch(
701
710
  return Object.keys(patch).length > 0 ? patch : undefined;
702
711
  }
703
712
 
713
+ /** The pod-identity patch: the ServiceAccount fields only. */
714
+ interface PodIdentityPatch {
715
+ serviceAccountName: string | null;
716
+ serviceAccount?: null;
717
+ }
718
+
719
+ /**
720
+ * Strategic-merge fragment that brings the live pod ServiceAccount to the
721
+ * Agent row's podServiceAccountName (COS-49.1), for Deployments without
722
+ * federation wiring (federationPatch owns the ServiceAccount when the desired
723
+ * Deployment carries federation).
724
+ *
725
+ * - Desired ServiceAccount set: patched when the live one differs.
726
+ * - Desired unset: cleared (serviceAccountName and the deprecated
727
+ * `serviceAccount` alias nulled) ONLY when the live value equals
728
+ * `previous`, the value this admin previously applied. A ServiceAccount set
729
+ * by another actor therefore survives, as does everything when `previous`
730
+ * is unknown (startup passes).
731
+ */
732
+ export function podIdentityPatch(
733
+ live: KubernetesDeployment,
734
+ desired: KubernetesDeployment,
735
+ previous?: string | null,
736
+ ): PodIdentityPatch | undefined {
737
+ const cur = federationParts(live);
738
+ const want = federationParts(desired);
739
+ if (want.volume || want.env.size > 0) return undefined;
740
+ if (want.serviceAccountName) {
741
+ return cur.serviceAccountName !== want.serviceAccountName
742
+ ? { serviceAccountName: want.serviceAccountName }
743
+ : undefined;
744
+ }
745
+ if (previous && cur.serviceAccountName === previous) {
746
+ return { serviceAccountName: null, serviceAccount: null };
747
+ }
748
+ return undefined;
749
+ }
750
+
704
751
  function isConflict(err: unknown): boolean {
705
752
  return err instanceof ConflictError;
706
753
  }
@@ -1420,6 +1467,16 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1420
1467
  const federation = manageFederation
1421
1468
  ? federationPatch(deployment, desiredDeployment)
1422
1469
  : undefined;
1470
+ // Pod ServiceAccount (COS-49.1). Runs under policyOnly too, so an
1471
+ // admin restart never regresses a Deployment to the wrong identity.
1472
+ const podIdentity =
1473
+ federation?.serviceAccountName === undefined
1474
+ ? podIdentityPatch(
1475
+ deployment,
1476
+ desiredDeployment,
1477
+ agent.previousPodServiceAccountName,
1478
+ )
1479
+ : undefined;
1423
1480
  const containerDrift =
1424
1481
  !policyOnly &&
1425
1482
  current !== undefined &&
@@ -1427,7 +1484,11 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1427
1484
  containerDrifted(current, desired);
1428
1485
  if (
1429
1486
  desired &&
1430
- (labelsDrifted || containerDrift || policy || federation)
1487
+ (labelsDrifted ||
1488
+ containerDrift ||
1489
+ policy ||
1490
+ federation ||
1491
+ podIdentity)
1431
1492
  ) {
1432
1493
  // Under policyOnly the container entry carries only the policy and
1433
1494
  // federation mount/env changes (if any); image, the rest of env,
@@ -1466,11 +1527,15 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1466
1527
  ...(policy?.volumes ?? []),
1467
1528
  ...(federation?.volumes ?? []),
1468
1529
  ];
1469
- const serviceAccount =
1530
+ const saPatch =
1470
1531
  federation?.serviceAccountName !== undefined
1532
+ ? federation
1533
+ : podIdentity;
1534
+ const serviceAccount =
1535
+ saPatch?.serviceAccountName !== undefined
1471
1536
  ? {
1472
- serviceAccountName: federation.serviceAccountName,
1473
- ...(federation.serviceAccount === null
1537
+ serviceAccountName: saPatch.serviceAccountName,
1538
+ ...(saPatch.serviceAccount === null
1474
1539
  ? { serviceAccount: null }
1475
1540
  : {}),
1476
1541
  }
@@ -21,6 +21,7 @@ import {
21
21
  type KubernetesAgentProvisionerConfig,
22
22
  NoopAgentProvisioner,
23
23
  perAgentFederation,
24
+ podIdentityPatch,
24
25
  policyDrifted,
25
26
  } from "./agent-provisioner.ts";
26
27
  import type { AgentTokenService } from "./agent-tokens.ts";
@@ -1138,6 +1139,7 @@ function manifestWith(
1138
1139
  overrides: {
1139
1140
  imageTag?: string;
1140
1141
  anthropicFederation?: AgentAnthropicFederation;
1142
+ podServiceAccountName?: string;
1141
1143
  } = {},
1142
1144
  ) {
1143
1145
  return buildAgentDeploymentManifest({
@@ -1151,6 +1153,7 @@ function manifestWith(
1151
1153
  tokenSecretKey: "token",
1152
1154
  claudePolicy,
1153
1155
  anthropicFederation: overrides.anthropicFederation,
1156
+ podServiceAccountName: overrides.podServiceAccountName,
1154
1157
  });
1155
1158
  }
1156
1159
 
@@ -1898,3 +1901,136 @@ describe("KubernetesAgentProvisioner — pod service account (COS-48.1)", () =>
1898
1901
  ).toBeUndefined();
1899
1902
  });
1900
1903
  });
1904
+
1905
+ // ─── Pod ServiceAccount drift (COS-49.1) ────────────────────────────────────
1906
+
1907
+ describe("podIdentityPatch()", () => {
1908
+ const withSa = (name?: string) =>
1909
+ manifestWith(undefined, { podServiceAccountName: name });
1910
+
1911
+ it("patches the desired ServiceAccount when the live one differs", () => {
1912
+ expect(podIdentityPatch(withSa(), withSa("agent-sa"))).toEqual({
1913
+ serviceAccountName: "agent-sa",
1914
+ });
1915
+ expect(podIdentityPatch(withSa("old-sa"), withSa("agent-sa"))).toEqual({
1916
+ serviceAccountName: "agent-sa",
1917
+ });
1918
+ });
1919
+
1920
+ it("is undefined when already in sync", () => {
1921
+ expect(
1922
+ podIdentityPatch(withSa("agent-sa"), withSa("agent-sa")),
1923
+ ).toBeUndefined();
1924
+ });
1925
+
1926
+ it("clears serviceAccountName and serviceAccount when live equals the previous value", () => {
1927
+ expect(podIdentityPatch(withSa("agent-sa"), withSa(), "agent-sa")).toEqual({
1928
+ serviceAccountName: null,
1929
+ serviceAccount: null,
1930
+ });
1931
+ });
1932
+
1933
+ it("leaves a foreign ServiceAccount alone when clearing", () => {
1934
+ expect(
1935
+ podIdentityPatch(withSa("hand-set"), withSa(), "agent-sa"),
1936
+ ).toBeUndefined();
1937
+ });
1938
+
1939
+ it("never clears when the previous value is unknown", () => {
1940
+ expect(podIdentityPatch(withSa("agent-sa"), withSa())).toBeUndefined();
1941
+ expect(
1942
+ podIdentityPatch(withSa("agent-sa"), withSa(), null),
1943
+ ).toBeUndefined();
1944
+ });
1945
+
1946
+ it("defers to federationPatch when the desired Deployment has federation", () => {
1947
+ expect(
1948
+ podIdentityPatch(withSa("x"), fedManifest(FED_A), "x"),
1949
+ ).toBeUndefined();
1950
+ });
1951
+ });
1952
+
1953
+ describe("KubernetesAgentProvisioner.reconcile() — pod ServiceAccount drift (COS-49.1)", () => {
1954
+ const live = (sa?: string) =>
1955
+ new RecordedKubernetesClient({
1956
+ deployments: {
1957
+ [`${NAMESPACE}/${POLICY_RESOURCE}`]: manifestWith(undefined, {
1958
+ podServiceAccountName: sa,
1959
+ }),
1960
+ },
1961
+ secrets: {},
1962
+ pvcs: {},
1963
+ });
1964
+ const provisioner = (client: KubernetesClient, enabled = true) =>
1965
+ new KubernetesAgentProvisioner(client, stubTokens() as AgentTokenService, {
1966
+ ...BASE_CONFIG,
1967
+ podServiceAccount: { enabled },
1968
+ });
1969
+ const sa = async (rec: RecordedKubernetesClient) =>
1970
+ (await rec.getDeployment(NAMESPACE, POLICY_RESOURCE)).spec.template.spec
1971
+ .serviceAccountName;
1972
+
1973
+ for (const policyOnly of [false, true]) {
1974
+ it(`patches the desired ServiceAccount when live differs (policyOnly=${policyOnly})`, async () => {
1975
+ const rec = live("old-sa");
1976
+ const { client, patches } = patchSpy(rec);
1977
+ const result = await provisioner(client).reconcile(
1978
+ [{ id: POLICY_AGENT_ID, podServiceAccountName: "agent-sa" }],
1979
+ { policyOnly },
1980
+ );
1981
+ expect(result.updated).toEqual([POLICY_AGENT_ID]);
1982
+ expect(patches).toHaveLength(1);
1983
+ expect(await sa(rec)).toBe("agent-sa");
1984
+ });
1985
+ }
1986
+
1987
+ it("clears a previously managed ServiceAccount", async () => {
1988
+ const rec = live("agent-sa");
1989
+ const result = await provisioner(rec).reconcile([
1990
+ {
1991
+ id: POLICY_AGENT_ID,
1992
+ podServiceAccountName: null,
1993
+ previousPodServiceAccountName: "agent-sa",
1994
+ },
1995
+ ]);
1996
+ expect(result.updated).toEqual([POLICY_AGENT_ID]);
1997
+ expect(await sa(rec)).toBeUndefined();
1998
+ });
1999
+
2000
+ it("leaves a hand-set ServiceAccount on an unmanaged Deployment alone", async () => {
2001
+ const rec = live("hand-set");
2002
+ const { client, patches } = patchSpy(rec);
2003
+ const result = await provisioner(client).reconcile(
2004
+ [
2005
+ {
2006
+ id: POLICY_AGENT_ID,
2007
+ podServiceAccountName: null,
2008
+ previousPodServiceAccountName: "agent-sa",
2009
+ },
2010
+ ],
2011
+ { policyOnly: true },
2012
+ );
2013
+ expect(result.updated).toEqual([]);
2014
+ expect(patches).toHaveLength(0);
2015
+ expect(await sa(rec)).toBe("hand-set");
2016
+ });
2017
+
2018
+ it("does nothing for a clear without a previous value (startup pass)", async () => {
2019
+ const rec = live("agent-sa");
2020
+ const { client, patches } = patchSpy(rec);
2021
+ await provisioner(client).reconcile(
2022
+ [{ id: POLICY_AGENT_ID, podServiceAccountName: null }],
2023
+ { policyOnly: true },
2024
+ );
2025
+ expect(patches).toHaveLength(0);
2026
+ });
2027
+
2028
+ it("does not patch when the flag is off", async () => {
2029
+ const rec = live("hand-set");
2030
+ const { client, patches } = patchSpy(rec);
2031
+ await provisioner(client, false).reconcile([
2032
+ { id: POLICY_AGENT_ID, podServiceAccountName: "agent-sa" },
2033
+ ]);
2034
+ expect(patches).toHaveLength(0);
2035
+ });
2036
+ });
@@ -703,3 +703,150 @@ describeOrSkip("admin CRUD API (integration)", () => {
703
703
  expect((await fleet.json()).totals.input).toBe(510);
704
704
  });
705
705
  });
706
+
707
+ // ─── COS-49.1: PATCH /agents/:id podServiceAccountName ──────────────────────────
708
+
709
+ describeOrSkip("PATCH /agents/:id podServiceAccountName (integration)", () => {
710
+ let prisma: PrismaClient;
711
+ let agentId: string;
712
+ let cookie: string;
713
+ const reconciled: Array<Record<string, unknown>> = [];
714
+
715
+ const provisioner: AgentProvisioner = {
716
+ canProvision: true,
717
+ provision: async () => ({
718
+ resourceName: "r",
719
+ secretName: "s",
720
+ deploymentName: "d",
721
+ }),
722
+ deprovision: async () => {},
723
+ reconcile: async (targets: Array<{ id: string }>) => {
724
+ for (const t of targets) reconciled.push(t as Record<string, unknown>);
725
+ return {
726
+ recreated: [],
727
+ updated: [],
728
+ migrated: [],
729
+ noOwner: [],
730
+ orphans: [],
731
+ failed: [],
732
+ dryRun: false,
733
+ };
734
+ },
735
+ } as unknown as AgentProvisioner;
736
+
737
+ const patch = (
738
+ app: ReturnType<typeof createAdminApp>,
739
+ body: unknown,
740
+ headers: Record<string, string> = { Cookie: `admin_session=${cookie}` },
741
+ ) =>
742
+ app.request(`/agents/${agentId}`, {
743
+ method: "PATCH",
744
+ body: JSON.stringify(body),
745
+ headers: { "Content-Type": "application/json", ...headers },
746
+ });
747
+
748
+ beforeEach(async () => {
749
+ prisma = makePrisma();
750
+ await prisma.agentWorkQueueSnapshot.deleteMany();
751
+ await prisma.agentPlugin.deleteMany();
752
+ await prisma.agentPhaseMethodology.deleteMany();
753
+ await prisma.agentToken.deleteMany();
754
+ await prisma.agentCronJob.deleteMany();
755
+ await prisma.agentTool.deleteMany();
756
+ await prisma.agentEnv.deleteMany();
757
+ await prisma.agentMember.deleteMany();
758
+ await prisma.agent.deleteMany();
759
+ agentId = await createAgent(prisma);
760
+ cookie = await makeSessionCookie();
761
+ reconciled.length = 0;
762
+ });
763
+
764
+ afterEach(async () => {
765
+ await prisma.$disconnect();
766
+ });
767
+
768
+ const appWith = (enabled: boolean) =>
769
+ createAdminApp({
770
+ ...makeDeps(prisma, provisioner),
771
+ podServiceAccountEnabled: enabled,
772
+ });
773
+
774
+ it("409 when the flag is off; the column is untouched", async () => {
775
+ const res = await patch(appWith(false), {
776
+ podServiceAccountName: "sa",
777
+ });
778
+ expect(res.status).toBe(409);
779
+ const row = await prisma.agent.findUniqueOrThrow({
780
+ where: { id: agentId },
781
+ });
782
+ expect(row.podServiceAccountName).toBeNull();
783
+ expect(reconciled).toEqual([]);
784
+ });
785
+
786
+ it("403 for a non-admin session", async () => {
787
+ const memberCookie = await sign(
788
+ {
789
+ isAdmin: false,
790
+ userId: "user-m",
791
+ email: "member@example.com",
792
+ iat: Math.floor(Date.now() / 1000),
793
+ exp: Math.floor(Date.now() / 1000) + 3600,
794
+ },
795
+ SESSION_SECRET,
796
+ "HS256",
797
+ );
798
+ await prisma.agentMember.create({
799
+ data: { agentId, email: "member@example.com" },
800
+ });
801
+ const res = await patch(
802
+ appWith(true),
803
+ { podServiceAccountName: "sa" },
804
+ { Cookie: `admin_session=${memberCookie}` },
805
+ );
806
+ expect(res.status).toBe(403);
807
+ expect(reconciled).toEqual([]);
808
+ });
809
+
810
+ it("422 for an agent with a per-agent federated identity", async () => {
811
+ await prisma.agent.update({
812
+ where: { id: agentId },
813
+ data: {
814
+ ownerEmail: "jane.doe@example.com",
815
+ anthropicIdentityName: "id-49-1",
816
+ anthropicServiceAccountId: "svac_1",
817
+ anthropicFederationRuleId: "fdrl_1",
818
+ k8sServiceAccountName: "agent-sa",
819
+ },
820
+ });
821
+ const res = await patch(appWith(true), { podServiceAccountName: "sa" });
822
+ expect(res.status).toBe(422);
823
+ expect(reconciled).toEqual([]);
824
+ });
825
+
826
+ it("200 persists the value, serializes it and reconciles once; null clears", async () => {
827
+ const app = appWith(true);
828
+ const res = await patch(app, { podServiceAccountName: "agent-pod-sa" });
829
+ expect(res.status).toBe(200);
830
+ expect((await res.json()).podServiceAccountName).toBe("agent-pod-sa");
831
+ expect(reconciled).toHaveLength(1);
832
+ expect(reconciled[0]).toMatchObject({
833
+ id: agentId,
834
+ podServiceAccountName: "agent-pod-sa",
835
+ previousPodServiceAccountName: null,
836
+ });
837
+
838
+ const get = await app.request(`/agents/${agentId}`, {
839
+ headers: { Cookie: `admin_session=${cookie}` },
840
+ });
841
+ expect((await get.json()).podServiceAccountName).toBe("agent-pod-sa");
842
+
843
+ const clear = await patch(app, { podServiceAccountName: null });
844
+ expect(clear.status).toBe(200);
845
+ expect((await clear.json()).podServiceAccountName).toBeNull();
846
+ expect(reconciled).toHaveLength(2);
847
+ expect(reconciled[1]).toMatchObject({
848
+ podServiceAccountName: null,
849
+ previousPodServiceAccountName: "agent-pod-sa",
850
+ });
851
+ });
852
+ });
@@ -6,7 +6,7 @@
6
6
  * Services are injected as in-memory mocks.
7
7
  */
8
8
 
9
- import { beforeAll, describe, expect, it } from "bun:test";
9
+ import { beforeAll, describe, expect, it, spyOn } from "bun:test";
10
10
  import { sign } from "hono/jwt";
11
11
  import type { Prisma } from "../prisma/client/client.ts";
12
12
  import { getSelfServeSpecialty } from "./account-agent-create.ts";
@@ -5515,6 +5515,229 @@ describe("admin API — PATCH /agents/:id accountId (SSP-5.3)", () => {
5515
5515
  });
5516
5516
  });
5517
5517
 
5518
+ // ─── COS-49.1: PATCH /agents/:id podServiceAccountName ───────────────────────
5519
+
5520
+ describe("admin API — PATCH /agents/:id podServiceAccountName (COS-49.1)", () => {
5521
+ const patch = (
5522
+ app: ReturnType<typeof createAdminApp>,
5523
+ body: unknown,
5524
+ headers: Record<string, string>,
5525
+ ) =>
5526
+ app.request(`/agents/${AGENT_ID}`, {
5527
+ method: "PATCH",
5528
+ body: JSON.stringify(body),
5529
+ headers: { "Content-Type": "application/json", ...headers },
5530
+ });
5531
+
5532
+ const FEDERATED = {
5533
+ ownerEmail: "jane.doe@example.com",
5534
+ anthropicIdentityName: "id",
5535
+ anthropicServiceAccountId: "svac_1",
5536
+ anthropicFederationRuleId: "fdrl_1",
5537
+ k8sServiceAccountName: "agent-sa",
5538
+ };
5539
+
5540
+ function depsFor(opts: {
5541
+ provisioner: RecordingProvisioner;
5542
+ enabled?: boolean;
5543
+ existing?: Record<string, unknown>;
5544
+ updated?: Array<Record<string, unknown>>;
5545
+ }): AdminDeps {
5546
+ const base = makeMockDeps();
5547
+ return {
5548
+ ...base,
5549
+ provisioner: opts.provisioner,
5550
+ podServiceAccountEnabled: opts.enabled ?? true,
5551
+ agentService: {
5552
+ ...base.agentService,
5553
+ getDetail: async (id: string) => {
5554
+ const d = await base.agentService.getDetail(id);
5555
+ return d ? ({ ...d, ...opts.existing } as typeof d) : d;
5556
+ },
5557
+ updateSelfHosted: async (id, input) => {
5558
+ opts.updated?.push(input as Record<string, unknown>);
5559
+ return {
5560
+ ...(await base.agentService.updateSelfHosted(id, input)),
5561
+ podServiceAccountName:
5562
+ (input as { podServiceAccountName?: string | null })
5563
+ .podServiceAccountName ?? null,
5564
+ };
5565
+ },
5566
+ },
5567
+ };
5568
+ }
5569
+
5570
+ it("admin sets the value: 200, serialized, and reconcile invoked once with the previous value", async () => {
5571
+ const provisioner = new RecordingProvisioner();
5572
+ const app = createAdminApp(
5573
+ depsFor({
5574
+ provisioner,
5575
+ existing: { podServiceAccountName: "old-sa" },
5576
+ }),
5577
+ );
5578
+ const cookie = await makeSessionCookie();
5579
+ const res = await patch(
5580
+ app,
5581
+ { podServiceAccountName: "new-sa" },
5582
+ { Cookie: `admin_session=${cookie}` },
5583
+ );
5584
+ expect(res.status).toBe(200);
5585
+ expect(
5586
+ ((await res.json()) as { podServiceAccountName: string })
5587
+ .podServiceAccountName,
5588
+ ).toBe("new-sa");
5589
+ expect(provisioner.reconciled).toHaveLength(1);
5590
+ expect(provisioner.reconciled[0]).toMatchObject({
5591
+ id: AGENT_ID,
5592
+ podServiceAccountName: "new-sa",
5593
+ previousPodServiceAccountName: "old-sa",
5594
+ });
5595
+ });
5596
+
5597
+ it("null clears the value and passes the previous one", async () => {
5598
+ const provisioner = new RecordingProvisioner();
5599
+ const app = createAdminApp(
5600
+ depsFor({ provisioner, existing: { podServiceAccountName: "old-sa" } }),
5601
+ );
5602
+ const cookie = await makeSessionCookie();
5603
+ const res = await patch(
5604
+ app,
5605
+ { podServiceAccountName: null },
5606
+ { Cookie: `admin_session=${cookie}` },
5607
+ );
5608
+ expect(res.status).toBe(200);
5609
+ expect(provisioner.reconciled[0]).toMatchObject({
5610
+ podServiceAccountName: null,
5611
+ previousPodServiceAccountName: "old-sa",
5612
+ });
5613
+ });
5614
+
5615
+ it("409 naming the flag when it is off; nothing written or reconciled", async () => {
5616
+ const provisioner = new RecordingProvisioner();
5617
+ const updated: Array<Record<string, unknown>> = [];
5618
+ const app = createAdminApp(
5619
+ depsFor({ provisioner, enabled: false, updated }),
5620
+ );
5621
+ const cookie = await makeSessionCookie();
5622
+ const res = await patch(
5623
+ app,
5624
+ { podServiceAccountName: "sa" },
5625
+ { Cookie: `admin_session=${cookie}` },
5626
+ );
5627
+ expect(res.status).toBe(409);
5628
+ expect(JSON.stringify(await res.json())).toContain(
5629
+ "SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED",
5630
+ );
5631
+ expect(updated).toEqual([]);
5632
+ expect(provisioner.reconciled).toEqual([]);
5633
+ });
5634
+
5635
+ it("422 for an agent with a per-agent federated identity", async () => {
5636
+ const provisioner = new RecordingProvisioner();
5637
+ const updated: Array<Record<string, unknown>> = [];
5638
+ const app = createAdminApp(
5639
+ depsFor({ provisioner, existing: FEDERATED, updated }),
5640
+ );
5641
+ const cookie = await makeSessionCookie();
5642
+ const res = await patch(
5643
+ app,
5644
+ { podServiceAccountName: "sa" },
5645
+ { Cookie: `admin_session=${cookie}` },
5646
+ );
5647
+ expect(res.status).toBe(422);
5648
+ expect(updated).toEqual([]);
5649
+ expect(provisioner.reconciled).toEqual([]);
5650
+ });
5651
+
5652
+ it("403 for a non-admin session member", async () => {
5653
+ const provisioner = new RecordingProvisioner();
5654
+ const app = createAdminApp({
5655
+ ...depsFor({ provisioner }),
5656
+ callerScopeResolver: async () => ({
5657
+ kind: "scoped",
5658
+ accountId: "acct-a",
5659
+ agentIds: [AGENT_ID],
5660
+ }),
5661
+ });
5662
+ const cookie = await sign(
5663
+ {
5664
+ isAdmin: false,
5665
+ userId: "user-acct",
5666
+ email: "owner@acct-a.example.com",
5667
+ iat: Math.floor(Date.now() / 1000),
5668
+ exp: Math.floor(Date.now() / 1000) + 3600,
5669
+ },
5670
+ SESSION_SECRET,
5671
+ "HS256",
5672
+ );
5673
+ const res = await patch(
5674
+ app,
5675
+ { podServiceAccountName: "sa" },
5676
+ { Cookie: `admin_session=${cookie}` },
5677
+ );
5678
+ expect(res.status).toBe(403);
5679
+ expect(provisioner.reconciled).toEqual([]);
5680
+ });
5681
+
5682
+ it("rejects a value that is not an RFC 1123 label (400)", async () => {
5683
+ const app = createAdminApp(
5684
+ depsFor({ provisioner: new RecordingProvisioner() }),
5685
+ );
5686
+ const cookie = await makeSessionCookie();
5687
+ for (const bad of ["Bad_Name", "-lead", "a".repeat(64), ""]) {
5688
+ const res = await patch(
5689
+ app,
5690
+ { podServiceAccountName: bad },
5691
+ { Cookie: `admin_session=${cookie}` },
5692
+ );
5693
+ expect(res.status).toBe(400);
5694
+ }
5695
+ });
5696
+
5697
+ it("logs an audit line with agent, old, new and caller name but never the key", async () => {
5698
+ const provisioner = new RecordingProvisioner();
5699
+ const app = createAdminApp({
5700
+ ...depsFor({
5701
+ provisioner,
5702
+ existing: { podServiceAccountName: "old-sa" },
5703
+ }),
5704
+ adminApiKeys: parseAdminApiKeys(`ops-key-name:${ADMIN_API_KEY}:*`),
5705
+ });
5706
+ const spy = spyOn(console, "info").mockImplementation(() => {});
5707
+ try {
5708
+ const res = await patch(
5709
+ app,
5710
+ { podServiceAccountName: "new-sa" },
5711
+ { Authorization: `Bearer ${ADMIN_API_KEY}` },
5712
+ );
5713
+ expect(res.status).toBe(200);
5714
+ const lines = spy.mock.calls.map((c) => String(c[0]));
5715
+ const line = lines.find((l) => l.includes("podServiceAccountName"));
5716
+ expect(line).toBeDefined();
5717
+ expect(line).toContain(AGENT_ID);
5718
+ expect(line).toContain("old-sa");
5719
+ expect(line).toContain("new-sa");
5720
+ expect(line).toContain("ops-key-name");
5721
+ expect(lines.join("\n")).not.toContain(ADMIN_API_KEY);
5722
+ } finally {
5723
+ spy.mockRestore();
5724
+ }
5725
+ });
5726
+
5727
+ it("does not touch the provisioner when the field is absent", async () => {
5728
+ const provisioner = new RecordingProvisioner();
5729
+ const app = createAdminApp(depsFor({ provisioner, enabled: false }));
5730
+ const cookie = await makeSessionCookie();
5731
+ const res = await patch(
5732
+ app,
5733
+ { repos: ["org/repo"] },
5734
+ { Cookie: `admin_session=${cookie}` },
5735
+ );
5736
+ expect(res.status).toBe(200);
5737
+ expect(provisioner.reconciled).toEqual([]);
5738
+ });
5739
+ });
5740
+
5518
5741
  // ─── WIF-2.1: Anthropic credential guards under federation ───────────────────
5519
5742
 
5520
5743
  describe("admin API — Anthropic credential guards (WIF-2.1)", () => {
package/src/agents-api.ts CHANGED
@@ -39,7 +39,10 @@ import {
39
39
  getSelfServeSpecialty,
40
40
  isAccountCreateErrorCode,
41
41
  } from "./account-agent-create.ts";
42
- import { reconcileAgentAfterAccountChange } from "./agent-account-assignment.ts";
42
+ import {
43
+ reconcileAgentAfterAccountChange,
44
+ reconcileAgentAfterPodServiceAccountChange,
45
+ } from "./agent-account-assignment.ts";
43
46
  import {
44
47
  type AgentAskDeps,
45
48
  type AskChatClient,
@@ -62,7 +65,10 @@ import type { AgentEnvService } from "./agent-envs.ts";
62
65
  import type { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
63
66
  import type { AgentMemberService } from "./agent-members.ts";
64
67
  import type { AgentPluginService } from "./agent-plugins.ts";
65
- import type { AgentProvisioner } from "./agent-provisioner.ts";
68
+ import {
69
+ type AgentProvisioner,
70
+ hasPerAgentIdentity,
71
+ } from "./agent-provisioner.ts";
66
72
  import type { AgentTokenService } from "./agent-tokens.ts";
67
73
  import type { AgentToolService } from "./agent-tools.ts";
68
74
  import type { AgentService } from "./agents.ts";
@@ -94,8 +100,10 @@ import {
94
100
  import {
95
101
  ApiError,
96
102
  BadRequestError,
103
+ ConflictError,
97
104
  ForbiddenError,
98
105
  NotFoundError,
106
+ UnprocessableEntityError,
99
107
  } from "./errors.ts";
100
108
  import {
101
109
  AddAgentReposBodySchema,
@@ -287,6 +295,12 @@ export interface AdminDeps {
287
295
  >;
288
296
  /** WIF-2.1: Anthropic federation is enabled; defaults to false. */
289
297
  anthropicFederationEnabled?: boolean;
298
+ /**
299
+ * COS-49.1: per-agent pod ServiceAccount flag
300
+ * (SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED). Off (default) -> PATCH
301
+ * /agents/:id with podServiceAccountName returns 409.
302
+ */
303
+ podServiceAccountEnabled?: boolean;
290
304
  /**
291
305
  * Agent-to-agent ask (COS-31.1): flag + allowlist config, the AgentAsk
292
306
  * audit store and an injectable clock. Absent or flag off → the ask routes
@@ -397,6 +411,8 @@ const GetAgentResultSchema = z
397
411
  patchAuthorAllowlist: z.array(z.string()),
398
412
  restrictSlackToMembers: z.boolean(),
399
413
  typeName: z.string(),
414
+ /** COS-49.1: the pod ServiceAccount; null when unset. */
415
+ podServiceAccountName: z.string().nullable(),
400
416
  /** Owning account; null for unassigned agents (task-store maps null to its 'default' account). */
401
417
  accountId: z.string().nullable(),
402
418
  createdAt: z.string().datetime(),
@@ -604,7 +620,7 @@ const patchAgentRoute = createRoute({
604
620
  path: "/agents/{id}",
605
621
  summary: "Update an agent",
606
622
  description:
607
- "Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, and/or `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). `typeName` and `trialExpiryWarnedAt` are 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.",
623
+ "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). `typeName` and `trialExpiryWarnedAt` are 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.",
608
624
  request: {
609
625
  params: AgentIdParamSchema,
610
626
  body: {
@@ -619,6 +635,16 @@ const patchAgentRoute = createRoute({
619
635
  400: { description: "Bad request", ...jsonError },
620
636
  403: { description: "Forbidden", ...jsonError },
621
637
  404: { description: "Agent not found", ...jsonError },
638
+ 409: {
639
+ description:
640
+ "podServiceAccountName supplied while the pod ServiceAccount flag is off",
641
+ ...jsonError,
642
+ },
643
+ 422: {
644
+ description:
645
+ "podServiceAccountName supplied for an agent with a per-agent federated identity",
646
+ ...jsonError,
647
+ },
622
648
  },
623
649
  });
624
650
 
@@ -1657,6 +1683,39 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1657
1683
  throw new ForbiddenError("Admin access required to change accountId");
1658
1684
  }
1659
1685
  }
1686
+ // COS-49.1: pod ServiceAccount — platform-admin only, flag-gated, and not
1687
+ // for agents whose federated identity owns the ServiceAccount.
1688
+ let previousPodServiceAccountName: string | null = null;
1689
+ if (body.podServiceAccountName !== undefined) {
1690
+ if (c.get("isAdmin") !== true) {
1691
+ throw new ForbiddenError(
1692
+ "Admin access required to change podServiceAccountName",
1693
+ );
1694
+ }
1695
+ if (deps.podServiceAccountEnabled !== true) {
1696
+ throw new ConflictError(
1697
+ "podServiceAccountName cannot be changed: SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED is not enabled",
1698
+ );
1699
+ }
1700
+ const current = await agentService.getDetail(agentId);
1701
+ if (!current) {
1702
+ throw new NotFoundError(`agent ${agentId} not found`);
1703
+ }
1704
+ if (
1705
+ hasPerAgentIdentity({
1706
+ ownerEmail: current.ownerEmail ?? null,
1707
+ anthropicIdentityName: current.anthropicIdentityName ?? null,
1708
+ anthropicServiceAccountId: current.anthropicServiceAccountId ?? null,
1709
+ anthropicFederationRuleId: current.anthropicFederationRuleId ?? null,
1710
+ k8sServiceAccountName: current.k8sServiceAccountName ?? null,
1711
+ })
1712
+ ) {
1713
+ throw new UnprocessableEntityError(
1714
+ "podServiceAccountName cannot be set on an agent with a per-agent federated identity (federation owns its ServiceAccount)",
1715
+ );
1716
+ }
1717
+ previousPodServiceAccountName = current.podServiceAccountName ?? null;
1718
+ }
1660
1719
  const collision =
1661
1720
  body.repos !== undefined ? describeRepoNameCollision(body.repos) : null;
1662
1721
  if (collision) {
@@ -1665,6 +1724,9 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1665
1724
  const agent = await agentService
1666
1725
  .updateSelfHosted(agentId, {
1667
1726
  selfHosted: body.selfHosted,
1727
+ ...(body.podServiceAccountName !== undefined
1728
+ ? { podServiceAccountName: body.podServiceAccountName }
1729
+ : {}),
1668
1730
  ...(body.accountId !== undefined ? { accountId: body.accountId } : {}),
1669
1731
  ...(body.repos !== undefined ? { repos: body.repos } : {}),
1670
1732
  ...(body.reviewAuthorAllowlist !== undefined
@@ -1719,8 +1781,24 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1719
1781
  body.accountId !== undefined
1720
1782
  ? await reconcileAgentAfterAccountChange(provisioner, agent)
1721
1783
  : undefined;
1784
+ let podSaWarning: string | undefined;
1785
+ if (body.podServiceAccountName !== undefined) {
1786
+ // Audit: ids and names only — never the caller's key.
1787
+ console.info(
1788
+ `[agents-api] podServiceAccountName changed: agent=${agentId} old=${previousPodServiceAccountName ?? "null"} new=${body.podServiceAccountName ?? "null"} caller=${c.get("caller")?.name ?? "anonymous"}`,
1789
+ );
1790
+ if (body.podServiceAccountName !== previousPodServiceAccountName) {
1791
+ podSaWarning = await reconcileAgentAfterPodServiceAccountChange(
1792
+ provisioner,
1793
+ agent,
1794
+ previousPodServiceAccountName,
1795
+ );
1796
+ }
1797
+ }
1722
1798
  const warning =
1723
- [slackWarning, reconcileWarning].filter(Boolean).join("; ") || undefined;
1799
+ [slackWarning, reconcileWarning, podSaWarning]
1800
+ .filter(Boolean)
1801
+ .join("; ") || undefined;
1724
1802
  return c.json(serializeAgent(agent, warning), 200);
1725
1803
  });
1726
1804
 
@@ -2739,6 +2817,7 @@ function serializeAgent(
2739
2817
  restrictSlackToMembers?: boolean;
2740
2818
  typeName: string;
2741
2819
  accountId?: string | null;
2820
+ podServiceAccountName?: string | null;
2742
2821
  createdAt: Date;
2743
2822
  updatedAt: Date;
2744
2823
  missingRequiredEnv?: string[];
@@ -2768,6 +2847,7 @@ function serializeAgent(
2768
2847
  restrictSlackToMembers: agent.restrictSlackToMembers ?? false,
2769
2848
  typeName: agent.typeName,
2770
2849
  accountId: agent.accountId ?? null,
2850
+ podServiceAccountName: agent.podServiceAccountName ?? null,
2771
2851
  createdAt: agent.createdAt.toISOString(),
2772
2852
  updatedAt: agent.updatedAt.toISOString(),
2773
2853
  missingRequiredEnv: agent.missingRequiredEnv ?? [],
package/src/agents.ts CHANGED
@@ -87,6 +87,8 @@ export interface AgentSummary extends Partial<AgentAnthropicIdentity> {
87
87
  }
88
88
 
89
89
  export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
90
+ /** COS-49.1 pod ServiceAccount; null when unset. */
91
+ podServiceAccountName?: string | null;
90
92
  id: string;
91
93
  name: string;
92
94
  slackId: string | null;
@@ -137,6 +139,8 @@ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
137
139
  }
138
140
 
139
141
  interface UpdateSelfHostedInput {
142
+ /** COS-49.1: pod ServiceAccount; null clears it. */
143
+ podServiceAccountName?: string | null;
140
144
  /** SSP-5.3: owning account; null clears it (unassigned/platform agent). */
141
145
  accountId?: string | null;
142
146
  /**
@@ -472,6 +476,9 @@ export class AgentService {
472
476
  ...(input.accountId !== undefined
473
477
  ? { accountId: input.accountId }
474
478
  : {}),
479
+ ...(input.podServiceAccountName !== undefined
480
+ ? { podServiceAccountName: input.podServiceAccountName }
481
+ : {}),
475
482
  ...(input.repos !== undefined ? { repos: input.repos } : {}),
476
483
  ...(input.reviewAuthorAllowlist !== undefined
477
484
  ? { reviewAuthorAllowlist: input.reviewAuthorAllowlist }
@@ -510,6 +510,28 @@ describe("RecordedKubernetesClient.patchDeployment()", () => {
510
510
  );
511
511
  });
512
512
 
513
+ it("strategic-merges serviceAccountName: a string sets, null clears (with the alias)", async () => {
514
+ const rec = new RecordedKubernetesClient({
515
+ deployments: { "shipwright/agent-abc": makeDeployment("img:v1") },
516
+ secrets: {},
517
+ });
518
+ const patchSa = (podSpec: object) =>
519
+ rec.patchDeployment("shipwright", "agent-abc", {
520
+ spec: { template: { spec: podSpec } },
521
+ });
522
+ const podSpec = async () =>
523
+ (await rec.getDeployment("shipwright", "agent-abc")).spec.template.spec;
524
+
525
+ await patchSa({ serviceAccountName: "agent-sa" });
526
+ expect((await podSpec()).serviceAccountName).toBe("agent-sa");
527
+ // Untouched containers survive a pod-level-only patch.
528
+ expect((await podSpec()).containers[0]?.image).toBe("img:v1");
529
+
530
+ await patchSa({ serviceAccountName: null, serviceAccount: null });
531
+ expect((await podSpec()).serviceAccountName).toBeUndefined();
532
+ expect((await podSpec()).serviceAccount).toBeUndefined();
533
+ });
534
+
513
535
  it("throws NotFoundError when the deployment does not exist", async () => {
514
536
  const rec = new RecordedKubernetesClient({ deployments: {}, secrets: {} });
515
537
 
@@ -94,6 +94,22 @@ export const PatchAgentBodySchema = z
94
94
  .nullable()
95
95
  .optional()
96
96
  .openapi({ example: "acct_123" }),
97
+ /**
98
+ * COS-49.1: the Kubernetes ServiceAccount the agent's pod runs as
99
+ * (RFC 1123 label). Platform admin only; `null` clears it. Rejected 409
100
+ * unless SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED is on, and 422
101
+ * for agents with a per-agent federated identity.
102
+ */
103
+ podServiceAccountName: z
104
+ .string()
105
+ .max(63)
106
+ .regex(
107
+ /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/,
108
+ "must be an RFC 1123 label (lowercase alphanumerics and '-', start and end alphanumeric)",
109
+ )
110
+ .nullable()
111
+ .optional()
112
+ .openapi({ example: "agent-pod-sa" }),
97
113
  /**
98
114
  * ATE-1.1: trial expiry timestamp. Nullable so it can also be explicitly
99
115
  * cleared. trialExpiryWarnedAt is intentionally NOT part of this schema —
@@ -17,6 +17,7 @@ import {
17
17
  type AgentTool,
18
18
  AgentToolSchema,
19
19
  ErrorSchema,
20
+ PatchAgentBodySchema,
20
21
  } from "./openapi-schemas.ts";
21
22
 
22
23
  // ─── Fixtures ─────────────────────────────────────────────────────────────────
@@ -350,3 +351,33 @@ describe("OpenAPI route metadata", () => {
350
351
  expect(issues).toEqual([]);
351
352
  });
352
353
  });
354
+
355
+ describe("PatchAgentBodySchema.podServiceAccountName (COS-49.1)", () => {
356
+ const parse = (v: unknown) =>
357
+ PatchAgentBodySchema.safeParse({ podServiceAccountName: v });
358
+
359
+ test("accepts RFC 1123 labels", () => {
360
+ for (const ok of ["a", "agent-sa", "sa1", "a".repeat(63)]) {
361
+ expect(parse(ok).success).toBe(true);
362
+ }
363
+ });
364
+
365
+ test("accepts null (clear) and omission", () => {
366
+ expect(parse(null).success).toBe(true);
367
+ expect(PatchAgentBodySchema.safeParse({}).success).toBe(true);
368
+ });
369
+
370
+ test("rejects non-labels and values over 63 characters", () => {
371
+ for (const bad of [
372
+ "",
373
+ "Upper",
374
+ "under_score",
375
+ "-lead",
376
+ "trail-",
377
+ "dot.ted",
378
+ "a".repeat(64),
379
+ ]) {
380
+ expect(parse(bad).success).toBe(false);
381
+ }
382
+ });
383
+ });