diff --git a/.env.example b/.env.example index 7e72d3b..4488193 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,30 @@ +# === Deployment mode === +# "single_tenant" (default) = OSS self-host / internal team. Runs exactly like +# before: env credentials below, no Postgres, no encryption setup. +# "multi_tenant" = managed cloud. Per-org encrypted credentials in Postgres +# (see the multi-tenant block further down). +DEPLOYMENT_MODE=single_tenant + +# --- Single-tenant credentials (used when DEPLOYMENT_MODE=single_tenant) --- +# The agent runs with whichever of these is set. Your existing setup keeps +# working unchanged: if you already use CLAUDE_CODE_OAUTH_TOKEN, leave it. CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... +# Or run on a Console API key instead (API billing): +# ANTHROPIC_API_KEY=sk-ant-api03-... GH_TOKEN=ghp_... MAX_CONCURRENT_AGENTS=3 + +# === Multi-tenant only (ignore for single-tenant self-host) === +# Postgres connection (system of record). Create the schema with: alembic upgrade head +# DATABASE_URL=postgresql+psycopg://swarmops:swarmops@localhost:5432/swarmops +# Secret storage backend: "fernet" (encrypted at rest) or "env" (dev only). +# SECRET_BACKEND=fernet +# Master key-encryption-key for the fernet backend. Generate once with: +# python -c "from orchestrator.infra.crypto import generate_master_key; print(generate_master_key())" +# TODO(kms): source this from a KMS / secrets manager before serving real customers. +# SECRETS_MASTER_KEY= +# Credential provider for agent runs. "api_key" = each org brings its own Anthropic key. +# CREDENTIAL_PROVIDER=api_key DASHBOARD_PORT=8420 # Agents only start when a comment containing this mention appears on the issue. # Set to empty to pick up issues immediately on label. diff --git a/SwarmOps-Plan-v2.md b/SwarmOps-Plan-v2.md new file mode 100644 index 0000000..1be63d7 --- /dev/null +++ b/SwarmOps-Plan-v2.md @@ -0,0 +1,158 @@ +# SwarmOps — Plan v2: Subscription + BYO Anthropic Key + GitHub App + +*Companion to `SwarmOps-Product-Strategy.md` (v1), which stays as-is. v2 refines the cost model and integration mechanics based on two decisions you've made: (1) charge a flat software subscription and **never** resell/meter Anthropic usage — every customer brings their own Anthropic API key; (2) ship a first-class **GitHub App** that handles repo access, commits, PRs, issues, and an **@bot-mention trigger**. Grounded in Anthropic's current docs, June 2026.* + +> **What this supersedes in v1:** the §2 "two-rail" cost model (drop Rail B / managed metering entirely), the §5 pricing (now pure subscription), and the integration mechanics in §4. Everything else in v1 — the ICP call, the no-touch GTM, open-core, the self-serve stack, the roadmap shape — still stands. + +--- + +## 0. The model in one line + +**You sell a flat monthly subscription for the orchestration software. Each customer connects their own Anthropic Console API key and their own GitHub via your GitHub App. Anthropic bills them directly for tokens; GitHub access is scoped per-install. You touch neither their AI spend nor their code permissions beyond what they grant.** + +This is simpler, cheaper, and lower-risk than v1's managed tier — and it's the only ToS-clean way to do it. + +--- + +## 1. The Anthropic access model — what's actually possible (from the docs) + +### Kill the wrong mental model first +There is **no "Sign in with Anthropic" / third-party OAuth** to obtain API access on a user's behalf. This is the single most important fact for this plan: + +- OAuth subscription tokens (the `sk-ant-oat01-…` you get from `claude setup-token`) were **banned for third-party apps in Feb 2026 and are now rejected by the API.** Your current architecture runs on exactly this token type — it must change. +- Anthropic's docs are explicit: *"Developers building products or services that interact with Claude's capabilities… should use API key authentication through Claude Console."* Apps are **not** permitted to route requests through Free/Pro/Max subscription credentials on behalf of users. +- There is no consumer-style OAuth "grant this app access to my Claude account." Unlike GitHub or Google, Anthropic does not offer that for API usage. + +### How "other apps" actually do it (the real pattern) +The user **creates a Console API key in their own Anthropic account and pastes it into your app.** That's the entire flow. It's BYO-key, productized as a clean onboarding step. + +### Recommended "connect Anthropic" onboarding (the closest thing to click-to-accept) +1. In onboarding, deep-link the user to the [Claude Console](https://platform.claude.com/) → have them create (or select) a **Workspace** named "SwarmOps" and **set a monthly spend limit** on it. Workspaces exist precisely to *"segment your API keys and control spend by use case."* This protects them (runaway agent can't blow their whole budget) and you (no angry "why did this cost $400" emails). +2. They generate an API key in that workspace and paste `sk-ant-api03-…` into SwarmOps. +3. You **validate immediately** with a cheap call (e.g. `GET /v1/models` or a 1-token message) and show "✓ Connected." Store it **encrypted** (KMS / Fernet), never log it, and offer rotate/revoke. + +### The required code change (small but essential) +Today agents spawn `claude -p` with `CLAUDE_CODE_OAUTH_TOKEN`. Swap that env var for the customer's `ANTHROPIC_API_KEY`. The Claude Code CLI accepts a Console API key and bills the API account — so your orchestrator (`agent_pool.py`, `planner.py`, `rate_limit_watcher.py`) stays structurally identical; you're changing *which credential* is injected per run, and injecting the *right customer's* key per job. *(Auth behavior has changed fast this year — verify the exact current Claude Code API-key flag/env against the live Claude Code docs when you build it; if the CLI path ever gets restricted, the fallback is to drive runs through the Claude Agent SDK / Messages API, which is unambiguously API-key native.)* + +### Honest caveats to put in your onboarding copy +- **They need a Console account with pay-as-you-go billing**, not just a Pro/Max subscription. Some indie users only have a subscription — document "add a small Console credit" clearly; this is your biggest activation hurdle, so write the doc well and detect/redirect subscription-only users. +- **Their rate limits and spend tier are theirs.** A fresh Console org starts at Tier 1 and auto-tiers-up with spend. Surface rate-limit (429) errors gracefully and tell users how to raise limits. +- **You cannot see their token spend** (that needs *their* admin key). Don't promise a "$ spent" dashboard you can't power. You *can* show your own orchestration metrics — runs, PRs shipped, turns — just not their Anthropic bill. Encourage the workspace spend cap instead. +- **Why this is the win:** billing separation = ToS-clean (Commercial Terms, pay-per-use, no automation restrictions), **zero COGS, zero metering to build.** The June 15 2026 "Agent SDK credit" subscription change is now irrelevant to you — you're on API billing, not subscription. + +--- + +## 2. Pricing under pure subscription (revises v1 §5) + +Because you no longer mark up tokens, **every tier is a flat software subscription** and the customer's AI spend goes straight to Anthropic at cost. This *simplifies* the pricing page and strengthens the pitch. + +| Tier | Price (flat) | Limits (the paywall levers) | Target | +|---|---|---|---| +| **Hobby** | $0 | 1 repo, 1 concurrent agent, ~30 runs/mo, community support. BYO Anthropic key + GitHub App | Wedge / funnel | +| **Pro** | $39–49/mo | 3 repos, 3 concurrent agents, unlimited Planner, email/Slack notifications | Indie / solo founders | +| **Team** | $299–499/mo | 10+ concurrent agents, unlimited repos, seats + roles, Slack/Linear, priority support | Startup eng teams — revenue core | + +- **No managed/metered tier at all** (v1's Rail B is deleted, not deferred-with-effort — you simply don't build it). Less code, less risk. +- **Margin ~90%+ on every tier** — your only COGS is infra. +- **Positioning vs Devin's ACU metering:** *"Flat monthly price. Your AI usage bills straight to your Anthropic account at cost — no middleman markup, no surprise compute units. You set the spend cap."* That's a genuinely strong line for cost-conscious buyers. + +--- + +## 3. The GitHub App (replaces the single PAT + polling) + +A GitHub App is the right primitive for everything you described — repo access, commits, PRs, issues, and the @mention trigger — and it doubles as a **distribution channel** (GitHub Marketplace). + +### Why an App (not a PAT, not an OAuth App) +Per-installation **scoped tokens**, **granular least-privilege permissions**, its **own bot identity**, **webhooks** (event-driven, no polling), higher rate limits, and a Marketplace listing. The customer installs once and picks exactly which repos to expose. + +### Permissions to request (least privilege) +- **Contents:** Read & write (branches, commits) +- **Pull requests:** Read & write (open PRs, comment, push fixes) +- **Issues:** Read & write (read the plan, comment, create issues from the Planner) +- **Metadata:** Read-only (required baseline) +- **Checks / Commit statuses:** Read (see CI results to drive the review-fix loop) +- *(Avoid Actions/Workflows scope unless you truly need it — extra scopes slow Marketplace verification and scare buyers.)* + +**Webhook events to subscribe:** `issues`, `issue_comment`, `pull_request`, `pull_request_review`, `pull_request_review_comment`, `check_suite` / `check_run`, `status`, plus `installation` and `installation_repositories`. + +### Install + auth flow +1. User clicks **Install SwarmOps** → selects all or specific repos → grants permissions. +2. GitHub sends an `installation` webhook with an `installation_id`. Store the mapping `{ your_org → installation_id → repos }`. +3. To act on a repo, mint a **short-lived (1-hour) installation access token** by signing a JWT with your **App ID + private key**, then exchanging it. Cache tokens, refresh on expiry. + +### The @bot-mention trigger (exactly what you want) +1. A repo collaborator comments **`@swarmops-bot start`** on an issue or PR. +2. GitHub fires an `issue_comment.created` webhook to your `/api/github/webhook` endpoint. +3. **Verify the commenter has write/admin permission on that repo** — this is the critical security gate. Without it, anyone could comment and spend the customer's Anthropic key / run agents on their code. Check via the installation token (`GET /repos/{owner}/{repo}/collaborators/{user}/permission`). +4. Parse the command (`start`, `stop`, `plan`, `retry`) and **enqueue a job** for that installation + repo. The bot can reply with a 👍 reaction or a "on it 🛠️" comment for instant feedback. + +This mirrors your existing `TRIGGER_MENTION` logic — you're moving it from a polling-and-scan model to an event-driven one. + +### Bot identity & commit attribution +The app acts as **`swarmops-bot[bot]`**. Commits made via the API with the installation token are attributed to the bot and show as **Verified** in GitHub — which cleanly **replaces the `GIT_AUTHOR_NAME/EMAIL` hack** currently used to dodge deploy-platform rejections. Agents push branches and open PRs as the bot; the human reviews and merges. Clear, auditable separation between machine work and human approval. + +### Webhooks replace polling (big scalability win) +Deprecate the `issue_poller` and `pr_monitor` poll loops in favor of webhook handlers. Keep a **low-frequency reconcile poll** (e.g., every 30–60 min) only as a safety net for missed webhook deliveries. This is what lets you scale to thousands of repos without hammering the GitHub API every few minutes per repo. + +### Security specifics (don't skip) +- Verify every webhook's **HMAC signature** (`X-Hub-Signature-256`) against your webhook secret. +- Handle **`installation.deleted`** and **`installation_repositories.removed`** to revoke access and stop jobs immediately. +- **Idempotency** on redelivered webhooks (dedupe by delivery ID). +- **Per-installation rate limiting** and abuse caps (a mention storm shouldn't be able to fan out unbounded paid runs). + +--- + +## 4. How this rewires the codebase (delta vs. what you have) + +| Concern | Today | v2 | +|---|---|---| +| GitHub auth | Single `GH_TOKEN` PAT | **GitHub App** + per-install 1h tokens — new `github_app.py` (JWT mint, token cache, webhook HMAC verify) | +| Triggering | `issue_poller` polls + scans comments for mention | **Webhook receiver** `/api/github/webhook` on `issue_comment` / `pull_request`; @mention parse **+ write-access permission check** | +| PR review loop | `pr_monitor` polls PRs for comments/CI | `pull_request_review` / `check_suite` **webhooks**; reconcile poll as fallback | +| Claude auth | Shared `CLAUDE_CODE_OAUTH_TOKEN` (banned for 3rd-party!) | **Per-org `ANTHROPIC_API_KEY`** (customer's Console key), encrypted, injected per run in `agent_pool.py` | +| Commit identity | `GIT_AUTHOR_NAME/EMAIL` env hack | App **bot identity** via installation token (Verified commits) | +| Secrets storage | Plaintext env vars in SQLite | **Encrypted** column / KMS for: customer API keys, App private key, webhook secret | +| Data model | No tenant scoping | Add `github_installations`; scope all tables by org/tenant (v1 Phase 1) | + +--- + +## 5. Roadmap delta (vs. v1 §4) + +**Added** +- **GitHub App build** — register app, JWT/token plumbing, webhook receiver, @mention + permission gate, `installation` lifecycle handling, Marketplace listing. **≈ 2–3 focused weeks** (the webhook security + permission checks are the careful part, not the happy path). +- **Anthropic key connect** — deep-link UX, paste → validate → encrypt → inject; swap the agent credential. **≈ 0.5–1 week.** + +**Removed (time saved)** +- The **entire managed-metering system** from v1 (usage records, ACU-style billing, markup logic, spend reconciliation). You don't build any of it. + +**Net:** v2 is **less total build than v1**, because you delete metering and lean on a GitHub App you wanted anyway. The Phase-1 multi-tenant foundation (GitHub login, Postgres, tenant scoping, MoR subscription billing, encrypted secrets) is unchanged and is where most of the work still is. + +--- + +## 6. Risks & caveats specific to v2 + +- **Activation friction (the main one):** BYO Anthropic key requires a Console account with billing. Some indie users have only a subscription. Mitigate with excellent onboarding copy, a "why + how to add credit" doc, and detection that redirects subscription-only users. This is the #1 thing that will silently kill conversion — invest in it. +- **You're blind to their token spend** — set expectations explicitly; push the Console **workspace spend cap** during onboarding. +- **GitHub Marketplace review** — publishing and certain permission scopes may require GitHub verification; budget calendar time. The @mention-triggered compute makes the **write-access check non-negotiable** (it guards the customer's paid key). +- **You now hold crown jewels** — your App private key + every customer's API key. KMS, encryption at rest, rotation, audit logging, and a tight blast radius are table stakes the day you go multi-tenant. +- **Platform dependency** stays (v1 risk) — abstract the agent runner so a future non-Claude backend is *possible*, while staying Claude-native as the wedge. + +--- + +## 7. End-to-end onboarding (no-touch, ties to v1 §8) + +1. **Land** → "Connect GitHub" (GitHub OAuth login). +2. **Install the SwarmOps GitHub App** → pick repos. +3. **Connect Anthropic** → deep link to Console (create workspace + spend cap + key) → paste → validate ✓. +4. **Subscribe** (Merchant-of-Record checkout) — or start on Free. +5. **Comment `@swarmops-bot start`** on an issue → first PR lands. **Activation.** + +All self-serve, all async, zero calls — consistent with v1's no-touch motion. + +--- + +### Sources +- [Anthropic — API overview & authentication (Console API keys, workspaces for spend control)](https://platform.claude.com/docs/en/api/overview) +- [Anthropic — Admin API (org-only; not a third-party key-provisioning mechanism)](https://platform.claude.com/docs/en/manage-claude/admin-api) +- [Anthropic bans subscription/OAuth auth for third-party apps (Feb 2026); API keys required](https://autonomee.ai/blog/claude-code-terms-of-service-explained/) · [OAuth tokens disabled for third-party apps](https://alternativeto.net/news/2026/2/anthropic-officially-bans-using-subscription-authentication-for-third-party-claude-use) +- [GitHub Docs — Authenticating as a GitHub App installation (installation access tokens)](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation) diff --git a/SwarmOps-Product-Strategy.md b/SwarmOps-Product-Strategy.md new file mode 100644 index 0000000..cd1899c --- /dev/null +++ b/SwarmOps-Product-Strategy.md @@ -0,0 +1,262 @@ +# SwarmOps → Product: Founder-Mode Strategy + +*Prepared from a full read of the codebase (orchestrator ~6,000 LOC Python, dashboard ~4,000 LOC React) and current-market research, June 2026. Estimates assume you build solo with Claude Code.* + +--- + +## TL;DR — the six decisions + +1. **You already have the hard, rare part.** A working autonomous swarm that turns GitHub issues into merged PRs *and closes the CI-review loop* is genuinely differentiated. Most competitors open a PR and stop. Don't rebuild it — wrap it. +2. **The cost/licensing model is the whole ballgame, and it's time-sensitive.** Your current single shared `CLAUDE_CODE_OAUTH_TOKEN` (a Max plan) **cannot legally be resold or shared across customers.** Anthropic explicitly bans routing requests through subscription credentials on behalf of others. Build a **two-rail model**: self-hosted BYO-credentials (their keys, their use) + managed cloud on **API keys under Commercial Terms** with metering. See §2. +3. **Heads-up: June 15, 2026** (6 days out) Anthropic moves `claude -p` / Agent SDK subscription usage to a separate monthly "Agent SDK credit." This changes the economics of the exact mechanism your product runs on. Re-test throughput the day it lands. +4. **Who to sell to:** land with **indie/solo devs** (huge top-of-funnel, self-serve, near-zero COGS on BYO-key), monetize on **small startup eng teams, 5–50 devs** (that's where revenue concentrates). Skip enterprise until you have a team. See §3. +5. **Time to a sellable, scalable v1:** ~**3–4 focused months** with Claude Code for Phases 1–2 — and a **self-serve free tier can take its first paying customer in week 2–3**, with zero sales calls (§4, Phase 0). +6. **Distribution moat = open-core.** The repo is already self-hostable. Open-source the orchestrator, sell the hosted multi-tenant cloud + team features. This is the Sentry/GitLab/PostHog playbook and it's your cheapest growth channel (§6, §7). +7. **No-touch by design.** You don't want to sell on calls — good, this product shouldn't need them. The entire motion is product-led and async: sign up with GitHub, self-onboard to a first merged PR, pay by card via a Merchant of Record, get support over email/Discord. The trade is that build energy moves from *selling* into *onboarding UX + content* — which suits you (§8). + +--- + +## 1. What you've actually built (honest asset assessment) + +### The assets (valuable and hard to copy) + +- **A real autonomous loop, not a demo.** `issue_poller` → `agent_pool` (spawns `claude -p` in isolated git worktrees) → `pr_monitor` (watches review comments) → dispatches fix agents → loops until 0 comments or retry cap → escalates to `needs-human`. The **review-fix loop** is the differentiator. Devin, Copilot agent, Sweep mostly open a PR and stop; you iterate against bugbot/CI feedback automatically. +- **Parallel swarm semantics.** Concurrency cap, turn limits, hard timeouts, retry limits, rate-limit watcher with resume, crash recovery, graceful shutdown. This is the unglamorous reliability scaffolding that takes months to get right, and you have it. +- **The AI Planner.** Plain-language description → codebase-aware, file-by-file implementation plan → GitHub issue → agent build. This is your **best self-serve hook and your best marketing demo** — it's visual, it's "magic," and it lowers the skill floor. +- **Multi-workspace + env management + skills support + a planning API** with static API keys for external automation. You're already past "script" into "platform." +- **It's self-hostable.** `run.sh` does systemd install, UI build, skill install. That's a distribution asset (open-core, §6). + +### The gaps (what "a product people pay for" requires) + +Today the system is **single-tenant by construction.** Specifically: + +| Gap | Current state | Why it blocks a paid product | +|---|---|---| +| **Identity** | One `ADMIN_USERNAME` / `ADMIN_PASSWORD` in `.env`, 30-day cookie | No way for many customers to sign up, isolate, or pay | +| **Credentials** | One `CLAUDE_CODE_OAUTH_TOKEN` + one `GH_TOKEN`, injected into every agent | Can't legally or safely serve multiple customers from one set of keys | +| **Data model** | SQLite, no `tenant_id` on any table | No row-level isolation between customers | +| **Execution** | Agents run as local subprocesses on one host, sharing the box | Running untrusted customer code + secrets on a shared host is a security non-starter at scale | +| **GitHub access** | Single fine-grained PAT | Doesn't scale to many orgs; "paste a PAT" is friction + a trust killer | +| **Billing** | None | No revenue | +| **Secrets** | Env vars stored plaintext in SQLite | Unacceptable for a multi-tenant SaaS | +| **Trust surface** | No ToS/privacy/DPA, no audit log, no status page | Teams won't buy without these | + +None of this is a criticism — it's exactly the right shape for a tool you built for yourself. The work ahead is **productization, not reinvention.** + +--- + +## 2. The decision that determines everything: the Claude cost/licensing model + +This is where most people building on Claude Code quietly break the rules and then get cut off. Get it right deliberately. + +### The ToS reality (researched, June 2026) + +- OAuth tokens (what `claude setup-token` gives you, what your `.env` holds) are **"intended exclusively for ordinary use of Claude Code"** by the subscriber. +- Anthropic **"does not permit third-party developers to offer Claude.ai login or to route requests through Free, Pro, or Max plan credentials on behalf of their users,"** and reserves the right to enforce **without prior notice.** +- For **commercial/automated** use, the sanctioned path is **API keys under the Commercial Terms — "no automation restrictions, no ambiguity."** +- **June 15, 2026:** `claude -p` / Agent SDK usage on subscription plans starts drawing from a **separate monthly Agent SDK credit.** Your product *is* `claude -p`. Re-benchmark throughput-per-dollar the day this lands. + +**Translation:** the moment you point your existing single-token architecture at paying customers, you are reselling your Max plan — a ToS violation that can be killed overnight. Don't. + +### Recommendation: a two-rail model + +**Rail A — Self-Hosted / BYO-credentials (your wedge, ~$0 COGS).** +The customer runs SwarmOps (open-core) or your one-click deploy, and connects **their own** Claude credentials and **their own** GitHub. They're using their own subscription for their own work — "ordinary use." You sell the **orchestration software / a license / a thin hosted control-plane**, not Claude access. Gross margin is software-grade (85–95%). This is also the most honest sales pitch to a developer: *"your code and your keys never leave your infra."* + +**Rail B — Managed Cloud (your revenue engine, you carry COGS).** +You host everything. You run agents on **Anthropic API keys under Commercial Terms**, **meter token/compute usage per run**, and **mark it up** (industry norm 30–60%; Devin's whole model is metered "ACUs" at ~$2–2.25 each ≈ 15 min of work). Customers who don't want to manage keys or infra pay a premium for convenience. This is ToS-clean because it's API + Commercial Terms, not subscription resale. + +**Never do:** one shared Max token across customers. It's the one move that's both illegal-per-ToS and economically suicidal (rate limits collapse, one ban kills everyone). + +> Founder note: Rail A gets you to market and revenue fast with no cost risk. Rail B is where you eventually make real margin and serve teams who value "it just works." Start A, add B once you have demand and the metering plumbing. + +--- + +## 3. Who to sell to — my recommendation (you asked for my view) + +The honest scoring is **volume × ACV × conversion-ease × retention:** + +| Segment | Volume | ACV | Self-serve? | Retention | Verdict | +|---|---|---|---|---|---| +| Indie / solo devs | Huge | Low ($0–50/mo) | Yes | Churny | **Wedge / funnel** | +| Startup eng teams (5–50) | Medium | Mid ($200–2k/mo) | Mostly | Sticky (in workflow) | **Revenue core** | +| Mid-market / enterprise | Low | High ($20k+/yr) | No | Sticky | **Later, not solo** | +| Agencies / resellers | Low | Mid | No | Medium | **Opportunistic** | + +**My call: "Land with indies, monetize on teams."** Same product, one expansion motion. + +- **Indie/solo devs are the wedge,** not the business. Every developer with a Claude subscription and a messy backlog is a candidate, conversion is self-serve, and on BYO-key your cost to serve them is ~zero — so a generous free tier is pure growth fuel and word-of-mouth. +- **Small startup eng teams are where money concentrates.** They have a real backlog-velocity problem, a budget, and once SwarmOps is wired into their issue tracker it's *in the workflow* and hard to rip out. 5–20× the ACV of an indie, far stickier. +- **Skip enterprise** as a solo founder. SSO/SOC2/procurement/security review will eat 6 months and you'll lose the plot. Revisit once you have a team and 20+ paying teams as references. + +Design pricing so a solo user **naturally pulls in their team** (shared workspaces, "invite a teammate to review the PRs"). That's your expansion loop. + +--- + +## 4. Product roadmap: what to build, in what order, how long + +Estimates are in **focused solo-weeks building with Claude Code.** Claude Code roughly **1.5–2.5×'s** your throughput on boilerplate (auth, CRUD, Stripe, migrations, React screens). It helps **less** on the security-critical, judgment-heavy parts (multi-tenant execution isolation, GitHub App, secrets) — those take real care regardless of tooling. Wall-clock will be longer than the focused-week numbers because you're *also selling, supporting, and marketing* solo — budget ~1.5× calendar time. + +### Phase 0 — Self-serve paid beta (week 1–3). *No calls. Build almost nothing.* +Validate willingness to pay **without ever talking to anyone.** Stand up the smallest possible no-touch loop on top of today's single-tenant code: + +1. A **landing page** with the demo video, transparent pricing, and one button: "Connect your GitHub." +2. A **one-command provisioner** that spins up a dedicated SwarmOps instance per signup, running today's code with *their* keys (per-customer isolation "for free" via infra — same trick as before, just automated instead of hand-held). +3. A **Merchant-of-Record payment link** (Lemon Squeezy / Polar / Paddle) for a flat $99–299/mo. Card on file, they self-cancel, you never invoice. +4. A **Discord invite + support email** in the welcome screen. That's your entire "onboarding." + +Feedback comes from **watching, not asking**: drop PostHog on the page and the dashboard and read where people stall. If you want richer signal, send one async email ("hit reply if anything broke") — optional, never a meeting. + +**Deliverable: first real revenue + funnel data on where signup→first-PR breaks — with zero sales calls.** +*Effort: landing page + provisioner script + MoR link + PostHog. ~1 week of build.* + +### Phase 1 — BYO-key multi-tenant MVP (≈ 4–7 weeks). *Rail A, sellable.* + +| Workstream | What | Est. | +|---|---|---| +| **Auth + org model** | "Sign in with GitHub" (perfect fit), `organizations` / `users` / `memberships`, replace admin-password auth | 1.5–2.5 wk | +| **Tenant isolation** | Add `tenant_id` everywhere; scope every query; migrate **SQLite → Postgres** | (incl. above) | +| **Encrypted credentials** | Per-org Claude token + GitHub connection, encrypted at rest (Fernet/libsodium + KMS), injected per run | 0.5–1 wk | +| **Billing** | Merchant-of-Record subscriptions (Lemon Squeezy/Polar/Paddle — they own tax + invoices) + plan limits + self-serve customer portal + webhooks; gate `MAX_CONCURRENT_AGENTS`, repos, runs by plan | 1 wk | +| **Onboarding + polish** | Connect-GitHub → pick repos → first run wizard; empty states; docs | 1 wk | + +*Defer the hardest part:* keep execution as **one container per customer** (managed isolation by infra) so you don't block launch on a sandbox runtime. This is enough to **charge teams.** + +### Phase 2 — Secure execution + GitHub App + queue (≈ 4–8 weeks). *Scale-ready.* + +| Workstream | What | Est. | +|---|---|---| +| **GitHub App** | Replace PAT with a GitHub App: per-install tokens, granular permissions, webhooks (kills "paste a PAT" friction, unlocks GitHub Marketplace distribution) | 1.5–2 wk | +| **Ephemeral sandboxed runner** | Per-run containers (Docker → Firecracker/gVisor), resource + **egress** limits, guaranteed teardown/secret-scrub. *The genuinely hard, security-sensitive piece.* | 2–4 wk | +| **Job queue + workers** | Redis/RQ/Celery or SQS instead of in-process threads; autoscale workers; survive restarts | 1–1.5 wk | +| **Secrets + observability** | KMS-backed secrets, Sentry, per-tenant rate limiting, audit log | 1 wk | + +### Phase 3 — Team features + managed tier + trust (≈ 6–10 weeks, ongoing). +Seats/roles/RBAC; Slack + Linear/Jira integrations; email/Slack notifications ("3 PRs ready for review"); **Rail B metered managed tier** + usage dashboards; ToS/privacy/DPA + security page; SSO when you start chasing bigger teams. + +**Bottom line:** Phase 0 = revenue in ~2 weeks. Phases 1–2 = a sellable, scalable product in **~3–4 focused months.** Phase 3 is continuous and demand-driven. + +--- + +## 5. Monetization & pricing (based on your current logic) + +Your code already exposes the **natural value metrics**: concurrent agents (`MAX_CONCURRENT_AGENTS`), number of workspaces/repos, and agent-runs (issues resolved / PRs shipped). Price on those. + +| Tier | Price | Limits (the paywall levers) | Target | +|---|---|---|---| +| **Hobby** | $0 (BYO-key) | 1 repo, 1 concurrent agent, ~30 runs/mo, community support | Wedge / funnel | +| **Pro** | $39–49/mo (BYO-key) | 3 repos, 3 concurrent agents (your current default!), unlimited Planner, email notifications | Indie / solo founders | +| **Team** | $299–499/mo (BYO-key) | 10+ concurrent agents, unlimited repos, seats, roles, Slack/Linear, PR notifications, priority support | **Startup eng teams — your revenue core** | +| **Managed** | Team price **+ metered usage** (Rail B) | We run the keys + infra; usage billed at API-cost × ~1.4–1.6; SSO; SLA | Teams who won't manage keys; later enterprise | + +Pricing logic notes: +- **Anchor against the market:** Devin $20 → $500 + ACUs; Copilot agent $39 Pro+; Cursor $60 Pro+. A $39 Pro / $299 Team line sits credibly in that band while your BYO-key COGS is ~$0, so your **gross margin on BYO-key tiers is 85–95%.** +- **Concurrency is your cleanest paywall** — it's already a config variable, it maps directly to value (more parallel agents = more backlog cleared per night), and it's painless to meter. +- **Outcome framing converts better than seat framing** for this product: "PRs shipped while you sleep" > "per developer." Consider a usage-credit option on Rail B that mirrors Devin's ACU model so buyers can compare apples-to-apples. + +--- + +## 6. How to scale — technical and business + +### Technical (the architecture you grow into) +Postgres (multi-tenant, row-level scoping) → job queue + autoscaling stateless workers → **ephemeral per-run sandboxes** (the core scaling unit; one customer's code/secrets must never touch another's) → GitHub App for per-install auth + webhooks (replace polling, which won't scale to thousands of repos) → KMS for secrets → Sentry/metrics/audit → multi-region later. The orchestrator logic you have stays largely intact; you're wrapping it in isolation + a control plane. + +### Business (your real moat is distribution) +**Open-core.** Your repo is already self-hostable — lean into it. Open-source the orchestrator under a permissive-but-protected license (Apache/MIT core, or BSL if you want to prevent a hyperscaler reselling it). Monetize the **hosted multi-tenant cloud + team features + support.** Why this wins for you specifically: +- GitHub stars are free, compounding distribution for a dev tool. +- Self-hosters become your funnel: they hit the ops pain, then upgrade to managed. +- It's the proven path (Sentry, GitLab, PostHog, n8n, **OpenHands** — your closest competitor is open-source and just raised $18.8M doing exactly this). + +The strategic risk: **OpenHands is close** (open-source, self-hosted, issue→PR, GitHub/Slack, Kubernetes, well-funded). Your defensible wedge vs. them: **Claude-Code-native** (you ride Claude's coding quality and brand rather than a generic agent), the **autonomous review-fix loop**, the **AI Planner**, and **radical simplicity** ("label an issue, get a merged PR" with far less setup). Pick that wedge and hammer it. + +--- + +## 7. Go-to-market & marketing + +Every channel below is **async and call-free** by design — it's all writing, building, and posting, never meetings. That's deliberate: a no-touch product is *acquired* the same way it's sold. Pricing is public, signup is self-serve, and "talk to sales" never appears. + +### Positioning (the one sentence) +**"SwarmOps clears your GitHub backlog while you sleep — a swarm of Claude Code agents that turn labeled issues into reviewed, merged PRs, and fix their own review comments."** + +The wedge is **autonomy + the closed review loop**, against IDE-bound tools (Cursor/Copilot live in your editor; SwarmOps works the backlog unattended). + +### Channels for a solo dev-tool founder (in priority order) +1. **Build in public.** You're already active on LinkedIn/X (you even have a LinkedIn comment-publisher workflow). Post the money shot weekly: *"Left 12 issues labeled `agent` Friday night. Woke up to 9 merged PRs. Here's the dashboard."* Demo > description for this product. +2. **Open-source launch.** "Show HN: SwarmOps — autonomous Claude Code agents that clear your GitHub backlog." HN + Product Hunt + r/programming + r/ClaudeAI + r/ExperiencedDevs + dev.to. The self-hostable repo *is* the launch asset. +3. **The Planner demo video.** Loom/short-form: plain-English feature → generated plan → issue → swarm → merged PR. This is inherently shareable and is your best top-of-funnel content. +4. **GitHub Marketplace** (once the GitHub App ships, Phase 2). Native distribution to exactly your buyer, at the moment of intent. +5. **SEO/comparison content.** "Automate your GitHub backlog," "Claude Code in CI/CD," "autonomous coding agents compared," and honest comparison pages: *vs Devin, vs Copilot coding agent, vs Cursor background agents, vs OpenHands, vs Sweep.* Buyers in this category search these exact queries. +6. **Ride the Claude Code wave.** Anthropic actively showcases ecosystem tools; being a visible, well-built Claude-Code-native product is a tailwind. Engage their dev community/Discord. +7. **Templates & skills as content.** You already support skills — ship a few great ones and write them up; each is a discovery surface. + +### Competitive one-liners (for your battlecard) +- **vs Devin:** "Devin is one autonomous engineer you rent by the ACU. SwarmOps is a *swarm* on *your* Claude keys clearing the whole backlog in parallel — and it fixes its own review comments." +- **vs Copilot/Cursor agents:** "Those live in your editor and wait for you. SwarmOps works your issue tracker unattended and closes the PR review loop." +- **vs OpenHands:** "Claude-Code-native, simpler to run, with a built-in planner and an autonomous review-fix loop. Label an issue — that's the whole setup." + +--- + +## 8. The no-touch self-serve stack (and where it plugs into your code) + +The goal: a stranger finds you, signs up, reaches a merged PR, and pays — with you asleep. Here's the concrete stack and exactly where each piece attaches to what you've already built. The good news: your existing `sessions` table, cookie/middleware auth, and workspace flow are the right *shape* — you're swapping the credential check and adding a tenant + billing layer, not starting over. + +| Need | Tool (recommended) | Where it plugs into your code | Effort | +|---|---|---|---| +| **Self-serve login** | GitHub OAuth | Replace the `ADMIN_USERNAME`/`ADMIN_PASSWORD` check in `dashboard.py` `/api/auth/login`. **Keep** the existing `sessions` table + cookie middleware — just mint the session after a GitHub OAuth callback instead of a password compare. Add `users` + `organizations` + `memberships` tables alongside `workspaces`. | 1.5–2.5 wk | +| **Payments, tax, invoices — all of it** | **Merchant of Record:** Lemon Squeezy, Polar, or Paddle | Net-new `/api/billing/webhook` route in `dashboard.py`. On `subscription.created/updated/canceled`, write a `plan` + `status` onto the org. MoR handles VAT/sales tax, receipts, dunning, chargebacks — the stuff you'd otherwise email people about. (Plain Stripe also works but then *you* own tax.) | ~1 wk | +| **Enforce plan limits** | Your own entitlement check | Gate at the spawn point: in `agent_pool.py` check the org's plan before launching (cap `MAX_CONCURRENT_AGENTS`), and in `workspace_manager.create_workspace` cap repo count. Count agent-runs per month for the free-tier ceiling. This *is* your paywall. | (incl. above) | +| **Onboarding to first PR** | In-app wizard + great empty states | Extend the existing `AddWorkspaceModal`/workspace flow into a guided "connect GitHub → pick a repo → label an issue → watch it ship" first-run. This is the highest-ROI build for a no-touch motion — every % of activation lift is pure revenue. | ~1 wk | +| **See where users stall (instead of asking on a call)** | **PostHog** (free tier) | Snippet in `frontend/src/main.jsx`; fire backend events from `dashboard.py` on key steps (signup, repo connected, first run, first merged PR, paywall hit). Funnels + session replay tell you what a call would have, asynchronously. | 1–2 days | +| **Docs that answer the questions you'd otherwise field** | Mintlify / Docusaurus (separate repo) | Standalone site. Link from the dashboard empty states and error toasts. | ongoing | +| **Async support + community** | Email + **Discord**; AI support bot later | Invite link + support email on the welcome screen. The community answers itself over time. | hours | +| **Let users ask for features without you interviewing them** | Public roadmap: Canny or GitHub Issues | Link in the dashboard footer. Voting replaces discovery calls. | hours | +| **Demo that does the selling** | Loom / a hosted live demo | The Planner→PR loop video, embedded on the landing page and pinned everywhere. | 1 day | + +**The principle:** anywhere you'd normally *talk to a user*, substitute a built artifact — a wizard for onboarding, PostHog for discovery, docs for questions, a roadmap for feature requests, a MoR for billing ops. Each is a one-time build that scales to unlimited customers with zero of your time. + +--- + +## 9. Unit economics (illustrative) + +**Rail A (BYO-key), Pro $44/mo:** COGS ≈ infra only (~$1–3/customer/mo on a shared control plane). **Gross margin ~93%.** This tier exists to grow and to feed Team. + +**Rail A Team $399/mo:** still ~90% margin. 50 Team customers ≈ **$240k ARR** at near-software margins. This is a very reachable solo-founder target in 12–18 months given the funnel. + +**Rail B (managed), metered:** you pay Anthropic API per run, bill at ~1.4–1.6×. Margin 30–40% on the metered portion + the platform fee. Lower margin, higher ACV, serves the "just make it work" buyer — add it once Rail A demand proves the wedge. + +The headline: **on BYO-key you can run an aggressive free tier and still have software-grade margins,** because you're selling orchestration, not tokens. That's your structural advantage over Devin-style metered competitors. + +--- + +## 10. Risks & moats + +**Risks** +- **Anthropic ToS / June 15 Agent-SDK-credit change** (§2) — the single biggest one. Mitigate by building Rail B on Commercial-Terms API keys and never sharing subscription tokens. +- **Platform dependency** — you're a layer on Claude Code. Mitigate by abstracting the agent runner so you *could* add other models, while staying Claude-native as the wedge. +- **OpenHands / well-funded open-source** — mitigate with the Claude-native + review-loop + simplicity wedge and speed. +- **Trust** — running agents against customer code with customer secrets. Mitigate with ephemeral sandboxes, "we never store your code," self-host option, and eventually SOC2. + +**Moats (build deliberately)** +- The **autonomous review-fix loop** + reliability scaffolding (hard to replicate, you already have it). +- **Open-core distribution** + GitHub Marketplace presence. +- **Workflow lock-in** — once it's wired into a team's issue tracker and shipping PRs, switching cost is real. +- **Data flywheel** (later) — aggregate (opt-in, anonymized) signal on which plans/prompts produce merged PRs → better Planner → better outcomes → more merges. + +--- + +## 11. Your next 14 days + +1. **Re-benchmark on June 15** when the Agent SDK credit change lands — know your real throughput-per-dollar before you price. +2. **Stand up the no-touch Phase 0 loop:** landing page + the Planner demo video + a one-command per-signup provisioner + a Merchant-of-Record payment link + PostHog. No call, no invoice. +3. **Drive your first traffic async** — post the demo in indie-hacker and Claude communities, on LinkedIn/X, and let the page convert. Your only job is to ship the link, not to pitch. +4. **Decide the license** (Apache vs BSL) and prep the open-source launch — repo polish, README, the Planner demo video. +5. **Write the comparison pages** (vs Devin / Copilot / Cursor / OpenHands) — they're SEO + self-serve conversion assets you'll reuse everywhere. +6. **Start Phase 1** (GitHub login + Postgres + per-org encrypted keys + MoR billing) with Claude Code while Phase 0 runs and collects funnel data. + +--- + +### Sources +- [Devin Plans and Pricing](https://devin.ai/pricing/) · [Devin 2026 pricing breakdown (ACU costs)](https://aitoolpick.org/blog/devin-pricing-2026/) +- [Copilot Coding Agent vs Codex vs Cursor Background Agents — 2026 workflow map](https://ralphable.com/blog/copilot-coding-agent-vs-codex-vs-cursor-background-agents-2026) +- [Anthropic — Claude Code legal & compliance](https://code.claude.com/docs/en/legal-and-compliance) · [Claude Code Terms of Service explained (OAuth vs API, reselling, June 15 2026 Agent SDK credit)](https://autonomee.ai/blog/claude-code-terms-of-service-explained/) +- [OpenHands — open platform for cloud coding agents](https://www.openhands.dev/) · [Open-source coding agents fixing your GitHub issues](https://www.openhands.dev/blog/open-source-coding-agents-in-your-github-fixing-your-issues) diff --git a/SwarmOps-System-Design.md b/SwarmOps-System-Design.md new file mode 100644 index 0000000..2a3f55c --- /dev/null +++ b/SwarmOps-System-Design.md @@ -0,0 +1,237 @@ +# SwarmOps — System Design (v2 refactor) + +*The architecture blueprint the code follows. Companion to `SwarmOps-Plan-v2.md`. Scope of the first build slice: **foundational abstractions + Anthropic BYO-key**, on **Postgres**, evolving multi-tenant on a branch. Everything here is written so later slices (GitHub App, billing, full multi-tenant auth) drop in without rework.* + +--- + +## 1. Goals & constraints + +**Functional (this slice)** +- Each tenant (organization) supplies its **own Anthropic Console API key**; agents for that org run on *that* key (`ANTHROPIC_API_KEY`), never a shared subscription token. +- Secrets are **encrypted at rest** with a per-tenant data key (envelope encryption); a DB dump leaks only ciphertext. +- The credential mechanism is **pluggable** — API key today; KMS/Vault/WIF later — without touching call sites. + +**Non-functional** +- Postgres as system of record (concurrent, multi-tenant, real migrations). +- No business logic in the execution path knows *how* a secret is stored or *which* credential type is used — it asks an interface. +- Keep the orchestrator's proven control loop intact; we change *plumbing*, not the swarm logic. + +**Constraints** +- Solo build with Claude Code; incremental, each step compiles. +- Branch `feat/v2-byok-foundations`; temporary breakage of higher layers is acceptable, but new code must be coherent and tested. + +--- + +## 1a. Deployment modes (single-tenant stays first-class) + +The OSS self-host / internal-team deployment must keep running exactly as it +does today, so `DEPLOYMENT_MODE` selects the wiring in the composition root: + +| Mode | Default | Credentials | Storage | Postgres? | +|---|---|---|---|---| +| `single_tenant` | ✅ yes | `EnvCredentialProvider` — injects `CLAUDE_CODE_OAUTH_TOKEN` (unchanged) or `ANTHROPIC_API_KEY` from env | none | **no** | +| `multi_tenant` | — | `ApiKeyCredentialProvider` — per-org Console key | `FernetSecretStore` (encrypted) | yes | + +In single-tenant mode `resolve_org_id()` returns a constant and never imports the +DB layer, so the self-host path needs no Postgres, no master key, and no schema +migration — it behaves byte-for-byte like the original. The same `CredentialProvider` +interface serves both, so the execution path is identical regardless of mode. + +## 2. Layered architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Interface layer dashboard.py (FastAPI) · pollers │ +│ CLI / webhooks (later) │ +├─────────────────────────────────────────────────────────────┤ +│ Service layer AgentPool · Planner · PRMonitor │ ← orchestration logic +│ (depend on INTERFACES, not globals) │ +├─────────────────────────────────────────────────────────────┤ +│ Domain interfaces CredentialProvider · SecretStore │ ← the seams +│ repositories (Org/Credential/Workspace…) │ +├─────────────────────────────────────────────────────────────┤ +│ Infrastructure SQLAlchemy engine/session · Alembic │ +│ FernetSecretStore · (KMS/Vault TODO) │ +│ Postgres │ +└─────────────────────────────────────────────────────────────┘ +``` + +Dependencies point **downward only**. The service layer receives its collaborators by **dependency injection** from a single composition root (`orchestrator/container.py`), so nothing deep in the stack imports `config.CLAUDE_CODE_OAUTH_TOKEN` anymore. + +### Design patterns in play (and why) +- **Repository** — one class per aggregate hides SQL/ORM behind intention-revealing methods. Swaps the 850-line `db.py` god-module for testable units. +- **Strategy** — `SecretStore` and `CredentialProvider` are interfaces with interchangeable implementations (Fernet now, KMS/WIF later). New backend = new class, zero call-site edits. +- **Dependency Injection / Composition Root** — `container.py` wires concrete implementations once; everything else takes them as constructor args. Makes unit testing trivial (inject fakes). +- **Strangler Fig** — the new Postgres/repository stack grows *alongside* legacy `db.py`; call sites migrate one module at a time behind an adapter, never a risky big-bang rewrite. Each migrated module deletes its slice of `db.py`. +- **Adapter** — during transition, legacy `db.py` functions become thin adapters delegating to repositories, so unmigrated callers keep working on Postgres. + +--- + +## 3. Package structure (target) + +``` +orchestrator/ +├── container.py # composition root — builds & wires singletons +├── settings.py # typed config (pydantic-settings) — replaces ad-hoc os.environ +├── infra/ +│ ├── db.py # SQLAlchemy engine + session factory (Postgres) +│ └── crypto.py # envelope encryption primitives (Fernet + per-tenant DEK) +├── models/ # SQLAlchemy ORM models (one file per aggregate) +│ ├── base.py # DeclarativeBase, TimestampMixin, UUID pk helper +│ ├── organization.py # organizations, users, memberships +│ ├── credential.py # org_credentials (encrypted), tenant_keys +│ ├── workspace.py # workspaces (+ org_id), workspace_env… +│ └── … # issues, agents, pr_reviews, planning… (ported incrementally) +├── repositories/ +│ ├── base.py # Repository[T] generic base +│ ├── organization_repo.py +│ ├── credential_repo.py +│ └── workspace_repo.py +├── secrets/ +│ ├── base.py # SecretStore (ABC) +│ ├── fernet_store.py # FernetSecretStore (envelope encryption) +│ └── env_store.py # EnvVarSecretStore (dev/bootstrap fallback) +├── credentials/ +│ ├── base.py # CredentialProvider (ABC) + AgentCredentials value object +│ └── api_key_provider.py # ApiKeyCredentialProvider → ANTHROPIC_API_KEY +├── alembic/ # migrations +└── (existing modules: agent_pool, planner, pr_monitor, … refactored to use the above) +``` + +--- + +## 4. Data model (Postgres) + +New/changed tables for this slice. IDs are UUID (text) to stay merge-friendly and avoid cross-tenant enumeration. + +``` +organizations + id (uuid pk) · name · slug (unique) · created_at · updated_at + -- TODO(auth-slice): plan, stripe_customer_id, status + +users -- minimal now; full auth is a later slice + id (uuid pk) · github_id (unique, nullable) · email · name · created_at + -- TODO(auth-slice): avatar_url, last_login_at + +memberships + id (uuid pk) · org_id (fk) · user_id (fk) · role (owner|admin|member) + UNIQUE(org_id, user_id) + +tenant_keys -- per-tenant data-encryption-key (DEK), wrapped by the master key + org_id (uuid pk, fk) · wrapped_dek (bytea) · key_version (int) · created_at + -- the master key (KEK) lives in env now; TODO(kms): move KEK to AWS KMS / Vault + +org_credentials -- the encrypted Anthropic key (and future provider creds) + id (uuid pk) · org_id (fk) · provider (text: 'anthropic') + ciphertext (bytea) · key_version (int) + status (active|invalid|revoked) · last_validated_at · created_at · updated_at + UNIQUE(org_id, provider) + +workspaces -- existing + org scoping + id · org_id (fk, NEW) · name · github_repo · repo_url · local_path + base_branch · status · is_monorepo · structure_json · created_at · updated_at +``` + +Existing tables (`issues`, `agents`, `agent_events`, `pr_reviews`, `pr_conflict_fixes`, `planning_*`, `workspace_env*`, `sessions`) are ported to ORM models and gain `org_id` where they represent tenant data. Tenant scoping on those is wired as each module migrates (TODO markers per module). + +**Migrations:** Alembic. One initial migration creates the full schema on Postgres. Schema changes from here are versioned migrations — no more `CREATE TABLE IF NOT EXISTS` drift. + +--- + +## 5. The two seams (interfaces) + +### SecretStore — *how* a secret is stored +```python +class SecretStore(ABC): + @abstractmethod + def put(self, org_id: str, name: str, plaintext: str) -> None: ... + @abstractmethod + def get(self, org_id: str, name: str) -> str | None: ... + @abstractmethod + def delete(self, org_id: str, name: str) -> None: ... +``` +- `FernetSecretStore` — envelope encryption: a per-tenant **DEK** (in `tenant_keys`) encrypts the secret; the DEK is itself wrapped by a master **KEK**. Ciphertext lives in `org_credentials`. No plaintext ever hits disk or logs. *(TODO(kms): swap the KEK source from env to AWS KMS / Vault; the interface does not change.)* +- `EnvVarSecretStore` — reads from process env; dev/bootstrap only. + +### CredentialProvider — *which* credential an agent run gets +```python +@dataclass(frozen=True) +class AgentCredentials: + env: dict[str, str] # injected into the agent subprocess + +class CredentialProvider(ABC): + @abstractmethod + def for_org(self, org_id: str) -> AgentCredentials: ... + @abstractmethod + def validate(self, org_id: str) -> bool: ... # cheap live check +``` +- `ApiKeyCredentialProvider` — pulls the org's Anthropic key from the `SecretStore`, returns `env={"ANTHROPIC_API_KEY": key}`. `validate()` hits a cheap endpoint (e.g. `GET /v1/models`) and updates `org_credentials.status`/`last_validated_at`. +- *(TODO(wif): `WifCredentialProvider` mints short-lived OIDC tokens — no stored secret. Same interface.)* +- *(TODO(legacy): `OAuthTokenCredentialProvider` only if a self-host single-tenant mode is ever needed; not built now.)* + +**Why two interfaces, not one:** storage and credential-semantics change independently. You might keep Fernet storage but switch from API-key to WIF; or keep API-key but move storage to KMS. Splitting the seams keeps each change isolated. + +--- + +## 6. Execution path — before vs after + +The injection sites today (all hardcode the shared, now-banned token): +- `agent_pool._spawn_agent` (~L536) and the resume path (~L1029) +- `planner.py` (~L198) +- `rate_limit_watcher.py` (~L25) + +**Before** +```python +env = {**os.environ, "CLAUDE_CODE_OAUTH_TOKEN": CLAUDE_CODE_OAUTH_TOKEN, "GH_TOKEN": GH_TOKEN} +``` + +**After** — the pool holds an injected `CredentialProvider`; the workspace resolves to an org: +```python +creds = self._credentials.for_org(org_id) # AgentCredentials +env = {**os.environ, **creds.env, "GH_TOKEN": gh_token} +``` +`org_id` comes from the workspace (`WorkspaceRepository.get(workspace_id).org_id`). `AgentPool`, `Planner`, and `RateLimitWatcher` each receive the `CredentialProvider` via the container — they no longer import `config` token globals. `rate_limit_watcher` probes per-org (TODO: it currently assumes one global account; under BYO-key, rate limits are per-org, so the watcher becomes per-org-aware — tracked as a TODO in that module). + +--- + +## 7. Configuration + +Replace scattered `os.environ.get` with one typed `settings.py` (pydantic-settings): +- `DATABASE_URL` (Postgres DSN) +- `SECRETS_MASTER_KEY` (the KEK; TODO(kms): source from KMS) +- `SECRET_BACKEND` = `fernet` | `env` +- `CREDENTIAL_PROVIDER` = `api_key` +- legacy `CLAUDE_CODE_OAUTH_TOKEN`, `GH_TOKEN`, etc. retained but marked deprecated. + +`container.py` reads `settings`, constructs the chosen `SecretStore` + `CredentialProvider`, the SQLAlchemy session factory, and the repositories, and hands them to the services. One place to see the whole wiring. + +--- + +## 8. Trade-offs & what we'd revisit + +| Decision | Why | Revisit when | +|---|---|---| +| SQLAlchemy 2.0 ORM + Alembic | Real migrations, relationships, testability; idiomatic for evolving multi-tenant schema | If raw-SQL perf becomes critical in a hot path (unlikely here) | +| Strangler-fig over big-bang DB rewrite | Keeps every commit coherent; lowers risk; matches "don't make messy code" | Once all modules are ported, delete `db.py` adapter | +| Envelope encryption with env-sourced KEK | Per-tenant blast radius now, no cloud dependency to start | Move KEK to KMS/Vault before serving real customers (TODO(kms)) | +| Two interfaces (SecretStore + CredentialProvider) | Storage and credential-type evolve independently | Stable — this is the long-term seam | +| Minimal `users`/`memberships` now | Org is needed as tenant anchor for credentials; full auth is a separate slice | GitHub-OAuth login slice | +| Per-org `rate_limit_watcher` deferred | Out of first-slice scope; single-account assumption tolerated on branch | Before multi-tenant load testing | + +--- + +## 9. First-slice build order (each step compiles & is tested) + +1. **Branch + deps** — `feat/v2-byok-foundations`; add `sqlalchemy`, `alembic`, `psycopg[binary]`, `cryptography`, `pydantic-settings`. +2. **`settings.py`** — typed config. +3. **`infra/db.py`** — engine + session factory. +4. **`models/`** — base + organization + credential + workspace (others ported incrementally). +5. **Alembic** — initial migration for the schema above. +6. **`infra/crypto.py` + `secrets/`** — Fernet envelope encryption + `SecretStore` impls. +7. **`credentials/`** — `CredentialProvider` + `ApiKeyCredentialProvider`. +8. **`repositories/`** — Organization, Credential, Workspace. +9. **`container.py`** — wire it all. +10. **Refactor execution path** — `agent_pool`, `planner` to inject per-org `ANTHROPIC_API_KEY` via the provider; `rate_limit_watcher` marked per-org TODO. +11. **Tests** — unit tests for crypto round-trip, FernetSecretStore, ApiKeyCredentialProvider (with a faked SecretStore), and a repository smoke test against a throwaway Postgres/SQLite-in-memory. + +Deferred work is captured in-code as `# TODO(): …` so it's greppable: `TODO(auth-slice)`, `TODO(github-app)`, `TODO(billing)`, `TODO(kms)`, `TODO(wif)`, `TODO(port)`. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..c53376f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,42 @@ +# Alembic configuration for SwarmOps (v2 Postgres migrations). +# The database URL is supplied at runtime from orchestrator.settings, so it is +# intentionally not hardcoded here. + +[alembic] +script_location = orchestrator/alembic +prepend_sys_path = . +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/orchestrator/agent_pool.py b/orchestrator/agent_pool.py index 7a83b56..4571465 100644 --- a/orchestrator/agent_pool.py +++ b/orchestrator/agent_pool.py @@ -17,7 +17,6 @@ AGENT_MAX_TURNS_FIX, AGENT_MAX_TURNS_IMPLEMENT, AGENT_TIMEOUT_SECONDS, - CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN, GIT_AUTHOR_EMAIL, GIT_AUTHOR_NAME, @@ -27,6 +26,9 @@ SKILLS_ENABLED, WORKSPACES_DIR, ) +from orchestrator.container import get_container +from orchestrator.credentials.base import CredentialProvider, MissingCredentialError +from orchestrator.tenancy import resolve_org_id AGENT_LOGS_DIR = WORKSPACES_DIR / ".agent-logs" from orchestrator.prompts import ( @@ -70,9 +72,14 @@ def _probe_claude_available(timeout: int = 60) -> bool: Both stdout and stderr are scanned because the CLI may print the limit message to either stream in ``-p`` mode (scanning only one risks a premature wake / flapping loop). + + TODO(port): under BYO-key, rate limits are per-org, not global. This probe + currently uses the default org's credential; make it per-org-aware (probe + the specific org whose agent is waiting) when the auth slice lands. """ try: - env = {**os.environ, "CLAUDE_CODE_OAUTH_TOKEN": CLAUDE_CODE_OAUTH_TOKEN} + creds = get_container().credential_provider.for_org(resolve_org_id()) + env = {**os.environ, **creds.env} result = subprocess.run( ["claude", "-p", "Reply with just the word OK", "--max-turns", "1"], capture_output=True, @@ -92,6 +99,9 @@ def _probe_claude_available(timeout: int = 60) -> bool: except subprocess.TimeoutExpired: logger.debug("Claude probe timed out — assuming still rate-limited") return False + except MissingCredentialError: + logger.debug("No Anthropic credential connected — cannot probe; treating as unavailable") + return False except Exception as e: logger.debug("Claude probe failed: %s", e) return False @@ -266,7 +276,13 @@ def kill(self): class AgentPool: """Manages the lifecycle of Claude Code agent subprocesses.""" - def __init__(self): + def __init__(self, credential_provider: "CredentialProvider | None" = None): + # CredentialProvider resolves each org's agent credentials (BYO Anthropic + # API key in v2). Injected for testability; defaults to the container's + # configured provider. Replaces the shared CLAUDE_CODE_OAUTH_TOKEN. + self._credentials: "CredentialProvider" = ( + credential_provider or get_container().credential_provider + ) self._agents: dict[str, AgentProcess] = {} self._lock = threading.Lock() self._on_agent_complete: Callable[[AgentProcess], None] | None = None @@ -573,6 +589,17 @@ def dispatch_resolve_conflict( return agent_id + def _agent_credential_env(self, workspace_id: str | None) -> dict[str, str]: + """Resolve the per-org credential env for an agent run. + + Replaces the shared CLAUDE_CODE_OAUTH_TOKEN with the owning org's + credentials (BYO Anthropic API key in v2). Raises MissingCredentialError + if the org has not connected a credential — callers surface this rather + than spawning an agent that would immediately fail. + """ + org_id = resolve_org_id(workspace_id) + return dict(self._credentials.for_org(org_id).env) + def _spawn_agent( self, agent_id: str, @@ -597,14 +624,22 @@ def _spawn_agent( "--verbose", ] + try: + credential_env = self._agent_credential_env(workspace_id) + except MissingCredentialError as e: + logger.error("Cannot spawn agent %s: %s", agent_id, e) + raise + env = { **os.environ, - "CLAUDE_CODE_OAUTH_TOKEN": CLAUDE_CODE_OAUTH_TOKEN, + **credential_env, # per-org ANTHROPIC_API_KEY "GH_TOKEN": GH_TOKEN, } # Set git author identity so commits are attributed to a real GitHub user # (avoids Vercel / deploy rejections for unknown commit authors). + # TODO(github-app): replace this with the GitHub App bot identity + + # installation token once the App slice lands. if GIT_AUTHOR_NAME: env["GIT_AUTHOR_NAME"] = GIT_AUTHOR_NAME env["GIT_COMMITTER_NAME"] = GIT_AUTHOR_NAME @@ -1300,9 +1335,19 @@ def resume_rate_limited_agent(self, agent_record: dict) -> str | None: "--verbose", ] + try: + credential_env = self._agent_credential_env(workspace_id) + except MissingCredentialError as e: + logger.error("Cannot resume agent %s: %s", old_agent_id, e) + db.finish_agent(old_agent_id, status="failed", error_message=str(e)) + if agent_type == "implement": + db.update_issue(issue_number, workspace_id=workspace_id, status="pending") + cleanup_worktree(worktree_path, repo_path=repo_path) + return None + env = { **os.environ, - "CLAUDE_CODE_OAUTH_TOKEN": CLAUDE_CODE_OAUTH_TOKEN, + **credential_env, # per-org ANTHROPIC_API_KEY "GH_TOKEN": GH_TOKEN, } diff --git a/orchestrator/alembic/env.py b/orchestrator/alembic/env.py new file mode 100644 index 0000000..213ac38 --- /dev/null +++ b/orchestrator/alembic/env.py @@ -0,0 +1,59 @@ +"""Alembic migration environment. + +Pulls the database URL from ``orchestrator.settings`` and the target schema from +``orchestrator.models.Base.metadata`` so autogenerate sees every ORM model. +""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from orchestrator.models import Base # noqa: F401 — registers all models +from orchestrator.settings import get_settings + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Inject the runtime DB URL. +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/orchestrator/alembic/script.py.mako b/orchestrator/alembic/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/orchestrator/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/orchestrator/alembic/versions/.gitkeep b/orchestrator/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/orchestrator/alembic/versions/225f9182aadb_initial_v2_schema_orgs_users_.py b/orchestrator/alembic/versions/225f9182aadb_initial_v2_schema_orgs_users_.py new file mode 100644 index 0000000..c845f6d --- /dev/null +++ b/orchestrator/alembic/versions/225f9182aadb_initial_v2_schema_orgs_users_.py @@ -0,0 +1,104 @@ +"""initial v2 schema: orgs, users, memberships, tenant_keys, org_credentials, workspaces + +Revision ID: 225f9182aadb +Revises: +Create Date: 2026-06-13 08:27:17.007070 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '225f9182aadb' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('organizations', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('slug', sa.String(length=120), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + op.create_table('users', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('github_id', sa.String(length=64), nullable=True), + sa.Column('email', sa.String(length=320), nullable=True), + sa.Column('name', sa.String(length=200), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('github_id') + ) + op.create_table('memberships', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('org_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['org_id'], ['organizations.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('org_id', 'user_id', name='uq_membership') + ) + op.create_table('org_credentials', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('org_id', sa.String(length=36), nullable=False), + sa.Column('provider', sa.String(length=40), nullable=False), + sa.Column('ciphertext', sa.LargeBinary(), nullable=False), + sa.Column('key_version', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('last_validated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['org_id'], ['organizations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('org_id', 'provider', name='uq_org_provider_credential') + ) + op.create_table('tenant_keys', + sa.Column('org_id', sa.String(length=36), nullable=False), + sa.Column('wrapped_dek', sa.LargeBinary(), nullable=False), + sa.Column('key_version', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['org_id'], ['organizations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('org_id') + ) + op.create_table('workspaces', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('org_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('github_repo', sa.String(length=300), nullable=False), + sa.Column('repo_url', sa.String(length=500), nullable=False), + sa.Column('local_path', sa.String(length=1000), nullable=False), + sa.Column('base_branch', sa.String(length=200), nullable=False), + sa.Column('status', sa.String(length=40), nullable=False), + sa.Column('is_monorepo', sa.Integer(), nullable=False), + sa.Column('structure_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['org_id'], ['organizations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_workspaces_org_id'), 'workspaces', ['org_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_workspaces_org_id'), table_name='workspaces') + op.drop_table('workspaces') + op.drop_table('tenant_keys') + op.drop_table('org_credentials') + op.drop_table('memberships') + op.drop_table('users') + op.drop_table('organizations') + # ### end Alembic commands ### diff --git a/orchestrator/container.py b/orchestrator/container.py new file mode 100644 index 0000000..14d734b --- /dev/null +++ b/orchestrator/container.py @@ -0,0 +1,78 @@ +"""Composition root — builds and wires the v2 singletons in one place. + +Everything that needs a SecretStore or CredentialProvider gets it from here, +rather than importing config globals. This is the only module that knows which +concrete implementations are in use, so swapping a backend (e.g. Fernet → KMS) +is a one-line change confined to this file. + +Usage:: + + from orchestrator.container import get_container + creds = get_container().credential_provider.for_org(org_id) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache + +from orchestrator.credentials.api_key_provider import ApiKeyCredentialProvider +from orchestrator.credentials.base import CredentialProvider +from orchestrator.credentials.env_provider import EnvCredentialProvider +from orchestrator.infra.db import session_scope +from orchestrator.secrets.base import SecretStore +from orchestrator.secrets.env_store import EnvVarSecretStore +from orchestrator.secrets.fernet_store import FernetSecretStore +from orchestrator.settings import Settings, get_settings + + +@dataclass(frozen=True) +class Container: + # secret_store is None in single-tenant mode (no encrypted store needed). + secret_store: SecretStore | None + credential_provider: CredentialProvider + + +def _build_secret_store(settings: Settings) -> SecretStore: + if settings.secret_backend == "env": + return EnvVarSecretStore() + # default: encrypted-at-rest + return FernetSecretStore( + master_key=settings.secrets_master_key or "", + session_scope=session_scope, + ) + + +def build_container(settings: Settings | None = None) -> Container: + settings = settings or get_settings() + + # Single-tenant (OSS self-host / internal team): env-based credentials, no + # Postgres, no encryption. Runs exactly like the original deployment. + if not settings.is_multi_tenant: + return Container( + secret_store=None, + credential_provider=EnvCredentialProvider(settings), + ) + + # Multi-tenant (managed cloud): per-org encrypted credentials in Postgres. + secret_store = _build_secret_store(settings) + if settings.credential_provider == "api_key": + credential_provider: CredentialProvider = ApiKeyCredentialProvider( + secret_store=secret_store, + session_scope=session_scope, + anthropic_base_url=settings.anthropic_base_url, + ) + # TODO(wif): elif settings.credential_provider == "wif": WifCredentialProvider(...) + else: + raise ValueError(f"Unknown CREDENTIAL_PROVIDER: {settings.credential_provider}") + + return Container( + secret_store=secret_store, + credential_provider=credential_provider, + ) + + +@lru_cache(maxsize=1) +def get_container() -> Container: + """Return the process-wide container singleton.""" + return build_container() diff --git a/orchestrator/credentials/__init__.py b/orchestrator/credentials/__init__.py new file mode 100644 index 0000000..8e80e86 --- /dev/null +++ b/orchestrator/credentials/__init__.py @@ -0,0 +1,17 @@ +"""Credential provider strategy (which credential an agent run receives).""" + +from orchestrator.credentials.base import ( + AgentCredentials, + CredentialProvider, + MissingCredentialError, +) +from orchestrator.credentials.api_key_provider import ApiKeyCredentialProvider +from orchestrator.credentials.env_provider import EnvCredentialProvider + +__all__ = [ + "CredentialProvider", + "AgentCredentials", + "MissingCredentialError", + "ApiKeyCredentialProvider", + "EnvCredentialProvider", +] diff --git a/orchestrator/credentials/api_key_provider.py b/orchestrator/credentials/api_key_provider.py new file mode 100644 index 0000000..b67b727 --- /dev/null +++ b/orchestrator/credentials/api_key_provider.py @@ -0,0 +1,104 @@ +"""ApiKeyCredentialProvider — BYO Anthropic Console API key. + +Resolves the org's Anthropic API key from the SecretStore and hands it to the +agent as ``ANTHROPIC_API_KEY``. This is the v2 model: each tenant runs on its +own Console key (billed by Anthropic to them), replacing the shared, now-banned +``CLAUDE_CODE_OAUTH_TOKEN``. + + TODO(wif): a sibling WifCredentialProvider can mint short-lived OIDC tokens + (no stored secret) behind this same interface for enterprise customers. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from contextlib import AbstractContextManager + +import httpx +from sqlalchemy.orm import Session + +from orchestrator.credentials.base import ( + AgentCredentials, + CredentialProvider, + MissingCredentialError, +) +from orchestrator.models.credential import ( + PROVIDER_ANTHROPIC, + STATUS_ACTIVE, + STATUS_INVALID, +) +from orchestrator.repositories.credential_repo import CredentialRepository +from orchestrator.secrets.base import SecretStore + +logger = logging.getLogger(__name__) + + +class ApiKeyCredentialProvider(CredentialProvider): + def __init__( + self, + secret_store: SecretStore, + session_scope: Callable[[], AbstractContextManager[Session]], + anthropic_base_url: str = "https://api.anthropic.com", + ) -> None: + self._secrets = secret_store + self._session_scope = session_scope + self._base_url = anthropic_base_url.rstrip("/") + + def for_org(self, org_id: str) -> AgentCredentials: + key = self._secrets.get(org_id, PROVIDER_ANTHROPIC) + if not key: + raise MissingCredentialError(org_id, PROVIDER_ANTHROPIC) + # Claude Code CLI honours ANTHROPIC_API_KEY for Console (API) billing. + # TODO(port): if a future Claude Code release changes this env contract, + # switch the agent runner to the Claude Agent SDK / Messages API, which + # is unambiguously API-key native. The interface here does not change. + return AgentCredentials(env={"ANTHROPIC_API_KEY": key}) + + def validate(self, org_id: str) -> bool: + """Hit a cheap endpoint to confirm the key works; persist the result. + + Returns True if the key is confirmed active, False if definitively + invalid, or False (without updating the DB status) on transient errors + so a momentary 429/5xx does not mark a valid credential as invalid. + """ + key = self._secrets.get(org_id, PROVIDER_ANTHROPIC) + if not key: + return False + ok = self._probe(key) + if ok is not None: + # Only persist when we have a definitive answer (True/False). + with self._session_scope() as s: + CredentialRepository(s).set_status( + org_id, + STATUS_ACTIVE if ok else STATUS_INVALID, + validated=ok, + ) + return bool(ok) + + def _probe(self, key: str) -> bool | None: + """Return True (active), False (invalid), or None (transient error). + + Only 401/403 are treated as definitive auth failures. 429, 5xx, and + network errors are transient and return None so the caller can skip + persisting a misleading STATUS_INVALID. + """ + try: + resp = httpx.get( + f"{self._base_url}/v1/models", + headers={"x-api-key": key, "anthropic-version": "2023-06-01"}, + timeout=10.0, + ) + if resp.status_code == 200: + return True + if resp.status_code in (401, 403): + return False + # 429, 5xx, or any other transient / unexpected code. + logger.warning( + "Anthropic key probe returned transient status %s — skipping status update", + resp.status_code, + ) + return None + except httpx.HTTPError as e: + logger.warning("Anthropic key validation failed: %s", e) + return None diff --git a/orchestrator/credentials/base.py b/orchestrator/credentials/base.py new file mode 100644 index 0000000..b9fa68c --- /dev/null +++ b/orchestrator/credentials/base.py @@ -0,0 +1,43 @@ +"""CredentialProvider interface + value object. + +A CredentialProvider answers one question for the execution path: "given this +org, what credential environment should the agent subprocess run with?" The +execution path injects ``AgentCredentials.env`` and never learns whether the +credential is a stored API key, a federated token, or anything else. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +class MissingCredentialError(Exception): + """Raised when an org has not connected a required credential.""" + + def __init__(self, org_id: str, provider: str) -> None: + super().__init__( + f"Organization {org_id} has no '{provider}' credential connected" + ) + self.org_id = org_id + self.provider = provider + + +@dataclass(frozen=True) +class AgentCredentials: + """Environment variables to inject into an agent subprocess.""" + + env: dict[str, str] + + +class CredentialProvider(ABC): + @abstractmethod + def for_org(self, org_id: str) -> AgentCredentials: + """Return the credential env for an org's agent run. + + Raises MissingCredentialError if the org has no usable credential. + """ + + @abstractmethod + def validate(self, org_id: str) -> bool: + """Cheaply verify the org's credential works; update its status.""" diff --git a/orchestrator/credentials/env_provider.py b/orchestrator/credentials/env_provider.py new file mode 100644 index 0000000..db8ff11 --- /dev/null +++ b/orchestrator/credentials/env_provider.py @@ -0,0 +1,46 @@ +"""EnvCredentialProvider — single-tenant / OSS self-host credential source. + +Reads the agent credential straight from the process environment. No database, +no encryption, no orgs — this is the path the OSS self-host and your internal +team run, and it preserves the original behaviour exactly: + + * If ``CLAUDE_CODE_OAUTH_TOKEN`` is set, it is injected just as before, so an + existing single-tenant deployment keeps working byte-for-byte. + * If ``ANTHROPIC_API_KEY`` is set, it is injected too (BYO Console key also + works for self-hosters who prefer API billing). + +A self-hoster running their own instance on their own Claude credentials for +their own use is ordinary, permitted usage — the third-party reselling +restriction does not apply to them. +""" + +from __future__ import annotations + +from orchestrator.credentials.base import ( + AgentCredentials, + CredentialProvider, + MissingCredentialError, +) +from orchestrator.settings import Settings, get_settings + + +class EnvCredentialProvider(CredentialProvider): + def __init__(self, settings: Settings | None = None) -> None: + self._settings = settings or get_settings() + + def for_org(self, org_id: str) -> AgentCredentials: + # org_id is ignored in single-tenant mode (there is one implicit tenant). + env: dict[str, str] = {} + if self._settings.anthropic_api_key: + env["ANTHROPIC_API_KEY"] = self._settings.anthropic_api_key + if self._settings.claude_code_oauth_token: + # Preserve the original self-host behaviour exactly. + env["CLAUDE_CODE_OAUTH_TOKEN"] = self._settings.claude_code_oauth_token + if not env: + raise MissingCredentialError(org_id, "anthropic") + return AgentCredentials(env=env) + + def validate(self, org_id: str) -> bool: + return bool( + self._settings.anthropic_api_key or self._settings.claude_code_oauth_token + ) diff --git a/orchestrator/infra/__init__.py b/orchestrator/infra/__init__.py new file mode 100644 index 0000000..543e65e --- /dev/null +++ b/orchestrator/infra/__init__.py @@ -0,0 +1 @@ +"""Infrastructure layer: database engine/session and crypto primitives.""" diff --git a/orchestrator/infra/crypto.py b/orchestrator/infra/crypto.py new file mode 100644 index 0000000..dd0dde7 --- /dev/null +++ b/orchestrator/infra/crypto.py @@ -0,0 +1,58 @@ +"""Envelope-encryption primitives (pure, no I/O). + +Two-tier key hierarchy so a database dump never exposes usable secrets: + + KEK (master key-encryption-key) ── wraps ──▶ per-tenant DEK + DEK (data-encryption-key) ── encrypts ─▶ the secret (e.g. API key) + +Each tenant gets its own DEK, so there is no single key whose compromise dumps +every customer's secret, and no code path that can bulk-decrypt across tenants. +The KEK currently comes from settings; the DEKs are stored wrapped in the +``tenant_keys`` table. + + TODO(kms): move the KEK into AWS KMS / Vault and wrap/unwrap DEKs via the + KMS API (envelope encryption as a service). This module's function + signatures stay the same; only the KEK source changes. + +All functions here are pure and side-effect free, which keeps them trivially +unit-testable. Persistence of wrapped DEKs and ciphertext lives in the +repository + SecretStore layers. +""" + +from __future__ import annotations + +from cryptography.fernet import Fernet + +# Current key version. Bump when rotating the KEK or changing the scheme so old +# ciphertext can still be identified and migrated. +KEY_VERSION = 1 + + +def generate_master_key() -> str: + """Generate a new KEK (base64 urlsafe string). Use once to seed config.""" + return Fernet.generate_key().decode("utf-8") + + +def generate_dek() -> bytes: + """Generate a new per-tenant data-encryption key (raw Fernet key bytes).""" + return Fernet.generate_key() + + +def wrap_dek(dek: bytes, master_key: str) -> bytes: + """Encrypt (wrap) a tenant DEK with the master KEK for storage at rest.""" + return Fernet(master_key.encode("utf-8")).encrypt(dek) + + +def unwrap_dek(wrapped_dek: bytes, master_key: str) -> bytes: + """Decrypt (unwrap) a stored tenant DEK using the master KEK.""" + return Fernet(master_key.encode("utf-8")).decrypt(wrapped_dek) + + +def encrypt_secret(plaintext: str, dek: bytes) -> bytes: + """Encrypt a secret string with a tenant DEK. Returns ciphertext bytes.""" + return Fernet(dek).encrypt(plaintext.encode("utf-8")) + + +def decrypt_secret(ciphertext: bytes, dek: bytes) -> str: + """Decrypt ciphertext with a tenant DEK back into the secret string.""" + return Fernet(dek).decrypt(ciphertext).decode("utf-8") diff --git a/orchestrator/infra/db.py b/orchestrator/infra/db.py new file mode 100644 index 0000000..1c38677 --- /dev/null +++ b/orchestrator/infra/db.py @@ -0,0 +1,82 @@ +"""SQLAlchemy engine and session factory (Postgres, v2). + +This is the new system of record. It coexists with the legacy sqlite-backed +``orchestrator/db.py`` during the strangler-fig migration; modules are ported +onto repositories (which use these sessions) one at a time. + + TODO(port): once every module uses repositories, delete legacy + ``orchestrator/db.py`` and its sqlite schema. +""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from orchestrator.settings import get_settings + +_engine: Engine | None = None +_SessionFactory: sessionmaker[Session] | None = None +_engine_lock = threading.Lock() +_session_factory_lock = threading.Lock() + + +def get_engine() -> Engine: + """Return the process-wide SQLAlchemy engine (lazily created).""" + global _engine + if _engine is None: + with _engine_lock: + if _engine is None: + settings = get_settings() + _engine = create_engine( + settings.database_url, + pool_pre_ping=True, # recover from dropped connections + future=True, + ) + return _engine + + +def get_session_factory() -> sessionmaker[Session]: + """Return the process-wide session factory (lazily created).""" + global _SessionFactory + if _SessionFactory is None: + with _session_factory_lock: + if _SessionFactory is None: + _SessionFactory = sessionmaker( + bind=get_engine(), expire_on_commit=False, future=True + ) + return _SessionFactory + + +@contextmanager +def session_scope() -> Iterator[Session]: + """Transactional scope around a series of operations. + + Commits on success, rolls back on exception, always closes. Repositories + accept a ``Session`` so callers control transaction boundaries:: + + with session_scope() as s: + repo = CredentialRepository(s) + repo.upsert(...) + """ + session = get_session_factory()() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +def reset_engine_for_tests(engine: Engine) -> None: + """Replace the global engine/session factory (used by the test suite).""" + global _engine, _SessionFactory + _engine = engine + _SessionFactory = sessionmaker(bind=engine, expire_on_commit=False, future=True) diff --git a/orchestrator/models/__init__.py b/orchestrator/models/__init__.py new file mode 100644 index 0000000..093fac3 --- /dev/null +++ b/orchestrator/models/__init__.py @@ -0,0 +1,25 @@ +"""SQLAlchemy ORM models (v2). + +Importing this package registers every model on the shared ``Base.metadata`` so +Alembic autogenerate and ``create_all`` (in tests) see the full schema. + + TODO(port): the existing tables (issues, agents, agent_events, pr_reviews, + pr_conflict_fixes, planning_*, workspace_env*, sessions) are ported to ORM + models incrementally as each owning module migrates off legacy db.py. Add + them here as they land. +""" + +from orchestrator.models.base import Base +from orchestrator.models.credential import OrgCredential, TenantKey +from orchestrator.models.organization import Membership, Organization, User +from orchestrator.models.workspace import Workspace + +__all__ = [ + "Base", + "Organization", + "User", + "Membership", + "TenantKey", + "OrgCredential", + "Workspace", +] diff --git a/orchestrator/models/base.py b/orchestrator/models/base.py new file mode 100644 index 0000000..a7ca310 --- /dev/null +++ b/orchestrator/models/base.py @@ -0,0 +1,40 @@ +"""Declarative base, shared mixins, and column helpers.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """Shared declarative base for all ORM models.""" + + +def _uuid_str() -> str: + return str(uuid.uuid4()) + + +def uuid_pk() -> Mapped[str]: + """A string UUID primary key column. + + UUIDs (not autoincrement ints) avoid cross-tenant id enumeration and keep + ids stable across environments. + """ + return mapped_column(String(36), primary_key=True, default=_uuid_str) + + +class TimestampMixin: + """Adds created_at / updated_at columns maintained by the database.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) diff --git a/orchestrator/models/credential.py b/orchestrator/models/credential.py new file mode 100644 index 0000000..d3fc96a --- /dev/null +++ b/orchestrator/models/credential.py @@ -0,0 +1,66 @@ +"""Encrypted credential storage: tenant_keys + org_credentials. + +``TenantKey`` holds each org's wrapped data-encryption-key (DEK). ``OrgCredential`` +holds the ciphertext of a provider credential (the Anthropic API key today), +encrypted under that org's DEK. Plaintext secrets are never stored. + +See ``orchestrator/infra/crypto.py`` for the key hierarchy and +``orchestrator/secrets/fernet_store.py`` for how these tables are used. +""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from orchestrator.models.base import Base, TimestampMixin, uuid_pk + +# Credential providers (extensible). Only Anthropic is used in this slice. +PROVIDER_ANTHROPIC = "anthropic" + +# Credential status values. +STATUS_ACTIVE = "active" +STATUS_INVALID = "invalid" +STATUS_REVOKED = "revoked" + + +class TenantKey(Base, TimestampMixin): + """Per-tenant wrapped data-encryption-key (DEK).""" + + __tablename__ = "tenant_keys" + + org_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True + ) + # DEK encrypted ("wrapped") by the master KEK. Never store the raw DEK. + wrapped_dek: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + key_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + +class OrgCredential(Base, TimestampMixin): + """An encrypted provider credential belonging to an organization.""" + + __tablename__ = "org_credentials" + __table_args__ = ( + UniqueConstraint("org_id", "provider", name="uq_org_provider_credential"), + ) + + id: Mapped[str] = uuid_pk() + org_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False + ) + provider: Mapped[str] = mapped_column( + String(40), nullable=False, default=PROVIDER_ANTHROPIC + ) + # The secret (e.g. ANTHROPIC_API_KEY) encrypted under the org's DEK. + ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + key_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + status: Mapped[str] = mapped_column( + String(20), nullable=False, default=STATUS_ACTIVE + ) + last_validated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/orchestrator/models/organization.py b/orchestrator/models/organization.py new file mode 100644 index 0000000..e60c4a1 --- /dev/null +++ b/orchestrator/models/organization.py @@ -0,0 +1,68 @@ +"""Tenant model: organizations, users, memberships. + +The ``Organization`` is the tenant anchor — every piece of tenant data +(credentials, workspaces, issues, …) hangs off an org. ``User`` and +``Membership`` are intentionally minimal in this slice; full authentication is a +later slice. + + TODO(auth-slice): flesh out User (GitHub OAuth fields, last_login_at) and + Organization (plan, stripe_customer_id, status) when the login + billing + slices land. +""" + +from __future__ import annotations + +from sqlalchemy import ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from orchestrator.models.base import Base, TimestampMixin, uuid_pk + + +class Organization(Base, TimestampMixin): + __tablename__ = "organizations" + + id: Mapped[str] = uuid_pk() + name: Mapped[str] = mapped_column(String(200), nullable=False) + slug: Mapped[str] = mapped_column(String(120), nullable=False, unique=True) + + # TODO(billing): plan, stripe_customer_id, subscription_status + # TODO(auth-slice): owner_user_id + + memberships: Mapped[list["Membership"]] = relationship( + back_populates="organization", cascade="all, delete-orphan" + ) + + +class User(Base, TimestampMixin): + __tablename__ = "users" + + id: Mapped[str] = uuid_pk() + github_id: Mapped[str | None] = mapped_column( + String(64), unique=True, nullable=True + ) + email: Mapped[str | None] = mapped_column(String(320), nullable=True) + name: Mapped[str | None] = mapped_column(String(200), nullable=True) + + # TODO(auth-slice): avatar_url, last_login_at, github OAuth token storage + + memberships: Mapped[list["Membership"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + +class Membership(Base, TimestampMixin): + __tablename__ = "memberships" + __table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_membership"),) + + id: Mapped[str] = uuid_pk() + org_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + # owner | admin | member + role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") + + organization: Mapped["Organization"] = relationship(back_populates="memberships") + user: Mapped["User"] = relationship(back_populates="memberships") diff --git a/orchestrator/models/workspace.py b/orchestrator/models/workspace.py new file mode 100644 index 0000000..113e41a --- /dev/null +++ b/orchestrator/models/workspace.py @@ -0,0 +1,34 @@ +"""Workspace model (ported from legacy db.py, now org-scoped). + +This is the first existing table ported to the ORM. It gains an ``org_id`` FK so +a workspace belongs to a tenant. The legacy sqlite ``workspaces`` table and its +``db.py`` accessors remain until ``workspace_manager`` is fully migrated +(strangler-fig). + + TODO(port): migrate workspace_env / workspace_env_sync to ORM models and move + workspace_manager off legacy db.py. +""" + +from __future__ import annotations + +from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from orchestrator.models.base import Base, TimestampMixin, uuid_pk + + +class Workspace(Base, TimestampMixin): + __tablename__ = "workspaces" + + id: Mapped[str] = uuid_pk() + org_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + github_repo: Mapped[str] = mapped_column(String(300), nullable=False) + repo_url: Mapped[str] = mapped_column(String(500), nullable=False) + local_path: Mapped[str] = mapped_column(String(1000), nullable=False) + base_branch: Mapped[str] = mapped_column(String(200), default="main") + status: Mapped[str] = mapped_column(String(40), default="active") + is_monorepo: Mapped[int] = mapped_column(Integer, default=0) + structure_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/orchestrator/planner.py b/orchestrator/planner.py index 2418b56..09a333f 100644 --- a/orchestrator/planner.py +++ b/orchestrator/planner.py @@ -11,12 +11,26 @@ import time from orchestrator import db -from orchestrator.config import CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN, ISSUE_LABEL +from orchestrator.config import GH_TOKEN, ISSUE_LABEL +from orchestrator.container import get_container +from orchestrator.credentials.base import MissingCredentialError +from orchestrator.tenancy import resolve_org_id from orchestrator.prompts import build_planning_prompt logger = logging.getLogger(__name__) +def _planning_credential_env(workspace_id: str | None = None) -> dict[str, str]: + """Resolve the per-org credential env (BYO Anthropic API key) for planner runs. + + Mirrors AgentPool._agent_credential_env so the planning agent and the issue + agents run on the same per-tenant credentials. Raises MissingCredentialError + if the org has not connected a credential. + """ + org_id = resolve_org_id(workspace_id) + return dict(get_container().credential_provider.for_org(org_id).env) + + def _close_process_pipes(process: subprocess.Popen | None) -> None: """Close stdin/stdout/stderr pipes of a finished planner subprocess. @@ -222,9 +236,26 @@ def _run_planning_agent_impl(session_id: str, workspace: dict, prompt: str): "--verbose", ] + try: + credential_env = _planning_credential_env(workspace_id) + except MissingCredentialError as e: + logger.error("Cannot start planning agent for session %s: %s", session_id, e) + with _active_lock: + was_cancelled = session_id in _cancelled + _starting.discard(session_id) + if was_cancelled: + _cancelled.discard(session_id) + if was_cancelled: + logger.info("Planning session %s was cancelled before spawn completed", session_id) + # Always surface as 'error', even if cancelled: the missing credential is + # the root cause of the failure and would otherwise cause an infinite + # retry loop if something polls for and re-runs 'active' sessions. + db.update_planning_session(session_id, status="error") + return + env = { **os.environ, - "CLAUDE_CODE_OAUTH_TOKEN": CLAUDE_CODE_OAUTH_TOKEN, + **credential_env, # per-org ANTHROPIC_API_KEY "GH_TOKEN": GH_TOKEN, } @@ -763,7 +794,8 @@ def _generate_title_with_ai(plan_body: str) -> str: + plan_body[:3000] ) try: - env = {**os.environ, "CLAUDE_CODE_OAUTH_TOKEN": CLAUDE_CODE_OAUTH_TOKEN} + # No workspace context here — resolves to the default org's credential. + env = {**os.environ, **_planning_credential_env()} result = subprocess.run( ["claude", "--output-format", "text"], input=prompt, diff --git a/orchestrator/repositories/__init__.py b/orchestrator/repositories/__init__.py new file mode 100644 index 0000000..62319bb --- /dev/null +++ b/orchestrator/repositories/__init__.py @@ -0,0 +1,16 @@ +"""Repository layer — intention-revealing data access over the ORM. + +Each repository wraps a SQLAlchemy ``Session`` and exposes methods for one +aggregate. Services depend on these instead of raw SQL, which keeps data access +testable and hides the storage details. +""" + +from orchestrator.repositories.credential_repo import CredentialRepository +from orchestrator.repositories.organization_repo import OrganizationRepository +from orchestrator.repositories.workspace_repo import WorkspaceRepository + +__all__ = [ + "OrganizationRepository", + "CredentialRepository", + "WorkspaceRepository", +] diff --git a/orchestrator/repositories/base.py b/orchestrator/repositories/base.py new file mode 100644 index 0000000..8db0bfa --- /dev/null +++ b/orchestrator/repositories/base.py @@ -0,0 +1,28 @@ +"""Generic repository base.""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from sqlalchemy.orm import Session + +from orchestrator.models.base import Base + +T = TypeVar("T", bound=Base) + + +class Repository(Generic[T]): + """Base repository holding a session and the mapped model type.""" + + model: type[T] + + def __init__(self, session: Session) -> None: + self.session = session + + def get(self, id_: str) -> T | None: + return self.session.get(self.model, id_) + + def add(self, entity: T) -> T: + self.session.add(entity) + self.session.flush() # assign defaults/ids without committing + return entity diff --git a/orchestrator/repositories/credential_repo.py b/orchestrator/repositories/credential_repo.py new file mode 100644 index 0000000..9ee4150 --- /dev/null +++ b/orchestrator/repositories/credential_repo.py @@ -0,0 +1,102 @@ +"""Credential + tenant-key repositories. + +These deal only in ciphertext and wrapped keys — encryption/decryption lives in +the SecretStore. Keeping crypto out of the repository keeps responsibilities +clean (repository = persistence, SecretStore = crypto policy). +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from orchestrator.models.credential import ( + PROVIDER_ANTHROPIC, + OrgCredential, + TenantKey, +) +from orchestrator.repositories.base import Repository + + +class TenantKeyRepository(Repository[TenantKey]): + model = TenantKey + + def get_for_org(self, org_id: str) -> TenantKey | None: + return self.session.get(TenantKey, org_id) + + def create(self, org_id: str, wrapped_dek: bytes, key_version: int) -> TenantKey: + return self.add( + TenantKey(org_id=org_id, wrapped_dek=wrapped_dek, key_version=key_version) + ) + + +class CredentialRepository(Repository[OrgCredential]): + model = OrgCredential + + def get( + self, org_id: str, provider: str = PROVIDER_ANTHROPIC + ) -> OrgCredential | None: + stmt = select(OrgCredential).where( + OrgCredential.org_id == org_id, OrgCredential.provider == provider + ) + return self.session.scalars(stmt).first() + + def upsert( + self, + org_id: str, + ciphertext: bytes, + key_version: int, + provider: str = PROVIDER_ANTHROPIC, + status: str = "active", + ) -> OrgCredential: + """Insert or replace the org's credential for a provider. + + Uses catch-and-retry to handle the race where two concurrent first-time + ``put()`` calls for the same (org_id, provider) both observe ``get()`` + returning ``None`` and attempt to INSERT simultaneously — the loser + catches IntegrityError and re-reads the winner's row. + """ + cred = self.get(org_id, provider) + if cred is None: + cred = OrgCredential( + org_id=org_id, + provider=provider, + ciphertext=ciphertext, + key_version=key_version, + status=status, + ) + try: + # SAVEPOINT around just the INSERT: on IntegrityError only this + # nested transaction rolls back, leaving the outer transaction + # (managed by the caller's session_scope) intact. + with self.session.begin_nested(): + self.session.add(cred) + self.session.flush() + return cred + except IntegrityError: + # Concurrent caller won the race to INSERT — re-read their row. + cred = self.get(org_id, provider) + if cred is None: + raise + cred.ciphertext = ciphertext + cred.key_version = key_version + cred.status = status + self.session.flush() + return cred + + def set_status( + self, + org_id: str, + status: str, + provider: str = PROVIDER_ANTHROPIC, + validated: bool = False, + ) -> None: + cred = self.get(org_id, provider) + if cred is None: + return + cred.status = status + if validated: + cred.last_validated_at = datetime.now(timezone.utc) + self.session.flush() diff --git a/orchestrator/repositories/organization_repo.py b/orchestrator/repositories/organization_repo.py new file mode 100644 index 0000000..36a8234 --- /dev/null +++ b/orchestrator/repositories/organization_repo.py @@ -0,0 +1,53 @@ +"""Organization repository.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from orchestrator.models.organization import Organization +from orchestrator.repositories.base import Repository + +# Slug of the bootstrap org used during the single-tenant→multi-tenant +# transition, before GitHub-OAuth login exists. +# TODO(auth-slice): remove the default org once real orgs are created at login. +DEFAULT_ORG_SLUG = "default" + + +class OrganizationRepository(Repository[Organization]): + model = Organization + + def get_by_slug(self, slug: str) -> Organization | None: + stmt = select(Organization).where(Organization.slug == slug) + return self.session.scalars(stmt).first() + + def create(self, name: str, slug: str) -> Organization: + return self.add(Organization(name=name, slug=slug)) + + def get_or_create_default(self) -> Organization: + """Return the bootstrap org, creating it on first use. + + Lets the refactored execution path resolve an ``org_id`` before the auth + slice introduces real organizations. + + Uses catch-and-retry to handle the race where concurrent callers all + observe a missing slug and attempt to INSERT simultaneously — the loser + catches IntegrityError and re-reads the row the winner committed. + """ + org = self.get_by_slug(DEFAULT_ORG_SLUG) + if org is not None: + return org + try: + # SAVEPOINT around just the INSERT: on IntegrityError only this + # nested transaction rolls back, leaving the caller's outer + # session_scope() transaction intact for any other work on it. + with self.session.begin_nested(): + org = self.create(name="Default", slug=DEFAULT_ORG_SLUG) + # Flush so the INSERT reaches the DB and any constraint violation + # surfaces here rather than at commit time. + self.session.flush() + except IntegrityError: + org = self.get_by_slug(DEFAULT_ORG_SLUG) + if org is None: + raise + return org diff --git a/orchestrator/repositories/workspace_repo.py b/orchestrator/repositories/workspace_repo.py new file mode 100644 index 0000000..7574696 --- /dev/null +++ b/orchestrator/repositories/workspace_repo.py @@ -0,0 +1,21 @@ +"""Workspace repository.""" + +from __future__ import annotations + +from sqlalchemy import select + +from orchestrator.models.workspace import Workspace +from orchestrator.repositories.base import Repository + + +class WorkspaceRepository(Repository[Workspace]): + model = Workspace + + def get_org_id(self, workspace_id: str) -> str | None: + """Resolve the owning org for a workspace (used by the exec path).""" + ws = self.get(workspace_id) + return ws.org_id if ws else None + + def list_for_org(self, org_id: str) -> list[Workspace]: + stmt = select(Workspace).where(Workspace.org_id == org_id) + return list(self.session.scalars(stmt).all()) diff --git a/orchestrator/secrets/__init__.py b/orchestrator/secrets/__init__.py new file mode 100644 index 0000000..6388e11 --- /dev/null +++ b/orchestrator/secrets/__init__.py @@ -0,0 +1,7 @@ +"""Secret storage strategy (how secrets are persisted).""" + +from orchestrator.secrets.base import SecretStore +from orchestrator.secrets.env_store import EnvVarSecretStore +from orchestrator.secrets.fernet_store import FernetSecretStore + +__all__ = ["SecretStore", "FernetSecretStore", "EnvVarSecretStore"] diff --git a/orchestrator/secrets/base.py b/orchestrator/secrets/base.py new file mode 100644 index 0000000..b91ed1e --- /dev/null +++ b/orchestrator/secrets/base.py @@ -0,0 +1,28 @@ +"""SecretStore interface. + +A SecretStore persists per-tenant secrets by ``(org_id, name)``. ``name`` is the +logical secret name — for this slice it is the credential provider (e.g. +``"anthropic"``). Implementations decide *how* secrets are stored (encrypted in +the DB, in a cloud KMS/secrets manager, or plain env for dev). + +Callers (e.g. CredentialProvider) depend only on this interface, so the storage +backend can change without touching the execution path. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class SecretStore(ABC): + @abstractmethod + def put(self, org_id: str, name: str, plaintext: str) -> None: + """Store (or replace) a secret for an org.""" + + @abstractmethod + def get(self, org_id: str, name: str) -> str | None: + """Return the plaintext secret, or None if absent.""" + + @abstractmethod + def delete(self, org_id: str, name: str) -> None: + """Remove a secret for an org.""" diff --git a/orchestrator/secrets/env_store.py b/orchestrator/secrets/env_store.py new file mode 100644 index 0000000..454ca7b --- /dev/null +++ b/orchestrator/secrets/env_store.py @@ -0,0 +1,30 @@ +"""EnvVarSecretStore — reads secrets from process environment (dev/bootstrap only). + +Ignores ``org_id`` (single-tenant) and cannot persist. Useful for local +development and for bootstrapping before the encrypted store is configured. + + TODO(port): not for multi-tenant production use — every org would share the + same env secret. Use FernetSecretStore (or a KMS store) in real deployments. +""" + +from __future__ import annotations + +import os + +from orchestrator.secrets.base import SecretStore + +# Maps a logical secret name to the env var that holds it. +_ENV_VAR_FOR = { + "anthropic": "ANTHROPIC_API_KEY", +} + + +class EnvVarSecretStore(SecretStore): + def put(self, org_id: str, name: str, plaintext: str) -> None: + raise NotImplementedError("EnvVarSecretStore is read-only") + + def get(self, org_id: str, name: str) -> str | None: + return os.environ.get(_ENV_VAR_FOR.get(name, name)) or None + + def delete(self, org_id: str, name: str) -> None: + raise NotImplementedError("EnvVarSecretStore is read-only") diff --git a/orchestrator/secrets/fernet_store.py b/orchestrator/secrets/fernet_store.py new file mode 100644 index 0000000..b67c459 --- /dev/null +++ b/orchestrator/secrets/fernet_store.py @@ -0,0 +1,95 @@ +"""FernetSecretStore — envelope-encrypted secret storage in Postgres. + +Storage layout (see models/credential.py): + * ``tenant_keys`` — one wrapped DEK per org + * ``org_credentials`` — ciphertext of each provider secret, keyed (org, provider) + +Crypto policy lives here; the raw crypto math is in ``infra/crypto.py`` and the +persistence is in the credential repositories. A DB dump yields only ciphertext +and wrapped DEKs — useless without the master KEK held outside the database. + + TODO(kms): a sibling KmsSecretStore can implement this same interface using a + cloud KMS/secrets manager. Swapping it is a one-line change in container.py. +""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import AbstractContextManager + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from orchestrator.infra import crypto +from orchestrator.repositories.credential_repo import ( + CredentialRepository, + TenantKeyRepository, +) +from orchestrator.secrets.base import SecretStore + + +class FernetSecretStore(SecretStore): + def __init__( + self, + master_key: str, + session_scope: Callable[[], AbstractContextManager[Session]], + ) -> None: + if not master_key: + raise ValueError( + "FernetSecretStore requires SECRETS_MASTER_KEY. " + "Generate one with orchestrator.infra.crypto.generate_master_key()." + ) + self._master_key = master_key + self._session_scope = session_scope + + # --- internal: get or lazily create the org's data-encryption-key --- + def _get_dek(self, tk_repo: TenantKeyRepository, org_id: str) -> bytes | None: + tk = tk_repo.get_for_org(org_id) + if tk is None: + return None + return crypto.unwrap_dek(tk.wrapped_dek, self._master_key) + + def _get_or_create_dek(self, tk_repo: TenantKeyRepository, org_id: str) -> bytes: + tk = tk_repo.get_for_org(org_id) + if tk is not None: + return crypto.unwrap_dek(tk.wrapped_dek, self._master_key) + dek = crypto.generate_dek() + wrapped = crypto.wrap_dek(dek, self._master_key) + try: + # SAVEPOINT around just the INSERT: on IntegrityError only this + # nested transaction rolls back, leaving the outer transaction + # (managed by the caller's session_scope) and anything already + # loaded/written on `tk_repo.session` intact. + with tk_repo.session.begin_nested(): + tk_repo.create(org_id, wrapped, crypto.KEY_VERSION) + except IntegrityError: + # Concurrent caller won the race to INSERT — re-read their row. + tk = tk_repo.get_for_org(org_id) + if tk is None: + raise + return crypto.unwrap_dek(tk.wrapped_dek, self._master_key) + return dek + + def put(self, org_id: str, name: str, plaintext: str) -> None: + with self._session_scope() as s: + dek = self._get_or_create_dek(TenantKeyRepository(s), org_id) + ciphertext = crypto.encrypt_secret(plaintext, dek) + CredentialRepository(s).upsert( + org_id, ciphertext, crypto.KEY_VERSION, provider=name + ) + + def get(self, org_id: str, name: str) -> str | None: + with self._session_scope() as s: + dek = self._get_dek(TenantKeyRepository(s), org_id) + if dek is None: + return None + cred = CredentialRepository(s).get(org_id, provider=name) + if cred is None: + return None + return crypto.decrypt_secret(cred.ciphertext, dek) + + def delete(self, org_id: str, name: str) -> None: + with self._session_scope() as s: + cred = CredentialRepository(s).get(org_id, provider=name) + if cred is not None: + s.delete(cred) diff --git a/orchestrator/settings.py b/orchestrator/settings.py new file mode 100644 index 0000000..ff5e475 --- /dev/null +++ b/orchestrator/settings.py @@ -0,0 +1,87 @@ +"""Typed application settings (v2). + +Single source of truth for configuration, replacing scattered ``os.environ.get`` +calls. Built on pydantic-settings so values are validated and typed at startup. + +This module is introduced as part of the v2 BYO-key refactor and coexists with +the legacy ``orchestrator/config.py`` during the strangler-fig migration. New +code should depend on ``get_settings()``; legacy modules continue to import +``config`` until they are ported. + + TODO(port): migrate remaining ``config.py`` consumers onto ``Settings`` and + delete ``config.py`` once nothing imports it. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Strongly-typed runtime configuration. + + Values are read from environment variables (and a local ``.env`` file in + development). Names match the existing env vars so this is drop-in. + """ + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", # legacy/unknown env vars are tolerated + case_sensitive=False, + ) + + # === Deployment mode === + # "single_tenant" (default, OSS self-host — runs exactly like before: env + # credentials, no Postgres, no encryption setup) or "multi_tenant" (managed + # cloud — per-org encrypted credentials in Postgres). + deployment_mode: str = Field(default="single_tenant", alias="DEPLOYMENT_MODE") + + # === Database (multi_tenant only) === + # Postgres DSN, e.g. postgresql+psycopg://user:pass@localhost:5432/swarmops + database_url: str = Field( + default="postgresql+psycopg://swarmops:swarmops@localhost:5432/swarmops", + alias="DATABASE_URL", + ) + + @property + def is_multi_tenant(self) -> bool: + return self.deployment_mode == "multi_tenant" + + # === Secrets / encryption === + # Which SecretStore backend to use: "fernet" (encrypted at rest) or "env" (dev only). + secret_backend: str = Field(default="fernet", alias="SECRET_BACKEND") + # The master key-encryption-key (KEK). Base64 urlsafe 32-byte Fernet key. + # TODO(kms): source this from AWS KMS / Vault instead of an env var before + # serving real customer keys. The SecretStore interface does not change. + secrets_master_key: str | None = Field(default=None, alias="SECRETS_MASTER_KEY") + + # === Credentials === + # Which CredentialProvider to use for agent runs: "api_key" (BYO Anthropic key). + credential_provider: str = Field(default="api_key", alias="CREDENTIAL_PROVIDER") + # Base URL used by ApiKeyCredentialProvider.validate(). + anthropic_base_url: str = Field( + default="https://api.anthropic.com", alias="ANTHROPIC_BASE_URL" + ) + + # === Single-tenant credentials (env-based, no DB) === + # In single_tenant mode the agent runs with whichever of these is set, + # preserving the original self-host behaviour exactly. + anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY") + claude_code_oauth_token: str = Field(default="", alias="CLAUDE_CODE_OAUTH_TOKEN") + gh_token: str = Field(default="", alias="GH_TOKEN") + + # === Paths === + workspaces_dir: Path = Field( + default=Path("/root/workspaces"), alias="WORKSPACES_DIR" + ) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Return the process-wide settings singleton.""" + return Settings() diff --git a/orchestrator/tenancy.py b/orchestrator/tenancy.py new file mode 100644 index 0000000..0ee2528 --- /dev/null +++ b/orchestrator/tenancy.py @@ -0,0 +1,40 @@ +"""Tenant resolution helpers (transition shim). + +The execution path needs an ``org_id`` to resolve per-tenant credentials, but +workspaces are not yet created through the org-scoped data layer. Until the auth +slice wires real organizations and the GitHub-App slice maps installations to +orgs, every run resolves to the bootstrap "default" org. + + TODO(auth-slice): map a workspace_id to its real owning org via + WorkspaceRepository once workspaces are created with an org_id, then delete + the default-org fallback. +""" + +from __future__ import annotations + +from orchestrator.settings import get_settings + +# Constant tenant id used in single-tenant mode (no DB, no real orgs). +SINGLE_TENANT_ORG_ID = "single-tenant" + + +def resolve_org_id(workspace_id: str | None = None) -> str: + """Return the org that owns this run. + + Single-tenant mode returns a constant without touching the database, so the + OSS self-host path needs no Postgres. Multi-tenant mode resolves the owning + org from the data layer. + """ + settings = get_settings() + if not settings.is_multi_tenant: + return SINGLE_TENANT_ORG_ID + + # Multi-tenant: resolve from the data layer. + # Imported lazily so single-tenant deployments never import the DB layer. + from orchestrator.infra.db import session_scope + from orchestrator.repositories.organization_repo import OrganizationRepository + + # TODO(auth-slice): if workspace_id maps to an org-scoped workspace, return + # that workspace's org_id instead of the default bootstrap org. + with session_scope() as s: + return OrganizationRepository(s).get_or_create_default().id diff --git a/requirements.txt b/requirements.txt index f6ee49a..0b77061 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,11 @@ fastapi>=0.104.0 uvicorn>=0.24.0 python-dotenv>=1.0.0 + +# v2 foundations (BYO-key + Postgres) +sqlalchemy>=2.0.0 +alembic>=1.13.0 +psycopg[binary]>=3.1.0 +cryptography>=42.0.0 +pydantic-settings>=2.0.0 +httpx>=0.27.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..68249a2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,62 @@ +"""Shared test fixtures. + +Each test gets a fresh in-memory SQLite database wired into the same +``session_scope`` the production code uses, so repositories and stores are +exercised end-to-end without a real Postgres. (SQLite is fine for these unit +tests; schema parity with Postgres is guaranteed by the Alembic migration.) +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool + +from orchestrator.infra import crypto +from orchestrator.infra.db import reset_engine_for_tests, session_scope +from orchestrator.models import Base + + +@pytest.fixture(autouse=True) +def _clear_settings_cache(): + """Ensure each test reads fresh settings (env overrides take effect).""" + from orchestrator.container import get_container + from orchestrator.settings import get_settings + + get_settings.cache_clear() + get_container.cache_clear() + yield + get_settings.cache_clear() + get_container.cache_clear() + + +@pytest.fixture() +def multi_tenant(monkeypatch): + """Switch to multi-tenant mode for a test.""" + monkeypatch.setenv("DEPLOYMENT_MODE", "multi_tenant") + from orchestrator.settings import get_settings + + get_settings.cache_clear() + + +@pytest.fixture() +def db(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, # one shared in-memory connection + ) + reset_engine_for_tests(engine) + Base.metadata.create_all(engine) + yield engine + Base.metadata.drop_all(engine) + + +@pytest.fixture() +def master_key() -> str: + return crypto.generate_master_key() + + +@pytest.fixture() +def scope(): + return session_scope diff --git a/tests/test_agent_pool_credentials.py b/tests/test_agent_pool_credentials.py new file mode 100644 index 0000000..cea2cc1 --- /dev/null +++ b/tests/test_agent_pool_credentials.py @@ -0,0 +1,83 @@ +"""Tests that AgentPool surfaces MissingCredentialError cleanly instead of +spawning a subprocess that would immediately fail.""" + +from __future__ import annotations + +import pytest + +from orchestrator.agent_pool import AgentPool +from orchestrator.credentials.base import CredentialProvider, MissingCredentialError + + +class RaisingProvider(CredentialProvider): + """Simulates an org that has not connected an Anthropic credential.""" + + def for_org(self, org_id): + raise MissingCredentialError(org_id, "anthropic_api_key") + + def validate(self, org_id): + return False + + +def test_spawn_agent_missing_credential_raises_without_popen(monkeypatch): + pool = AgentPool(credential_provider=RaisingProvider()) + + def _fail_if_called(*args, **kwargs): + raise AssertionError("subprocess.Popen must not run without a credential") + + monkeypatch.setattr("orchestrator.agent_pool.subprocess.Popen", _fail_if_called) + + with pytest.raises(MissingCredentialError): + pool._spawn_agent( + agent_id="agent-test-1", + prompt="do work", + worktree_path="/tmp/does-not-matter", + max_turns=5, + issue_number=1, + agent_type="implement", + ) + + +def test_resume_rate_limited_agent_missing_credential_fails_cleanly(monkeypatch, tmp_path): + """resume_rate_limited_agent must not let MissingCredentialError escape: it + should mark the agent failed, reset the issue, and never call Popen.""" + pool = AgentPool(credential_provider=RaisingProvider()) + + def _fail_if_called(*args, **kwargs): + raise AssertionError("subprocess.Popen must not run without a credential") + + monkeypatch.setattr("orchestrator.agent_pool.subprocess.Popen", _fail_if_called) + + finished = {} + updated_issues = {} + monkeypatch.setattr( + "orchestrator.agent_pool.db.get_workspace", + lambda workspace_id: {"id": workspace_id, "local_path": str(tmp_path), "github_repo": "org/repo"}, + ) + monkeypatch.setattr( + "orchestrator.agent_pool.db.finish_agent", + lambda agent_id, status, error_message=None: finished.update(agent_id=agent_id, status=status), + ) + monkeypatch.setattr( + "orchestrator.agent_pool.db.update_issue", + lambda issue_number, **kwargs: updated_issues.update(issue_number=issue_number, **kwargs), + ) + monkeypatch.setattr("orchestrator.agent_pool.cleanup_worktree", lambda *args, **kwargs: None) + + agent_record = { + "agent_id": "agent-test-2", + "issue_number": 1, + "agent_type": "implement", + "worktree_path": str(tmp_path), + "branch_name": "fix/issue-1", + "pr_number": None, + "session_id": "session-1", + "resume_count": 0, + "workspace_id": "ws-1", + } + + result = pool.resume_rate_limited_agent(agent_record) + + assert result is None + assert finished == {"agent_id": "agent-test-2", "status": "failed"} + assert updated_issues == {"issue_number": 1, "workspace_id": "ws-1", "status": "pending"} diff --git a/tests/test_api_key_provider.py b/tests/test_api_key_provider.py new file mode 100644 index 0000000..019266a --- /dev/null +++ b/tests/test_api_key_provider.py @@ -0,0 +1,75 @@ +"""Tests for ApiKeyCredentialProvider, using a fake in-memory SecretStore.""" + +from __future__ import annotations + +import pytest + +from orchestrator.credentials.api_key_provider import ApiKeyCredentialProvider +from orchestrator.credentials.base import MissingCredentialError +from orchestrator.infra.db import session_scope +from orchestrator.models.credential import STATUS_ACTIVE, STATUS_INVALID +from orchestrator.repositories.credential_repo import CredentialRepository +from orchestrator.repositories.organization_repo import OrganizationRepository +from orchestrator.secrets.base import SecretStore + + +class FakeSecretStore(SecretStore): + def __init__(self) -> None: + self._data: dict[tuple[str, str], str] = {} + + def put(self, org_id, name, plaintext): + self._data[(org_id, name)] = plaintext + + def get(self, org_id, name): + return self._data.get((org_id, name)) + + def delete(self, org_id, name): + self._data.pop((org_id, name), None) + + +def test_for_org_returns_anthropic_env(): + store = FakeSecretStore() + store.put("org1", "anthropic", "sk-ant-api03-AAA") + provider = ApiKeyCredentialProvider(store, session_scope) + creds = provider.for_org("org1") + assert creds.env == {"ANTHROPIC_API_KEY": "sk-ant-api03-AAA"} + + +def test_for_org_missing_raises(): + provider = ApiKeyCredentialProvider(FakeSecretStore(), session_scope) + with pytest.raises(MissingCredentialError): + provider.for_org("org-without-key") + + +def test_validate_updates_status(db, monkeypatch): + # seed an org + credential row so set_status has something to update + store = FakeSecretStore() + with session_scope() as s: + org = OrganizationRepository(s).create("Acme", "acme") + CredentialRepository(s).upsert(org.id, b"ciphertext", 1) + org_id = org.id + store.put(org_id, "anthropic", "sk-ant-api03-AAA") + + provider = ApiKeyCredentialProvider(store, session_scope) + + class _Resp: + status_code = 200 + + monkeypatch.setattr( + "orchestrator.credentials.api_key_provider.httpx.get", + lambda *a, **k: _Resp(), + ) + assert provider.validate(org_id) is True + with session_scope() as s: + assert CredentialRepository(s).get(org_id).status == STATUS_ACTIVE + + class _Bad: + status_code = 401 + + monkeypatch.setattr( + "orchestrator.credentials.api_key_provider.httpx.get", + lambda *a, **k: _Bad(), + ) + assert provider.validate(org_id) is False + with session_scope() as s: + assert CredentialRepository(s).get(org_id).status == STATUS_INVALID diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000..72d3a78 --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,35 @@ +"""Unit tests for the envelope-encryption primitives (pure, no DB).""" + +from __future__ import annotations + +import pytest + +from orchestrator.infra import crypto + + +def test_secret_round_trip(): + dek = crypto.generate_dek() + ct = crypto.encrypt_secret("sk-ant-api03-secret", dek) + assert ct != b"sk-ant-api03-secret" + assert crypto.decrypt_secret(ct, dek) == "sk-ant-api03-secret" + + +def test_dek_wrap_unwrap(): + kek = crypto.generate_master_key() + dek = crypto.generate_dek() + wrapped = crypto.wrap_dek(dek, kek) + assert wrapped != dek + assert crypto.unwrap_dek(wrapped, kek) == dek + + +def test_wrong_kek_cannot_unwrap(): + dek = crypto.generate_dek() + wrapped = crypto.wrap_dek(dek, crypto.generate_master_key()) + with pytest.raises(Exception): + crypto.unwrap_dek(wrapped, crypto.generate_master_key()) + + +def test_ciphertext_does_not_contain_plaintext(): + dek = crypto.generate_dek() + ct = crypto.encrypt_secret("supersecretkey", dek) + assert b"supersecretkey" not in ct diff --git a/tests/test_execution_wiring.py b/tests/test_execution_wiring.py new file mode 100644 index 0000000..1eab3f4 --- /dev/null +++ b/tests/test_execution_wiring.py @@ -0,0 +1,44 @@ +"""Tests that the execution path resolves per-org credentials (not a shared token).""" + +from __future__ import annotations + +from orchestrator.agent_pool import AgentPool +from orchestrator.credentials.base import AgentCredentials, CredentialProvider +from orchestrator.infra.db import session_scope +from orchestrator.repositories.organization_repo import ( + DEFAULT_ORG_SLUG, + OrganizationRepository, +) + + +class FakeProvider(CredentialProvider): + def __init__(self) -> None: + self.seen_org_ids: list[str] = [] + + def for_org(self, org_id): + self.seen_org_ids.append(org_id) + return AgentCredentials(env={"ANTHROPIC_API_KEY": "sk-ant-api03-INJECTED"}) + + def validate(self, org_id): + return True + + +def test_agent_credential_env_injects_per_org_key(multi_tenant, db): + provider = FakeProvider() + pool = AgentPool(credential_provider=provider) + + env = pool._agent_credential_env(workspace_id=None) + + assert env == {"ANTHROPIC_API_KEY": "sk-ant-api03-INJECTED"} + # multi-tenant resolves against a real org id (the bootstrap default) + assert provider.seen_org_ids + with session_scope() as s: + default = OrganizationRepository(s).get_by_slug(DEFAULT_ORG_SLUG) + assert default is not None + assert provider.seen_org_ids[0] == default.id + + +def test_no_oauth_token_in_multi_tenant_credential_env(multi_tenant, db): + pool = AgentPool(credential_provider=FakeProvider()) + env = pool._agent_credential_env(workspace_id=None) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in env diff --git a/tests/test_fernet_store.py b/tests/test_fernet_store.py new file mode 100644 index 0000000..e7ddc55 --- /dev/null +++ b/tests/test_fernet_store.py @@ -0,0 +1,202 @@ +"""Integration tests for FernetSecretStore (with the DB).""" + +from __future__ import annotations + +import pytest + +from orchestrator.infra.db import session_scope +from orchestrator.repositories.credential_repo import ( + CredentialRepository, + TenantKeyRepository, +) +from orchestrator.repositories.organization_repo import OrganizationRepository +from orchestrator.secrets.fernet_store import FernetSecretStore + + +def _make_org(slug: str) -> str: + with session_scope() as s: + return OrganizationRepository(s).create(slug.title(), slug).id + + +def test_put_get_round_trip(db, master_key): + org_id = _make_org("acme") + store = FernetSecretStore(master_key=master_key, session_scope=session_scope) + store.put(org_id, "anthropic", "sk-ant-api03-AAA") + assert store.get(org_id, "anthropic") == "sk-ant-api03-AAA" + + +def test_tenants_are_isolated(db, master_key): + a, b = _make_org("acme"), _make_org("beta") + store = FernetSecretStore(master_key=master_key, session_scope=session_scope) + store.put(a, "anthropic", "sk-ant-api03-AAA") + store.put(b, "anthropic", "sk-ant-api03-BBB") + assert store.get(a, "anthropic") == "sk-ant-api03-AAA" + assert store.get(b, "anthropic") == "sk-ant-api03-BBB" + + with session_scope() as s: + # distinct per-tenant DEKs — no single key dumps everyone + ka = TenantKeyRepository(s).get_for_org(a).wrapped_dek + kb = TenantKeyRepository(s).get_for_org(b).wrapped_dek + assert ka != kb + # ciphertext at rest never contains the plaintext + ct = CredentialRepository(s).get(a, "anthropic").ciphertext + assert b"sk-ant" not in ct + + +def test_get_missing_returns_none(db, master_key): + org_id = _make_org("acme") + store = FernetSecretStore(master_key=master_key, session_scope=session_scope) + assert store.get(org_id, "anthropic") is None + + +def test_put_replaces_existing(db, master_key): + org_id = _make_org("acme") + store = FernetSecretStore(master_key=master_key, session_scope=session_scope) + store.put(org_id, "anthropic", "old") + store.put(org_id, "anthropic", "new") + assert store.get(org_id, "anthropic") == "new" + + +def test_delete(db, master_key): + org_id = _make_org("acme") + store = FernetSecretStore(master_key=master_key, session_scope=session_scope) + store.put(org_id, "anthropic", "x") + store.delete(org_id, "anthropic") + assert store.get(org_id, "anthropic") is None + + +def test_requires_master_key(): + with pytest.raises(ValueError): + FernetSecretStore(master_key="", session_scope=session_scope) + + +def test_concurrent_dek_creation_race(db, master_key): + """Race loser catches IntegrityError, re-reads the winner's committed DEK.""" + from unittest.mock import patch + + from sqlalchemy.exc import IntegrityError as SAIntegrityError + + from orchestrator.infra import crypto as cr + from orchestrator.repositories.credential_repo import TenantKeyRepository + + org_id = _make_org("acme") + + # Pre-commit a DEK row simulating the "race winner" having already inserted. + real_dek = cr.generate_dek() + real_wrapped = cr.wrap_dek(real_dek, master_key) + with session_scope() as s: + TenantKeyRepository(s).create(org_id, real_wrapped, cr.KEY_VERSION) + + store = FernetSecretStore(master_key=master_key, session_scope=session_scope) + + # Simulate the race loser: + # 1. get_for_org returns None on the first call — the winner's INSERT isn't + # visible yet at the moment the loser checks. + # 2. create() raises IntegrityError — the winner committed just before us. + # 3. get_for_org delegates to the real implementation on subsequent calls so + # the re-read after rollback finds the committed winner row. + real_get_for_org = TenantKeyRepository.get_for_org + _call_count = 0 + + def _first_call_returns_none(self, org_id): + nonlocal _call_count + _call_count += 1 + if _call_count == 1: + return None + return real_get_for_org(self, org_id) + + def _raise_integrity(*args, **kwargs): + raise SAIntegrityError("UNIQUE constraint failed", None, None) + + with patch.object(TenantKeyRepository, "get_for_org", _first_call_returns_none): + with patch.object(TenantKeyRepository, "create", _raise_integrity): + # put() must catch the IntegrityError, re-read the winner's key, and succeed. + store.put(org_id, "anthropic", "sk-ant-race-test") + + assert store.get(org_id, "anthropic") == "sk-ant-race-test" + + +def test_concurrent_credential_upsert_race(db, master_key): + """Race loser catches IntegrityError, re-reads and updates the winner's row.""" + from unittest.mock import patch + + from orchestrator.repositories.credential_repo import CredentialRepository + + org_id = _make_org("acme") + + # Pre-commit a credential row simulating the "race winner" having already + # inserted, so the loser's INSERT below hits the real UNIQUE constraint. + with session_scope() as s: + CredentialRepository(s).upsert(org_id, b"winner-ciphertext", 1, provider="anthropic") + + store = FernetSecretStore(master_key=master_key, session_scope=session_scope) + + # Simulate the race loser: CredentialRepository.get() returns None on the + # first call — the winner's INSERT isn't visible yet at the moment the + # loser checks — then delegates to the real implementation so the re-read + # after the SAVEPOINT rollback finds the committed winner row. + real_get = CredentialRepository.get + _call_count = 0 + + def _first_call_returns_none(self, org_id, provider): + nonlocal _call_count + _call_count += 1 + if _call_count == 1: + return None + return real_get(self, org_id, provider) + + with patch.object(CredentialRepository, "get", _first_call_returns_none): + # upsert() must catch the IntegrityError, re-read the winner's row, + # and apply this call's values to it instead of raising. + store.put(org_id, "anthropic", "sk-ant-race-test") + + assert store.get(org_id, "anthropic") == "sk-ant-race-test" + + +def test_dek_creation_race_does_not_lose_sibling_writes_in_same_transaction(db, master_key): + """The DEK INSERT race is recovered via a SAVEPOINT, not a full rollback. + + A bare ``session.rollback()`` on the IntegrityError would undo the *entire* + transaction on that session — including any other work already flushed on + it before the race was hit — not just the failed INSERT. + """ + from unittest.mock import patch + + from sqlalchemy.exc import IntegrityError as SAIntegrityError + + from orchestrator.infra import crypto as cr + from orchestrator.repositories.credential_repo import TenantKeyRepository + from orchestrator.repositories.organization_repo import OrganizationRepository + + org_id = _make_org("acme") + + real_dek = cr.generate_dek() + real_wrapped = cr.wrap_dek(real_dek, master_key) + with session_scope() as s: + TenantKeyRepository(s).create(org_id, real_wrapped, cr.KEY_VERSION) + + store = FernetSecretStore(master_key=master_key, session_scope=session_scope) + + real_get_for_org = TenantKeyRepository.get_for_org + _call_count = 0 + + def _first_call_returns_none(self, org_id): + nonlocal _call_count + _call_count += 1 + if _call_count == 1: + return None + return real_get_for_org(self, org_id) + + def _raise_integrity(*args, **kwargs): + raise SAIntegrityError("UNIQUE constraint failed", None, None) + + with session_scope() as s: + # A sibling write earlier in the SAME transaction as the DEK race + # below — it must survive the race's internal rollback. + OrganizationRepository(s).create("Sibling Co", "sibling-co") + with patch.object(TenantKeyRepository, "get_for_org", _first_call_returns_none): + with patch.object(TenantKeyRepository, "create", _raise_integrity): + store._get_or_create_dek(TenantKeyRepository(s), org_id) + + with session_scope() as s: + assert OrganizationRepository(s).get_by_slug("sibling-co") is not None diff --git a/tests/test_planner_credentials.py b/tests/test_planner_credentials.py new file mode 100644 index 0000000..775383d --- /dev/null +++ b/tests/test_planner_credentials.py @@ -0,0 +1,72 @@ +"""Tests that the planning agent surfaces MissingCredentialError cleanly +instead of spawning a subprocess that would immediately fail, or leaving the +planning session stuck on 'generating'.""" + +from __future__ import annotations + +from orchestrator import planner +from orchestrator.credentials.base import MissingCredentialError + + +def test_run_planning_agent_impl_missing_credential_fails_cleanly(monkeypatch, tmp_path): + def _raise(workspace_id=None): + raise MissingCredentialError(workspace_id, "anthropic_api_key") + + monkeypatch.setattr(planner, "_planning_credential_env", _raise) + + def _fail_if_called(*args, **kwargs): + raise AssertionError("subprocess.Popen must not run without a credential") + + monkeypatch.setattr(planner.subprocess, "Popen", _fail_if_called) + + updated_sessions = {} + monkeypatch.setattr( + planner.db, + "update_planning_session", + lambda session_id, **kwargs: updated_sessions.update(session_id=session_id, **kwargs), + ) + + session_id = "session-test-1" + workspace = {"id": "ws-1", "local_path": str(tmp_path)} + + planner._run_planning_agent_impl(session_id, workspace, "plan this") + + assert updated_sessions == {"session_id": session_id, "status": "error"} + assert session_id not in planner._starting + assert session_id not in planner._cancelled + + +def test_run_planning_agent_impl_missing_credential_and_cancelled_fails_cleanly( + monkeypatch, tmp_path +): + """Even if the session was cancelled before the credential lookup failed, + the missing credential is the root cause and must surface as 'error' — + not 'active', which would make a poller that retries 'active' sessions + retry forever against the same missing credential.""" + + def _raise(workspace_id=None): + raise MissingCredentialError(workspace_id, "anthropic_api_key") + + monkeypatch.setattr(planner, "_planning_credential_env", _raise) + + def _fail_if_called(*args, **kwargs): + raise AssertionError("subprocess.Popen must not run without a credential") + + monkeypatch.setattr(planner.subprocess, "Popen", _fail_if_called) + + updated_sessions = {} + monkeypatch.setattr( + planner.db, + "update_planning_session", + lambda session_id, **kwargs: updated_sessions.update(session_id=session_id, **kwargs), + ) + + session_id = "session-test-2" + workspace = {"id": "ws-1", "local_path": str(tmp_path)} + planner._cancelled.add(session_id) + + planner._run_planning_agent_impl(session_id, workspace, "plan this") + + assert updated_sessions == {"session_id": session_id, "status": "error"} + assert session_id not in planner._starting + assert session_id not in planner._cancelled diff --git a/tests/test_single_tenant.py b/tests/test_single_tenant.py new file mode 100644 index 0000000..017445d --- /dev/null +++ b/tests/test_single_tenant.py @@ -0,0 +1,45 @@ +"""Single-tenant (OSS self-host) must behave exactly like before — no DB, +env credentials, and the legacy CLAUDE_CODE_OAUTH_TOKEN still injected as-is. +""" + +from __future__ import annotations + +from orchestrator.agent_pool import AgentPool +from orchestrator.container import build_container +from orchestrator.credentials.env_provider import EnvCredentialProvider +from orchestrator.settings import Settings, get_settings +from orchestrator.tenancy import SINGLE_TENANT_ORG_ID, resolve_org_id + + +def test_default_mode_is_single_tenant(): + assert get_settings().deployment_mode == "single_tenant" + assert get_settings().is_multi_tenant is False + + +def test_single_tenant_container_has_no_secret_store(monkeypatch): + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-TEAM") + c = build_container(Settings()) + assert c.secret_store is None + assert isinstance(c.credential_provider, EnvCredentialProvider) + + +def test_resolve_org_id_is_constant_without_db(): + # No `db` fixture here on purpose: single-tenant must not touch Postgres. + assert resolve_org_id() == SINGLE_TENANT_ORG_ID + assert resolve_org_id("any-workspace") == SINGLE_TENANT_ORG_ID + + +def test_legacy_oauth_token_injected_exactly_like_before(monkeypatch): + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-TEAM") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + pool = AgentPool(credential_provider=EnvCredentialProvider(Settings())) + env = pool._agent_credential_env(workspace_id=None) + assert env == {"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-TEAM"} + + +def test_api_key_also_works_for_selfhost(monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-SELF") + pool = AgentPool(credential_provider=EnvCredentialProvider(Settings())) + env = pool._agent_credential_env(workspace_id=None) + assert env == {"ANTHROPIC_API_KEY": "sk-ant-api03-SELF"}