Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Architecture

This document is the orientation map for contributors. The authoritative design lives in `docs/design/` (10 documents) and `docs/adr/` (12 decision records); this file tells you what exists, where it lives, and which invariants hold it together.
This document is the orientation map for contributors. The authoritative design lives in `docs/design/` (10 documents) and `docs/adr/` (13 decision records); this file tells you what exists, where it lives, and which invariants hold it together.

## Bird's-eye view

Expand Down Expand Up @@ -60,6 +60,7 @@ Dependency direction is enforced by `scripts/check-deps.ts` (runtime deps only).
8. **The worker trusts only the pinned manifest**: repos are project-scoped and skills/MCP resolve solely from `runs.resource_manifest`, never the mutable global registry.
9. **Lifecycle mutations are atomic**: run status transitions are compare-and-swap on the expected status and event `seq` is allocated by the database, so concurrent writers can't clobber a status or collide on a seq (`run-lifecycle.ts`).
10. **Approved evidence is the published tree**: platform Git reads only its private gitdir/index, excludes runtime configuration from both evidence and publication, and refuses errors, empty snapshots, or any patch mismatch before push (ADR-0012).
11. **A project provider credential outranks worker env, and slots resolve single-provider**: the executor overlay replaces the wire protocol's whole auth-var family (never merges), and submit-time resolution satisfies all of a slot's scoped roles from one provider — a step's base URL is process-wide, so a mixed slot could never execute (ADR-0013).

## Where to look

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to Agrippa are documented here. The format follows

## [Unreleased]

### Added

- **Per-project provider credentials (Aliyun Bailian/DashScope first)** — projects configure model-provider API keys in Settings → Providers instead of relying on worker env ([ADR-0013](docs/adr/0013-per-project-provider-credentials.md)):
- *Generic credential store.* One `provider_credentials` row per (project, provider) with an optional endpoint override; the key lives encrypted in `secrets` (new kind `provider_api_key`), write-only through the API (reads expose `hasCredential` only), rotated in place, deleted together with its secret, audited on every mutation.
- *Provider catalog.* `PROVIDER_CATALOG` in `@agrippa/core` declares per-wire-protocol default endpoints and an auth policy per provider — `dashscope` requires a project credential (no legitimate env fallback), `anthropic`/`openai` keep worker env as the deployment default. Qwen models (`qwen3.7-max/plus`, `qwen3.6-flash`) are seeded; claude-agent-sdk serves `dashscope` (claude-only for now — Codex CLI ≥0.122 removed the chat wire API Bailian's compatible mode speaks; ADR-0013 amendment).
- *Executor injection, project wins.* The engine materializes the project's key fresh per step (decrypted in the worker, redactor-registered before use, so rotation applies at the next step) and executors overlay it by replacing the wire protocol's entire auth-var family — Bailian lands as bearer `ANTHROPIC_AUTH_TOKEN` + gateway URL on claude; explicit `openai` proxy overrides land as a synthesized `-c model_providers` entry with a per-run `CODEX_HOME` on codex (so ambient `auth.json` can't outrank the project key). Codex now registers on a CLI probe alone; env auth at boot is no longer required.
- *Base-URL overrides are policy-checked, and endpoint + key travel together.* A credential's baseUrl is where the worker sends the decrypted key, so overrides must be https, carry no userinfo/query, point at a public DNS name (never an IP literal or localhost — and the worker requires every resolved address to be global-unicast at each step; for IPv6 that is allowlist-first from `2000::/3`, so unallocated space never passes), and stay within the provider's pinned hosts (dashscope → the exact API hosts + `.maas.aliyuncs.com`, excluding customer-controlled OSS bucket domains) — rejected as `base_url_invalid` otherwise. Permanent non-resolution is the same actionable configuration failure; transient resolver errors remain queue-retryable. Changing the endpoint requires re-entering the key in the same request, so an existing write-only key can never be redirected; clearing back to the catalog default needs no key.
- *Role-scoped, single-provider slot resolution.* Slots resolve only the model roles their steps (and subagents) reference, and provider-constrained slots resolve all of them from one provider (credentialed provider first, then cheapest, deterministic) — a step's base URL is process-wide, so mixed-provider slots could never execute. A slot blocked only by a missing credential fails submission with the new `provider_credential_required` error — retries re-assert the same gate against the frozen resolution, and a credential deleted mid-run fails the run with the same code before the executor is invoked; demo/`fake` resolution is unchanged.
- *Keyless workers defer instead of failing.* Executors advertise which providers their worker env can authenticate (`envAuthProviders`); a run whose providers neither a project credential nor the claiming worker's env covers is declined before any status transition (the existing `run.deferred` heterogeneous-fleet path), so it waits for a capable worker rather than burning mid-run. The decline applies only to the pre-claim states the worker can actually re-enqueue (`queued`/`waiting_approval`) — a crash-recovered running run proceeds and, if its auth is truly unusable (including an env-policy credential deleted after claim on a keyless worker), fails actionably per-step with `provider_credential_required` instead of a generic error. Preflight checks only credential-row/secret presence; endpoint validation and decryption happen after claim, where bad configuration fails actionably and infrastructure errors retry.

### Fixed

- **Fourth review round (PR #5)** — the evidence boundary now excludes all agent-writable Git metadata:
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/routes/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "@agrippa/db";
import {
appendRunEvent as allocateRunEvent,
assertResolutionCredentialed,
authorizeResources,
buildParamsValidator,
decideCheckpoint,
Expand Down Expand Up @@ -440,6 +441,22 @@ export const executionRoutes = new Hono<AppEnv>()
throw AppError.conflict("run_active", "The latest run has not finished");
}

// the frozen resolution is copied verbatim below, so re-assert that its
// project-only provider credentials still exist — a credential deleted
// since the last run must fail here, not as an auth error mid-run
try {
await assertResolutionCredentialed(
db,
task.projectId,
latest.modelResolution as Record<string, unknown>,
latest.agentBindings as Record<string, { executorId: string }> | null,
latest.executorId,
);
} catch (err) {
if (err instanceof SubmitError) throw new AppError(err.code, 400, err.message, err.details);
throw err;
}

const [run] = await db
.insert(runs)
.values({
Expand Down
176 changes: 176 additions & 0 deletions apps/api/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import {
memberUpdateSchema,
projectCreateSchema,
projectUpdateSchema,
providerCredentialCreateSchema,
providerCredentialUpdateSchema,
quotaUpdateSchema,
type ResourceType,
repoCreateSchema,
validateProviderBaseUrl,
} from "@agrippa/core";
import {
auditLogs,
Expand All @@ -21,6 +24,7 @@ import {
projectQuotas,
projectResourceGrants,
projects,
providerCredentials,
repoConnections,
secrets,
skills,
Expand Down Expand Up @@ -324,6 +328,178 @@ export const projectRoutes = new Hono<AppEnv>()
return c.json({ removed: true });
})

// ── Provider credentials ───────────────────────────────────────────────────
// The key is write-only: stored encrypted via the secrets table, surfaced
// only as hasCredential. Clearing a key = DELETE (a keyless row is useless).
// baseUrl is where the worker SENDS the key, so it is policy-checked here
// (https, public DNS name, per-provider host family) — a lax override would
// let one admin exfiltrate a key another admin entered.
.get("/:projectId/providers", requireProjectRole("viewer"), async (c) => {
const rows = await c.var.db
.select({
id: providerCredentials.id,
provider: providerCredentials.provider,
baseUrl: providerCredentials.baseUrl,
createdAt: providerCredentials.createdAt,
rotatedAt: secrets.rotatedAt,
})
.from(providerCredentials)
.leftJoin(secrets, eq(secrets.id, providerCredentials.secretRef))
.where(eq(providerCredentials.projectId, c.req.param("projectId")));
return c.json(rows.map((r) => ({ ...r, hasCredential: true })));
})
.post(
"/:projectId/providers",
requireProjectRole("admin"),
validate("json", providerCredentialCreateSchema),
async (c) => {
const projectId = c.req.param("projectId");
const { apiKey, provider, baseUrl } = c.req.valid("json");
if (baseUrl !== undefined) {
const reason = validateProviderBaseUrl(provider, baseUrl);
if (reason) throw new AppError("base_url_invalid", 400, `Base URL ${reason}`);
}
const db = c.var.db;
const [existing] = await db
.select({ id: providerCredentials.id })
.from(providerCredentials)
.where(
and(
eq(providerCredentials.projectId, projectId),
eq(providerCredentials.provider, provider),
),
);
if (existing) {
throw AppError.conflict(
"provider_exists",
`Provider '${provider}' already has a credential — rotate or remove it`,
);
}
const created = await db.transaction(async (tx) => {
const [secret] = await tx
.insert(secrets)
.values({
orgId: c.var.user.orgId,
kind: "provider_api_key",
ciphertext: encryptSecret(apiKey, loadSecretKey()),
createdBy: c.var.user.id,
})
.returning();
if (!secret) throw new Error("secret insert failed");
const [row] = await tx
.insert(providerCredentials)
.values({ projectId, provider, baseUrl: baseUrl ?? null, secretRef: secret.id })
.returning();
await audit(
c,
{
action: "project.provider.add",
resourceType: "provider_credential",
resourceId: row?.id,
projectId,
payload: { provider },
},
tx,
);
return row;
});
return c.json(
created ? { ...created, secretRef: undefined, hasCredential: true } : null,
201,
);
},
)
.patch(
"/:projectId/providers/:provider",
requireProjectRole("admin"),
validate("json", providerCredentialUpdateSchema),
async (c) => {
const projectId = c.req.param("projectId");
const provider = c.req.param("provider");
const patch = c.req.valid("json");
if (patch.baseUrl !== undefined && patch.baseUrl !== null) {
const reason = validateProviderBaseUrl(provider, patch.baseUrl);
if (reason) throw new AppError("base_url_invalid", 400, `Base URL ${reason}`);
}
const db = c.var.db;
const [current] = await db
.select()
.from(providerCredentials)
.where(
and(
eq(providerCredentials.projectId, projectId),
eq(providerCredentials.provider, provider),
),
);
if (!current) throw AppError.notFound("Provider credential");
const rotated = patch.apiKey !== undefined;
// one transaction: a partially applied rotate/baseUrl change with no
// audit row would break the every-mutation-is-audited invariant
await db.transaction(async (tx) => {
if (patch.apiKey !== undefined) {
// rotate in place: the ref stays stable, running steps see the new key
await tx
.update(secrets)
.set({
ciphertext: encryptSecret(patch.apiKey, loadSecretKey()),
rotatedAt: new Date(),
})
.where(eq(secrets.id, current.secretRef));
}
if (patch.baseUrl !== undefined) {
await tx
.update(providerCredentials)
.set({ baseUrl: patch.baseUrl })
.where(eq(providerCredentials.id, current.id));
}
await audit(
c,
{
action: "project.provider.update",
resourceType: "provider_credential",
resourceId: current.id,
projectId,
payload: { provider, rotated, baseUrlChanged: patch.baseUrl !== undefined },
},
tx,
);
});
return c.json({ updated: true, hasCredential: true });
},
)
.delete("/:projectId/providers/:provider", requireProjectRole("admin"), async (c) => {
const projectId = c.req.param("projectId");
const provider = c.req.param("provider");
const db = c.var.db;
const [current] = await db
.select()
.from(providerCredentials)
.where(
and(
eq(providerCredentials.projectId, projectId),
eq(providerCredentials.provider, provider),
),
);
if (!current) throw AppError.notFound("Provider credential");
await db.transaction(async (tx) => {
await tx.delete(providerCredentials).where(eq(providerCredentials.id, current.id));
// the secret dies with the credential — no orphaned key material
await tx.delete(secrets).where(eq(secrets.id, current.secretRef));
await audit(
c,
{
action: "project.provider.remove",
resourceType: "provider_credential",
resourceId: current.id,
projectId,
payload: { provider },
},
tx,
);
});
return c.json({ removed: true });
})

// ── Resource grants ────────────────────────────────────────────────────────
.get("/:projectId/grants", requireProjectRole("viewer"), async (c) => {
const rows = await c.var.db
Expand Down
Loading