Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Optio is an orchestration system for AI coding agents. Think of it as "CI/CD whe
8. Auto-resumes agent when reviewer requests changes (if enabled)
9. Auto-completes on merge, auto-fails on close

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.

- **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.

- **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/`.
Expand Down
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"openapi:types": "openapi-typescript openapi.generated.json -o openapi.generated.d.ts"
},
"dependencies": {
"@aws-sdk/client-codecommit": "^3.1041.0",
"@aws-sdk/client-sts": "^3.1041.0",
"@fastify/cors": "^11.0.0",
"@fastify/formbody": "^8.0.2",
"@fastify/rate-limit": "^10.2.2",
Expand Down Expand Up @@ -61,6 +63,7 @@
"@types/node": "^22.15.0",
"@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^3.2.4",
"aws-sdk-client-mock": "^4.1.0",
"drizzle-kit": "^0.31.1",
"openapi-typescript": "^7.13.0",
"tsx": "^4.19.4",
Expand Down
9 changes: 6 additions & 3 deletions apps/api/src/routes/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,16 +324,18 @@ const MIGRATED_ROUTES: MigratedRoute[] = [
{ method: "get", path: "/api/analytics/costs" },

// Phase 7 — setup, secrets, optio, cluster (28 routes)
// setup.ts (10)
// setup.ts (12)
{ method: "get", path: "/api/setup/status" },
{ method: "post", path: "/api/setup/validate/github-token" },
{ method: "post", path: "/api/setup/validate/gitlab-token" },
{ method: "post", path: "/api/setup/validate/aws-credentials" },
{ method: "post", path: "/api/setup/validate/anthropic-key" },
{ method: "post", path: "/api/setup/validate/copilot-token" },
{ method: "post", path: "/api/setup/validate/openai-key" },
{ method: "post", path: "/api/setup/validate/gemini-key" },
{ method: "post", path: "/api/setup/repos" },
{ method: "post", path: "/api/setup/repos/gitlab" },
{ method: "post", path: "/api/setup/repos/codecommit" },
{ method: "post", path: "/api/setup/validate/repo" },
// secrets.ts (3)
{ method: "get", path: "/api/secrets" },
Expand Down Expand Up @@ -416,8 +418,9 @@ describe("OpenAPI spec — migrated routes are fully documented", () => {

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

it("components.schemas contains the Task domain types", () => {
Expand Down
115 changes: 115 additions & 0 deletions apps/api/src/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ const gitlabTokenSchema = z
.describe("Optional self-hosted GitLab host; defaults to gitlab.com"),
})
.describe("GitLab token + optional host");
const awsCredentialsSchema = z
.object({
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
sessionToken: z.string().optional(),
region: z.string().min(1),
})
.describe("AWS credentials + region for CodeCommit access");
const keySchema = z.object({ key: z.string().min(1) }).describe("Body with a required API key");
const reposBodySchema = z
.object({
Expand Down Expand Up @@ -250,6 +258,113 @@ export async function setupRoutes(rawApp: FastifyInstance) {
},
);

app.post(
"/api/setup/validate/aws-credentials",
{
config: { rateLimit: SETUP_POST_RATE_LIMIT },
preHandler: [requireAdminWhenAuthenticated],
schema: {
operationId: "validateAwsCredentials",
summary: "Validate AWS credentials for CodeCommit",
description:
"Confirms the supplied AWS credentials by calling sts:GetCallerIdentity " +
"and codecommit:ListRepositories in the given region.",
tags: ["Setup & Settings"],
body: awsCredentialsSchema,
response: { 200: ValidationResultSchema, 400: ErrorResponseSchema },
},
},
async (req, reply) => {
const { accessKeyId, secretAccessKey, sessionToken, region } = req.body;
try {
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
const { CodeCommitClient: CC, ListRepositoriesCommand } =
await import("@aws-sdk/client-codecommit");
const credentials = {
accessKeyId,
secretAccessKey,
...(sessionToken ? { sessionToken } : {}),
};
const sts = new STSClient({ region, credentials });
const id = await sts.send(new GetCallerIdentityCommand({}));
const cc = new CC({ region, credentials });
await cc.send(new ListRepositoriesCommand({}));
reply.send({
valid: true,
user: {
login: id.Arn ?? "aws",
name: id.Account ? `AWS account ${id.Account}` : "AWS",
},
});
} catch (err) {
app.log.error(err, "AWS credential validation failed");
reply.send({ valid: false, error: sanitizeError(err) });
}
},
);

app.post(
"/api/setup/repos/codecommit",
{
config: { rateLimit: SETUP_POST_RATE_LIMIT },
preHandler: [requireAdminWhenAuthenticated],
schema: {
operationId: "listSetupCodeCommitRepos",
summary: "List CodeCommit repositories for setup",
description: "List CodeCommit repos in the given region accessible to the supplied creds.",
tags: ["Setup & Settings"],
body: awsCredentialsSchema,
response: { 200: ReposListResponseSchema, 400: ErrorResponseSchema },
},
},
async (req, reply) => {
const { accessKeyId, secretAccessKey, sessionToken, region } = req.body;
try {
const {
CodeCommitClient: CC,
ListRepositoriesCommand,
GetRepositoryCommand,
} = await import("@aws-sdk/client-codecommit");
const credentials = {
accessKeyId,
secretAccessKey,
...(sessionToken ? { sessionToken } : {}),
};
const cc = new CC({ region, credentials });
const list = await cc.send(new ListRepositoriesCommand({}));
const names = (list.repositories ?? []).map((r) => r.repositoryName).filter(Boolean) as
| string[]
| [];
const details = await Promise.all(
names.slice(0, 50).map((name) =>
cc
.send(new GetRepositoryCommand({ repositoryName: name }))
.then((d) => d.repositoryMetadata)
.catch(() => null),
),
);
const repos = details
.filter((d): d is NonNullable<typeof d> => Boolean(d))
.map((d) => ({
fullName: d.repositoryName ?? "",
cloneUrl:
d.cloneUrlHttp ??
`https://git-codecommit.${region}.amazonaws.com/v1/repos/${d.repositoryName}`,
htmlUrl: `https://${region}.console.aws.amazon.com/codesuite/codecommit/repositories/${d.repositoryName}/browse`,
defaultBranch: d.defaultBranch ?? "main",
isPrivate: true,
description: d.repositoryDescription ?? null,
language: null,
pushedAt: d.lastModifiedDate ? new Date(d.lastModifiedDate).toISOString() : "",
}));
reply.send({ repos });
} catch (err) {
app.log.error(err, "CodeCommit repo listing failed");
reply.send({ repos: [], error: sanitizeError(err) });
}
},
);

app.post(
"/api/setup/validate/anthropic-key",
{
Expand Down
87 changes: 87 additions & 0 deletions apps/api/src/services/codecommit-credential-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { retrieveSecretWithFallback } from "./secret-service.js";
import { logger } from "../logger.js";

export interface AwsCredentials {
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
region: string;
}

/**
* Resolve AWS credentials for CodeCommit access from secrets, with workspace and global scopes,
* falling back to env vars. Returns the credentials as a JSON string so it can be passed
* through the existing GitPlatform factory which takes a single `token: string`.
*
* Lookup order:
* 1. Workspace-scoped or global secrets named AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
* (+ optional AWS_SESSION_TOKEN, AWS_REGION).
* 2. Process env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN,
* AWS_REGION / AWS_DEFAULT_REGION).
*
* If neither source provides creds, returns the sentinel string `"workload-identity"` so the
* AWS SDK falls back to its default credential provider chain (instance profile / IRSA /
* environment) at the call site.
*/
export async function getCodeCommitCredentials(workspaceId?: string | null): Promise<string> {
let accessKeyId: string | undefined;
let secretAccessKey: string | undefined;
let sessionToken: string | undefined;
let region: string | undefined;

try {
accessKeyId = await retrieveSecretWithFallback("AWS_ACCESS_KEY_ID", "global", workspaceId);
secretAccessKey = await retrieveSecretWithFallback(
"AWS_SECRET_ACCESS_KEY",
"global",
workspaceId,
);
} catch {
logger.debug({ workspaceId }, "No AWS credential secrets found, checking env");
}

try {
sessionToken = await retrieveSecretWithFallback("AWS_SESSION_TOKEN", "global", workspaceId);
} catch {
// Optional
}

try {
region = await retrieveSecretWithFallback("AWS_REGION", "global", workspaceId);
} catch {
// Optional
}

accessKeyId ??= process.env.AWS_ACCESS_KEY_ID;
secretAccessKey ??= process.env.AWS_SECRET_ACCESS_KEY;
sessionToken ??= process.env.AWS_SESSION_TOKEN;
region ??= process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;

if (!accessKeyId || !secretAccessKey) {
// Signal to the SDK to use the default credential provider chain
return "workload-identity";
}

const creds: AwsCredentials = {
accessKeyId,
secretAccessKey,
region: region ?? "us-east-1",
...(sessionToken ? { sessionToken } : {}),
};
return JSON.stringify(creds);
}

/**
* Parse a credential string produced by getCodeCommitCredentials() back into an
* AwsCredentials object, or return null if the caller should use the default chain.
*/
export function parseAwsCredentials(token: string): AwsCredentials | null {
if (!token || token === "workload-identity") return null;
try {
const parsed = JSON.parse(token) as AwsCredentials;
if (!parsed.accessKeyId || !parsed.secretAccessKey) return null;
return parsed;
} catch {
return null;
}
}
Loading
Loading