@app-vitals/agent-admin 0.4.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 +104 -1
- package/openapi.platform.json +104 -1
- package/package.json +2 -2
- package/prisma/client/commonInputTypes.ts +75 -0
- package/prisma/client/internal/class.ts +4 -4
- package/prisma/client/internal/prismaNamespace.ts +9 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +9 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +27 -1
- package/prisma/migrations/20261028000000_add_repo_access_to_github_installations_snapshot/migration.sql +3 -0
- package/prisma/schema/schema.prisma +1 -0
- package/src/admin-boot.ts +7 -0
- package/src/agent-account-assignment.ts +42 -10
- package/src/agent-github-installations.integration.test.ts +32 -0
- package/src/agent-github-installations.ts +11 -1
- 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 +288 -1
- package/src/agents-api.ts +89 -4
- package/src/agents.ts +7 -0
- package/src/api.smoke.test.ts +15 -0
- package/src/api.ts +6 -2
- package/src/kubernetes-client.unit.test.ts +22 -0
- package/src/openapi-schemas.ts +51 -0
- package/src/openapi-schemas.unit.test.ts +31 -0
- package/src/repo-access-config.ts +22 -0
- package/src/repo-access-config.unit.test.ts +34 -0
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
|
|
|
@@ -2510,6 +2588,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
2510
2588
|
const snapshot = await agentGitHubInstallationsService.push(agentId, {
|
|
2511
2589
|
reportedAt: new Date(body.reportedAt),
|
|
2512
2590
|
installations: body.installations,
|
|
2591
|
+
repoAccess: body.repos,
|
|
2513
2592
|
});
|
|
2514
2593
|
return c.json(
|
|
2515
2594
|
{ snapshot: serializeGitHubInstallationsSnapshot(snapshot) },
|
|
@@ -2738,6 +2817,7 @@ function serializeAgent(
|
|
|
2738
2817
|
restrictSlackToMembers?: boolean;
|
|
2739
2818
|
typeName: string;
|
|
2740
2819
|
accountId?: string | null;
|
|
2820
|
+
podServiceAccountName?: string | null;
|
|
2741
2821
|
createdAt: Date;
|
|
2742
2822
|
updatedAt: Date;
|
|
2743
2823
|
missingRequiredEnv?: string[];
|
|
@@ -2767,6 +2847,7 @@ function serializeAgent(
|
|
|
2767
2847
|
restrictSlackToMembers: agent.restrictSlackToMembers ?? false,
|
|
2768
2848
|
typeName: agent.typeName,
|
|
2769
2849
|
accountId: agent.accountId ?? null,
|
|
2850
|
+
podServiceAccountName: agent.podServiceAccountName ?? null,
|
|
2770
2851
|
createdAt: agent.createdAt.toISOString(),
|
|
2771
2852
|
updatedAt: agent.updatedAt.toISOString(),
|
|
2772
2853
|
missingRequiredEnv: agent.missingRequiredEnv ?? [],
|
|
@@ -2814,6 +2895,7 @@ function serializeGitHubInstallationsSnapshot(snapshot: {
|
|
|
2814
2895
|
agentId: string;
|
|
2815
2896
|
reportedAt: Date;
|
|
2816
2897
|
installations: unknown;
|
|
2898
|
+
repoAccess: unknown;
|
|
2817
2899
|
createdAt: Date;
|
|
2818
2900
|
}): z.infer<typeof AgentGitHubInstallationsSnapshotSchema> {
|
|
2819
2901
|
return {
|
|
@@ -2823,6 +2905,9 @@ function serializeGitHubInstallationsSnapshot(snapshot: {
|
|
|
2823
2905
|
installations: snapshot.installations as z.infer<
|
|
2824
2906
|
typeof AgentGitHubInstallationsSnapshotSchema
|
|
2825
2907
|
>["installations"],
|
|
2908
|
+
repoAccess: (snapshot.repoAccess ?? null) as z.infer<
|
|
2909
|
+
typeof AgentGitHubInstallationsSnapshotSchema
|
|
2910
|
+
>["repoAccess"],
|
|
2826
2911
|
createdAt: snapshot.createdAt.toISOString(),
|
|
2827
2912
|
};
|
|
2828
2913
|
}
|
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 }
|
package/src/api.smoke.test.ts
CHANGED
|
@@ -239,6 +239,7 @@ function buildApp(opts?: {
|
|
|
239
239
|
cleanupMergedWorktrees?: boolean;
|
|
240
240
|
cleanupAfterDays?: number;
|
|
241
241
|
transcriptUploadEnabled?: boolean;
|
|
242
|
+
repoAccessCheckEnabled?: boolean;
|
|
242
243
|
}) {
|
|
243
244
|
const hasAgent = opts?.hasAgent ?? true;
|
|
244
245
|
const bundle: AgentEnvBundle | null =
|
|
@@ -312,6 +313,7 @@ function buildApp(opts?: {
|
|
|
312
313
|
sessionSecret: SESSION_SECRET,
|
|
313
314
|
anthropicFederationEnabled: opts?.anthropicFederationEnabled,
|
|
314
315
|
transcriptUploadEnabled: opts?.transcriptUploadEnabled,
|
|
316
|
+
repoAccessCheckEnabled: opts?.repoAccessCheckEnabled,
|
|
315
317
|
});
|
|
316
318
|
}
|
|
317
319
|
|
|
@@ -355,6 +357,19 @@ describe("GET /:id/config features (COS-43.1)", () => {
|
|
|
355
357
|
});
|
|
356
358
|
});
|
|
357
359
|
|
|
360
|
+
describe("GET /:id/config features.repoAccessCheck (COS-44.1)", () => {
|
|
361
|
+
test("follows the repo-access flag", async () => {
|
|
362
|
+
const app = buildApp({ repoAccessCheckEnabled: true });
|
|
363
|
+
const res = await app.request(`/${KNOWN_AGENT_ID}/config`, {
|
|
364
|
+
headers: { Authorization: `Bearer ${VALID_ADMIN_KEY}` },
|
|
365
|
+
});
|
|
366
|
+
expect((await res.json()).features).toEqual({
|
|
367
|
+
transcriptUpload: false,
|
|
368
|
+
repoAccessCheck: true,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
358
373
|
describe("GET /:id/config (mounted as GET /agents/:id/config from root)", () => {
|
|
359
374
|
test("200 with full bundle when agent exists with env vars, tools, plugins", async () => {
|
|
360
375
|
const app = buildApp();
|
package/src/api.ts
CHANGED
|
@@ -179,6 +179,11 @@ export interface AgentRuntimeDeps {
|
|
|
179
179
|
* as `features.transcriptUpload` in the config bundle. Defaults to false.
|
|
180
180
|
*/
|
|
181
181
|
transcriptUploadEnabled?: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* COS-44.1: SHIPWRIGHT_AGENT_REPO_ACCESS_CHECK_ENABLED is on — surfaced to the
|
|
184
|
+
* runtime as `features.repoAccessCheck` in the config bundle. Defaults to false.
|
|
185
|
+
*/
|
|
186
|
+
repoAccessCheckEnabled?: boolean;
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
// ─── Route definitions ────────────────────────────────────────────────────────
|
|
@@ -352,10 +357,9 @@ export function createAgentRuntimeApp(deps: AgentRuntimeDeps): OpenAPIHono {
|
|
|
352
357
|
maxFindings: agent.maxFindings,
|
|
353
358
|
cleanupMergedWorktrees: agent.cleanupMergedWorktrees,
|
|
354
359
|
cleanupAfterDays: agent.cleanupAfterDays,
|
|
355
|
-
// COS-43.1: repoAccessCheck is wired by COS-44.1; false until then.
|
|
356
360
|
features: {
|
|
357
361
|
transcriptUpload: deps.transcriptUploadEnabled === true,
|
|
358
|
-
repoAccessCheck:
|
|
362
|
+
repoAccessCheck: deps.repoAccessCheckEnabled === true,
|
|
359
363
|
},
|
|
360
364
|
};
|
|
361
365
|
|
|
@@ -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 —
|
|
@@ -953,6 +969,33 @@ const GitHubInstallationSchema = z
|
|
|
953
969
|
.strict()
|
|
954
970
|
.openapi("GitHubInstallation");
|
|
955
971
|
|
|
972
|
+
/**
|
|
973
|
+
* One per-repo access probe result (COS-44.1). CLOSED (.strict()) like the
|
|
974
|
+
* installation schema: tokens and other credentials are rejected, never stored.
|
|
975
|
+
* `error` is a fixed class, not free text.
|
|
976
|
+
*/
|
|
977
|
+
const RepoAccessEntrySchema = z
|
|
978
|
+
.object({
|
|
979
|
+
repo: z.string().min(1).max(200).openapi({ example: "agent" }),
|
|
980
|
+
owner: z.string().min(1).max(200).openapi({ example: "app-vitals" }),
|
|
981
|
+
read: z.boolean().openapi({ example: true }),
|
|
982
|
+
write: z.boolean().openapi({ example: false }),
|
|
983
|
+
checkedAt: z
|
|
984
|
+
.string()
|
|
985
|
+
.datetime()
|
|
986
|
+
.openapi({ example: "2026-01-01T00:00:00.000Z" }),
|
|
987
|
+
error: z
|
|
988
|
+
.enum(["not_found", "forbidden", "rate_limited", "network"])
|
|
989
|
+
.optional()
|
|
990
|
+
.openapi({
|
|
991
|
+
description:
|
|
992
|
+
"Why the probe could not read the repo; absent when the probe succeeded.",
|
|
993
|
+
example: "not_found",
|
|
994
|
+
}),
|
|
995
|
+
})
|
|
996
|
+
.strict()
|
|
997
|
+
.openapi("RepoAccessEntry");
|
|
998
|
+
|
|
956
999
|
/**
|
|
957
1000
|
* PUT /agents/:id/github-installations body. Replace-all: the installations
|
|
958
1001
|
* array overwrites whatever was previously stored.
|
|
@@ -964,6 +1007,10 @@ export const PutGitHubInstallationsBodySchema = z
|
|
|
964
1007
|
.datetime()
|
|
965
1008
|
.openapi({ example: "2026-01-01T00:00:00.000Z" }),
|
|
966
1009
|
installations: z.array(GitHubInstallationSchema),
|
|
1010
|
+
repos: z.array(RepoAccessEntrySchema).optional().openapi({
|
|
1011
|
+
description:
|
|
1012
|
+
"Per-repo access probe result (COS-44.1). Omit to leave the stored result unchanged; send [] to clear it.",
|
|
1013
|
+
}),
|
|
967
1014
|
})
|
|
968
1015
|
.strict()
|
|
969
1016
|
.openapi("PutGitHubInstallationsBody");
|
|
@@ -977,6 +1024,10 @@ export const AgentGitHubInstallationsSnapshotSchema = z
|
|
|
977
1024
|
.datetime()
|
|
978
1025
|
.openapi({ example: "2026-01-01T00:00:00.000Z" }),
|
|
979
1026
|
installations: z.array(GitHubInstallationSchema),
|
|
1027
|
+
repoAccess: z.array(RepoAccessEntrySchema).nullable().openapi({
|
|
1028
|
+
description:
|
|
1029
|
+
"Latest per-repo access probe result; null until a runtime with the repo-access check enabled reports one.",
|
|
1030
|
+
}),
|
|
980
1031
|
createdAt: z
|
|
981
1032
|
.string()
|
|
982
1033
|
.datetime()
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/repo-access-config.ts
|
|
3
|
+
*
|
|
4
|
+
* Pure parser for the per-repo GitHub access check flag (COS-44.1). Takes an
|
|
5
|
+
* injected env object (no process.env reads) so it is deterministically
|
|
6
|
+
* unit-testable; admin-boot reads it once at startup and feeds it into the
|
|
7
|
+
* config bundle as `features.repoAccessCheck`. Same "enabled" convention as
|
|
8
|
+
* transcript-config.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type RepoAccessEnv = Readonly<Record<string, string | undefined>>;
|
|
12
|
+
|
|
13
|
+
export interface RepoAccessConfig {
|
|
14
|
+
/** True only when SHIPWRIGHT_AGENT_REPO_ACCESS_CHECK_ENABLED === "enabled". */
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseRepoAccessConfig(env: RepoAccessEnv): RepoAccessConfig {
|
|
19
|
+
return {
|
|
20
|
+
enabled: env.SHIPWRIGHT_AGENT_REPO_ACCESS_CHECK_ENABLED === "enabled",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/repo-access-config.unit.test.ts
|
|
3
|
+
* Pure flag parser for the per-repo GitHub access check (COS-44.1).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import { parseRepoAccessConfig } from "./repo-access-config.ts";
|
|
8
|
+
|
|
9
|
+
describe("parseRepoAccessConfig", () => {
|
|
10
|
+
it('is on only for the literal "enabled"', () => {
|
|
11
|
+
expect(
|
|
12
|
+
parseRepoAccessConfig({
|
|
13
|
+
SHIPWRIGHT_AGENT_REPO_ACCESS_CHECK_ENABLED: "enabled",
|
|
14
|
+
}).enabled,
|
|
15
|
+
).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("every other value is off", () => {
|
|
19
|
+
for (const v of [
|
|
20
|
+
"true",
|
|
21
|
+
"",
|
|
22
|
+
"Enabled",
|
|
23
|
+
" enabled",
|
|
24
|
+
"disabled",
|
|
25
|
+
undefined,
|
|
26
|
+
]) {
|
|
27
|
+
expect(
|
|
28
|
+
parseRepoAccessConfig({ SHIPWRIGHT_AGENT_REPO_ACCESS_CHECK_ENABLED: v })
|
|
29
|
+
.enabled,
|
|
30
|
+
).toBe(false);
|
|
31
|
+
}
|
|
32
|
+
expect(parseRepoAccessConfig({}).enabled).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|