@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/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
 
@@ -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 }
@@ -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: false,
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
 
@@ -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
+ });