@app-vitals/agent-admin 0.5.0 → 0.6.1
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 +37 -1
- package/openapi.platform.json +37 -1
- package/package.json +2 -2
- package/src/admin-boot.ts +2 -0
- package/src/agent-account-assignment.ts +42 -10
- package/src/agent-provisioner.ts +69 -4
- package/src/agent-provisioner.unit.test.ts +136 -0
- package/src/agents-api.integration.test.ts +147 -0
- package/src/agents-api.smoke.test.ts +224 -1
- package/src/agents-api.ts +84 -4
- package/src/agents.ts +7 -0
- package/src/kubernetes-client.unit.test.ts +22 -0
- package/src/openapi-schemas.ts +16 -0
- package/src/openapi-schemas.unit.test.ts +31 -0
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`,
|
|
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
|
},
|
package/openapi.platform.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",
|
|
@@ -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`,
|
|
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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.
|
|
46
|
+
"@app-vitals/agent-lib": "0.6.1",
|
|
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
|
-
|
|
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:
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
?
|
|
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
|
|
81
|
+
return `${what}, but reconciling the agent workload failed: ${message}`;
|
|
50
82
|
}
|
|
51
83
|
}
|
package/src/agent-provisioner.ts
CHANGED
|
@@ -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 ||
|
|
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
|
|
1530
|
+
const saPatch =
|
|
1470
1531
|
federation?.serviceAccountName !== undefined
|
|
1532
|
+
? federation
|
|
1533
|
+
: podIdentity;
|
|
1534
|
+
const serviceAccount =
|
|
1535
|
+
saPatch?.serviceAccountName !== undefined
|
|
1471
1536
|
? {
|
|
1472
|
-
serviceAccountName:
|
|
1473
|
-
...(
|
|
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 {
|
|
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
|
|
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`,
|
|
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]
|
|
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
|
|
package/src/openapi-schemas.ts
CHANGED
|
@@ -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
|
+
});
|