Skip to content

Commit e432c12

Browse files
authored
feat(repo-providers): add AWS CodeCommit as a third git platform (#529)
Adds CodeCommit support symmetrically with GitHub and GitLab so agent tasks can clone, push, and open PRs against repos hosted in AWS CodeCommit. - Extend GitPlatformType to include "codecommit" and parseRepoUrl/parsePrUrl to recognise git-codecommit.<region>.amazonaws.com plus console PR URLs. - New CodeCommitPlatform implementing the full GitPlatform interface via @aws-sdk/client-codecommit (PR get/list, comments, approval states, three merge modes, repo metadata, folder listing). - New codecommit-credential-service that resolves AWS creds from secrets (workspace -> global -> env vars) with a "workload-identity" sentinel for IRSA / instance-profile fallback. - Pod runtime: install AWS CLI v2 in the agent base image and wire `aws codecommit credential-helper` for HTTPS clone auth in repo-init.sh and agent-entrypoint.sh. - Prompt templates: new GIT_PLATFORM_CODECOMMIT / CODECOMMIT_REPO / BASE_BRANCH vars; agent uses `aws codecommit create-pull-request` and `update-pull-request-approval-state` instead of gh/glab. - Setup wizard: new CodeCommit panel with region + access key + secret + session token inputs, validate button (sts:GetCallerIdentity + codecommit:ListRepositories), and repo picker integration. - Helm: document EKS IRSA via serviceAccount.annotations (eks.amazonaws.com/role-arn). Closes #527
1 parent 9a09959 commit e432c12

24 files changed

Lines changed: 2643 additions & 26 deletions

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Optio is an orchestration system for AI coding agents. Think of it as "CI/CD whe
1919
8. Auto-resumes agent when reviewer requests changes (if enabled)
2020
9. Auto-completes on merge, auto-fails on close
2121

22+
Supported git platforms: **GitHub**, **GitLab** (incl. self-hosted via `GITLAB_HOSTS`), and **AWS CodeCommit**. CodeCommit auths via AWS access keys (or IRSA / instance profile when running on EKS) and uses the AWS CLI credential helper for clones; PR ops go through `@aws-sdk/client-codecommit`. CodeCommit has no native CI or issues — `getCIChecks` returns `[]` (auto-merge still fires on `checksStatus="none"`), `listIssues` returns `[]`, and `reviewTrigger="on_pr"` is recommended over the default `on_ci_pass` for CodeCommit repos.
23+
2224
- **Standalone Task** — no `Where`. The agent runs in an isolated pod with no repo checkout, producing logs and side effects (e.g., queries Slack, posts to a database). Scheduled/webhook-driven runs of this flavor are the common case.
2325

2426
- **Persistent Agent** — long-lived, named, message-driven agent process that does _not_ terminate after running. Halts after each turn and waits to be re-woken by a user message, an agent message, a webhook, a cron tick, or a ticket event. Addressable by other agents in the same workspace via the inter-agent HTTP API (`/api/internal/persistent-agents/*`). Three configurable pod lifecycle modes: `always-on`, `sticky` (default, with idle warm window), and `on-demand`. UI at `/agents`. Schema: `persistent_agents`, `persistent_agent_turns`, `persistent_agent_messages`, `persistent_agent_pods`. See `docs/persistent-agents.md` and the demo in `demos/the-forge/`.

apps/api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"openapi:types": "openapi-typescript openapi.generated.json -o openapi.generated.d.ts"
1818
},
1919
"dependencies": {
20+
"@aws-sdk/client-codecommit": "^3.1041.0",
21+
"@aws-sdk/client-sts": "^3.1041.0",
2022
"@fastify/cors": "^11.0.0",
2123
"@fastify/formbody": "^8.0.2",
2224
"@fastify/rate-limit": "^10.2.2",
@@ -61,6 +63,7 @@
6163
"@types/node": "^22.15.0",
6264
"@types/web-push": "^3.6.4",
6365
"@vitest/coverage-v8": "^3.2.4",
66+
"aws-sdk-client-mock": "^4.1.0",
6467
"drizzle-kit": "^0.31.1",
6568
"openapi-typescript": "^7.13.0",
6669
"tsx": "^4.19.4",

apps/api/src/routes/openapi.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,16 +324,18 @@ const MIGRATED_ROUTES: MigratedRoute[] = [
324324
{ method: "get", path: "/api/analytics/costs" },
325325

326326
// Phase 7 — setup, secrets, optio, cluster (28 routes)
327-
// setup.ts (10)
327+
// setup.ts (12)
328328
{ method: "get", path: "/api/setup/status" },
329329
{ method: "post", path: "/api/setup/validate/github-token" },
330330
{ method: "post", path: "/api/setup/validate/gitlab-token" },
331+
{ method: "post", path: "/api/setup/validate/aws-credentials" },
331332
{ method: "post", path: "/api/setup/validate/anthropic-key" },
332333
{ method: "post", path: "/api/setup/validate/copilot-token" },
333334
{ method: "post", path: "/api/setup/validate/openai-key" },
334335
{ method: "post", path: "/api/setup/validate/gemini-key" },
335336
{ method: "post", path: "/api/setup/repos" },
336337
{ method: "post", path: "/api/setup/repos/gitlab" },
338+
{ method: "post", path: "/api/setup/repos/codecommit" },
337339
{ method: "post", path: "/api/setup/validate/repo" },
338340
// secrets.ts (3)
339341
{ method: "get", path: "/api/secrets" },
@@ -416,8 +418,9 @@ describe("OpenAPI spec — migrated routes are fully documented", () => {
416418

417419
it("migrated routes count matches the sum of completed phases", () => {
418420
// Removed 14 routes (8 schedule + 6 task-template) that were redundant
419-
// with agent workflows. 183 - 14 = 169.
420-
expect(MIGRATED_ROUTES).toHaveLength(169);
421+
// with agent workflows. 183 - 14 = 169. Then added 2 CodeCommit setup
422+
// routes (validate/aws-credentials and repos/codecommit) → 171.
423+
expect(MIGRATED_ROUTES).toHaveLength(171);
421424
});
422425

423426
it("components.schemas contains the Task domain types", () => {

apps/api/src/routes/setup.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ const gitlabTokenSchema = z
1818
.describe("Optional self-hosted GitLab host; defaults to gitlab.com"),
1919
})
2020
.describe("GitLab token + optional host");
21+
const awsCredentialsSchema = z
22+
.object({
23+
accessKeyId: z.string().min(1),
24+
secretAccessKey: z.string().min(1),
25+
sessionToken: z.string().optional(),
26+
region: z.string().min(1),
27+
})
28+
.describe("AWS credentials + region for CodeCommit access");
2129
const keySchema = z.object({ key: z.string().min(1) }).describe("Body with a required API key");
2230
const reposBodySchema = z
2331
.object({
@@ -250,6 +258,113 @@ export async function setupRoutes(rawApp: FastifyInstance) {
250258
},
251259
);
252260

261+
app.post(
262+
"/api/setup/validate/aws-credentials",
263+
{
264+
config: { rateLimit: SETUP_POST_RATE_LIMIT },
265+
preHandler: [requireAdminWhenAuthenticated],
266+
schema: {
267+
operationId: "validateAwsCredentials",
268+
summary: "Validate AWS credentials for CodeCommit",
269+
description:
270+
"Confirms the supplied AWS credentials by calling sts:GetCallerIdentity " +
271+
"and codecommit:ListRepositories in the given region.",
272+
tags: ["Setup & Settings"],
273+
body: awsCredentialsSchema,
274+
response: { 200: ValidationResultSchema, 400: ErrorResponseSchema },
275+
},
276+
},
277+
async (req, reply) => {
278+
const { accessKeyId, secretAccessKey, sessionToken, region } = req.body;
279+
try {
280+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
281+
const { CodeCommitClient: CC, ListRepositoriesCommand } =
282+
await import("@aws-sdk/client-codecommit");
283+
const credentials = {
284+
accessKeyId,
285+
secretAccessKey,
286+
...(sessionToken ? { sessionToken } : {}),
287+
};
288+
const sts = new STSClient({ region, credentials });
289+
const id = await sts.send(new GetCallerIdentityCommand({}));
290+
const cc = new CC({ region, credentials });
291+
await cc.send(new ListRepositoriesCommand({}));
292+
reply.send({
293+
valid: true,
294+
user: {
295+
login: id.Arn ?? "aws",
296+
name: id.Account ? `AWS account ${id.Account}` : "AWS",
297+
},
298+
});
299+
} catch (err) {
300+
app.log.error(err, "AWS credential validation failed");
301+
reply.send({ valid: false, error: sanitizeError(err) });
302+
}
303+
},
304+
);
305+
306+
app.post(
307+
"/api/setup/repos/codecommit",
308+
{
309+
config: { rateLimit: SETUP_POST_RATE_LIMIT },
310+
preHandler: [requireAdminWhenAuthenticated],
311+
schema: {
312+
operationId: "listSetupCodeCommitRepos",
313+
summary: "List CodeCommit repositories for setup",
314+
description: "List CodeCommit repos in the given region accessible to the supplied creds.",
315+
tags: ["Setup & Settings"],
316+
body: awsCredentialsSchema,
317+
response: { 200: ReposListResponseSchema, 400: ErrorResponseSchema },
318+
},
319+
},
320+
async (req, reply) => {
321+
const { accessKeyId, secretAccessKey, sessionToken, region } = req.body;
322+
try {
323+
const {
324+
CodeCommitClient: CC,
325+
ListRepositoriesCommand,
326+
GetRepositoryCommand,
327+
} = await import("@aws-sdk/client-codecommit");
328+
const credentials = {
329+
accessKeyId,
330+
secretAccessKey,
331+
...(sessionToken ? { sessionToken } : {}),
332+
};
333+
const cc = new CC({ region, credentials });
334+
const list = await cc.send(new ListRepositoriesCommand({}));
335+
const names = (list.repositories ?? []).map((r) => r.repositoryName).filter(Boolean) as
336+
| string[]
337+
| [];
338+
const details = await Promise.all(
339+
names.slice(0, 50).map((name) =>
340+
cc
341+
.send(new GetRepositoryCommand({ repositoryName: name }))
342+
.then((d) => d.repositoryMetadata)
343+
.catch(() => null),
344+
),
345+
);
346+
const repos = details
347+
.filter((d): d is NonNullable<typeof d> => Boolean(d))
348+
.map((d) => ({
349+
fullName: d.repositoryName ?? "",
350+
cloneUrl:
351+
d.cloneUrlHttp ??
352+
`https://git-codecommit.${region}.amazonaws.com/v1/repos/${d.repositoryName}`,
353+
htmlUrl: `https://${region}.console.aws.amazon.com/codesuite/codecommit/repositories/${d.repositoryName}/browse`,
354+
defaultBranch: d.defaultBranch ?? "main",
355+
isPrivate: true,
356+
description: d.repositoryDescription ?? null,
357+
language: null,
358+
pushedAt: d.lastModifiedDate ? new Date(d.lastModifiedDate).toISOString() : "",
359+
}));
360+
reply.send({ repos });
361+
} catch (err) {
362+
app.log.error(err, "CodeCommit repo listing failed");
363+
reply.send({ repos: [], error: sanitizeError(err) });
364+
}
365+
},
366+
);
367+
253368
app.post(
254369
"/api/setup/validate/anthropic-key",
255370
{
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { retrieveSecretWithFallback } from "./secret-service.js";
2+
import { logger } from "../logger.js";
3+
4+
export interface AwsCredentials {
5+
accessKeyId: string;
6+
secretAccessKey: string;
7+
sessionToken?: string;
8+
region: string;
9+
}
10+
11+
/**
12+
* Resolve AWS credentials for CodeCommit access from secrets, with workspace and global scopes,
13+
* falling back to env vars. Returns the credentials as a JSON string so it can be passed
14+
* through the existing GitPlatform factory which takes a single `token: string`.
15+
*
16+
* Lookup order:
17+
* 1. Workspace-scoped or global secrets named AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
18+
* (+ optional AWS_SESSION_TOKEN, AWS_REGION).
19+
* 2. Process env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN,
20+
* AWS_REGION / AWS_DEFAULT_REGION).
21+
*
22+
* If neither source provides creds, returns the sentinel string `"workload-identity"` so the
23+
* AWS SDK falls back to its default credential provider chain (instance profile / IRSA /
24+
* environment) at the call site.
25+
*/
26+
export async function getCodeCommitCredentials(workspaceId?: string | null): Promise<string> {
27+
let accessKeyId: string | undefined;
28+
let secretAccessKey: string | undefined;
29+
let sessionToken: string | undefined;
30+
let region: string | undefined;
31+
32+
try {
33+
accessKeyId = await retrieveSecretWithFallback("AWS_ACCESS_KEY_ID", "global", workspaceId);
34+
secretAccessKey = await retrieveSecretWithFallback(
35+
"AWS_SECRET_ACCESS_KEY",
36+
"global",
37+
workspaceId,
38+
);
39+
} catch {
40+
logger.debug({ workspaceId }, "No AWS credential secrets found, checking env");
41+
}
42+
43+
try {
44+
sessionToken = await retrieveSecretWithFallback("AWS_SESSION_TOKEN", "global", workspaceId);
45+
} catch {
46+
// Optional
47+
}
48+
49+
try {
50+
region = await retrieveSecretWithFallback("AWS_REGION", "global", workspaceId);
51+
} catch {
52+
// Optional
53+
}
54+
55+
accessKeyId ??= process.env.AWS_ACCESS_KEY_ID;
56+
secretAccessKey ??= process.env.AWS_SECRET_ACCESS_KEY;
57+
sessionToken ??= process.env.AWS_SESSION_TOKEN;
58+
region ??= process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;
59+
60+
if (!accessKeyId || !secretAccessKey) {
61+
// Signal to the SDK to use the default credential provider chain
62+
return "workload-identity";
63+
}
64+
65+
const creds: AwsCredentials = {
66+
accessKeyId,
67+
secretAccessKey,
68+
region: region ?? "us-east-1",
69+
...(sessionToken ? { sessionToken } : {}),
70+
};
71+
return JSON.stringify(creds);
72+
}
73+
74+
/**
75+
* Parse a credential string produced by getCodeCommitCredentials() back into an
76+
* AwsCredentials object, or return null if the caller should use the default chain.
77+
*/
78+
export function parseAwsCredentials(token: string): AwsCredentials | null {
79+
if (!token || token === "workload-identity") return null;
80+
try {
81+
const parsed = JSON.parse(token) as AwsCredentials;
82+
if (!parsed.accessKeyId || !parsed.secretAccessKey) return null;
83+
return parsed;
84+
} catch {
85+
return null;
86+
}
87+
}

0 commit comments

Comments
 (0)