diff --git a/docs/design/starter-credits-bridge/README.md b/docs/design/starter-credits-bridge/README.md new file mode 100644 index 00000000000..18f5bb85b34 --- /dev/null +++ b/docs/design/starter-credits-bridge/README.md @@ -0,0 +1,84 @@ +# Starter-credits bridge + +A new organization signs up with no model provider key, so nothing runs until someone +pastes one in. The starter-credits bridge closes that gap. At signup the platform mints a +budget-capped key on a proxy the operator runs, and writes it into the new organization's +vault as a ready-to-use provider connection. The first run then works with no key form and +no configuration. + +The bridge is deliberately small and deliberately temporary. It is EE only, inert unless a +deployment configures it, and it reuses the connection path that already ships instead of +adding a funded route of its own. When a first-class gateway owns funded traffic, the +bridge is removed. + +## The path, end to end + +1. **Signup.** A person signs up. The signup path creates their organization and its + default project, then calls the EE hook `provision_signup_subscription` + (`api/ee/src/core/organizations/service.py`), which calls + `seed_starter_credits_bridge_safely`. +2. **Gates.** Seeding runs only when the deployment is armed (see + [Configuration](#configuration)), the mint policy resolves, the proxy team's budget + ceiling verifies, the organization has no seeded row already, and the mint policy + allows this signup. +3. **Mint.** The service asks the proxy for one virtual key: a credential the proxy itself + issues, carrying a spend ceiling, an allowlist of exactly one model, and per-key + concurrency and throughput caps. The key's alias is the organization id, so a retried + signup cannot mint twice. +4. **Seed.** The same call writes the key into the default project's vault as a custom + provider connection, created `managed_by` the bridge and `write_only`. The connection + stores the proxy's public base URL and the one model id. +5. **First run.** The SDK resolves the connection like any other custom provider, the + runner pins that one model at that base URL, and the harness in the sandbox calls the + proxy. The proxy checks the key, checks the budget, attaches the operator's upstream + credential, and forwards the call. +6. **Exhaustion.** Once the organization's spend reaches the ceiling, the proxy refuses at + admission. The runner classifies the refusal from the response body and returns a + stable error code with a plain message, so the chat can tell the user to add their own + key. + +## The pieces + +| Piece | Where | What it does | +| --- | --- | --- | +| Seeding service | `api/ee/src/core/starter_credits_bridge/service.py` | The gates, the mint, the vault write, the velocity counters, the operator alert | +| Proxy admin client | `api/ee/src/core/starter_credits_bridge/client.py` | The master-keyed calls: generate a key, block a key, read team info | +| Mint policy and errors | `api/ee/src/core/starter_credits_bridge/types.py` | The `MintPolicy` model, the free-mail domain list, the development policy, the proxy error types | +| Signup hook | `api/ee/src/core/organizations/service.py` | The one call site, bounded and swallow-all | +| Configuration | `api/oss/src/utils/env.py` (`StarterCreditsBridgeConfig`) | The env contract and the `armed` predicate | +| Write-only secrets | `api/oss/src/core/secrets/redaction.py`, `api/oss/src/middlewares/auth.py` | Values a user can replace but never read back; the runtime reads plaintext through the `secret-resolve` grant | +| Managed secrets | `api/oss/src/core/secrets/managed.py` | Rows the platform owns, which users cannot edit or delete | +| SDK resolution | `sdks/python/agenta/sdk/agents/platform/connections.py`, `connections/errors.py` | Turns the connection into a provider configuration; raises `WriteOnlySecretError` when a caller without the grant gets a redacted key and no environment fallback | +| Endpoint validation | `sdks/python/agenta/sdk/agents/connections/endpoints.py` | Rejects any connection base URL that is not absolute HTTPS | +| Runner error classes | `services/runner/src/engines/sandbox_agent/errors.ts` | `RunErrorCode`, including `starter_credits_exhausted`, `starter_credits_program_paused`, `starter_credits_unavailable`, and `rate_limited` | + +## Configuration + +Every variable is read once into `StarterCreditsBridgeConfig` +(`api/oss/src/utils/env.py`). The bridge is `armed` only when `ENABLED` is true and both +proxy addresses, the master key, and the team id are all present. An unarmed deployment +returns from the seeding call immediately, which is what every OSS and self-hosted +deployment does. + +| Variable (`AGENTA_STARTER_CREDITS_BRIDGE_` prefix) | Required to arm | What it holds | +| --- | --- | --- | +| `ENABLED` | yes | The opt-in switch. Defaults to false. Changing it takes a redeploy | +| `PROXY_PUBLIC_URL` | yes | The base URL stored on the seeded connection. A sandboxed run dials it from outside the deployment's network, so only the proxy's inference paths are published there | +| `PROXY_ADMIN_URL` | yes | The proxy's address on the private network. The minting client dials it, so the master key never crosses the public edge | +| `MASTER_KEY` | yes | The proxy's admin credential. It mints and blocks keys | +| `TEAM_ID` | yes | The proxy team every minted key joins. The team's own budget ceiling bounds total exposure, so seeding refuses to run without one | +| `MODEL_ID` | no | The single model id the minted key allowlists and the seeded connection publishes. Defaults to `vertex_ai/gemini-3.6-flash` | +| `POLICY_FLAG` | no | The name of the PostHog feature flag whose payload carries the mint policy. Defaults to `starter-credits-bridge-policy` | +| `ALERT_WEBHOOK` | no | An operator webhook. The service posts `{"text": ...}` to it on refusals and failures | + +No policy value (grant size, velocity caps, per-key limits, domain rules) is configured +here. Those arrive in the policy payload. See +[Mint policy](design.md#the-mint-policy-comes-from-the-operator-not-from-source). + +## Documents + +| File | Answers | +| --- | --- | +| [design.md](design.md) | Every decision, the options it was chosen against, and why | +| [write-only-secrets.md](write-only-secrets.md) | The vault contract the seeded row depends on: write-only values and managed rows | +| [proxy-and-deployment.md](proxy-and-deployment.md) | What the proxy must look like, how it is routed, and what that constrains | diff --git a/docs/design/starter-credits-bridge/design.md b/docs/design/starter-credits-bridge/design.md new file mode 100644 index 00000000000..0c6c56788e2 --- /dev/null +++ b/docs/design/starter-credits-bridge/design.md @@ -0,0 +1,339 @@ +# Design + +Every decision the starter-credits bridge makes, the options it was chosen against, and +the reason. For what the bridge is and where its code lives, read +[README.md](README.md) first. + +Two words recur. A **virtual key** is a credential the operator's proxy issues to a +caller. It carries a spend ceiling, an allowlist of models, and rate limits, and it works +only against that proxy. The **grant** is the spend ceiling on one organization's virtual +key. The bridge never sees the operator's real upstream provider credential; the proxy +holds that and attaches it server-side. + +## What the bridge is not + +- **Not a credit system.** There is no ledger, no balance, no purchase, no hold, and no + refund. The proxy's own spend records are the accounting record for as long as the + bridge runs. +- **Not a gateway.** A spendable credential still reaches the sandbox. What bounds it is + the grant, the one-model allowlist, the per-key rate limits, and the team ceiling above + all of them. +- **Not for OSS or self-hosted deployments.** All of the seeding code is under `api/ee`, + which the OSS image never copies, and the code is inert unless a deployment supplies the + configuration in [README.md](README.md#configuration). +- **Not permanent.** See [Teardown](#teardown). + +## One key per organization, not one shared key + +Options considered: + +1. One virtual key for the whole deployment, seeded into every organization. +2. One virtual key per organization, minted at signup. + +The bridge mints per organization. A shared key is one number that every organization +spends from, so the first heavy user consumes everyone's allowance, and a leaked key +exposes the whole program rather than one organization's remainder. Per-organization keys +give three properties a shared key cannot: total exposure per organization equals that +organization's own grant, spend is attributable per organization without extra plumbing, +and one abusive organization can be blocked on its own. + +The cost is a mint call on the signup path. That call is bounded (10 seconds for the whole +seeding attempt) and its failure is swallowed, so a slow or unreachable proxy degrades the +signup to "no starter credits" rather than breaking it. The signup path deletes the new +user when setup raises, so swallowing is not politeness here. It is the only safe +behavior. + +Minting exactly once is enforced twice. The key's alias on the proxy is the organization +id, and aliases are unique, so a retried signup hook gets a conflict rather than a second +key. The vault row has a fixed slug under a unique index on project and slug, and the +service reads that row before it mints, so an organization that already holds a seeded row +never reaches the mint at all. + +## A vault connection, not a new funded route + +Options considered: + +1. Add a funded route through the API and teach the runner to use it. +2. Synthesize a platform-owned connection at resolution time, with no stored row. +3. Write an ordinary custom provider connection into the organization's vault. + +The bridge writes a vault row. The run path already knows how to carry a custom provider +connection end to end: the SDK resolves it, the runner pins its one model at its base URL, +and the harness calls that URL with that key. Option 3 therefore needs no change in the +SDK, the runner, or the web app, which is the whole reason a bridge is affordable at all. + +Option 2 is the better long-term shape, because a connection that exists only on the wire +cannot be read, renamed, or deleted by anyone. It is also a change across the SDK, the +runner, and the frontend's notion of a configured provider. That is the gateway's job, not +the bridge's. + +Option 1 duplicates the gateway's routing work in a component that is designed to be +deleted. + +The seeded row is an ordinary row in one respect only: it uses the same kind and the same +resolution path. In every other respect it is marked, and the next two sections are those +marks. + +## The seeded value is write-only + +Any project member can list the project's vault secrets. Before this change every one of +those responses carried decrypted values, so a seeded virtual key was readable by anyone +in the project with an API key. + +The bridge creates its row with `write_only: true`, which means the value can be created, +replaced, and deleted, but never read back by a user. The platform runtime keeps reading +plaintext through a scoped grant, so runs are unaffected. This is the model GitHub uses +for repository secrets. + +Two reasons to set it at creation rather than to add it afterwards. First, a row that +starts readable and is tightened later has a window in which the key is readable, and the +window is exactly the moment the organization is new and nobody is watching. Second, the +whole point of the seeded key is that the organization never supplied it and never needs +it: there is no user workflow that requires reading it back. + +What remains true after the flag: anyone who can start a run in the project can spend the +grant, because the run itself reaches the key. The flag removes the casual read, not the +ability to spend. Spending is what the grant, the allowlist, and the rate limits bound. + +The full contract is in [write-only-secrets.md](write-only-secrets.md). + +## The seeded row is managed + +`write_only` stops a read. It does not stop a delete, a rename, or a re-credential, and +all three break the seeded row in ways the user cannot repair: + +- Deleting it strands the funded budget behind it. There is no repair path + ([below](#no-repair-path)), so the organization is unfunded from then on. +- Renaming it breaks every saved model reference. The connection's display name is the + namespace half of every model key it publishes, and a model key is permanent once a + configuration references it (see + [The display name is part of the contract](#the-display-name-is-part-of-the-contract)). +- Re-pointing its URL or key at something else turns a row the platform is responsible for + into a row it is not. + +So the bridge creates the row with `managed_by` set to its own name. A managed row is +read-only to users: update and delete both return HTTP 409. The marker is server +controlled, so a client that supplies `managed_by` on any request gets HTTP 400 rather +than being silently ignored. + +The alternative considered was a per-field policy: keep the model list and the description +editable, pin everything else back to what was stored. It was dropped because the answer +to "can I save this form" never changed, and one sentence a user can act on ("this +connection is Agenta's") beats a per-field verdict that always says no. + +## The mint policy comes from the operator, not from source + +The mint policy is nine values: the grant, three per-key limits (concurrency, requests per +minute, tokens per minute), three velocity caps, the free-mail domain list, and one +eligibility rule. None of them lives in this repository. They arrive as the payload of a +PostHog feature flag whose name is the only configurable part +(`AGENTA_STARTER_CREDITS_BRIDGE_POLICY_FLAG`). + +Three reasons for that split. The values are the operator's money and the operator's abuse +posture, and neither belongs in an open source repository. They must change without a +redeploy, because the moment to lower a cap is the moment an abuse wave is running. +Keeping them in one payload also means no single field can be moved on one deployment in +isolation. + +Resolution is deliberately awkward, and every branch fails toward not seeding: + +| Situation | Result | +| --- | --- | +| Live payload present and valid | Seed. The payload is cached for transport outages | +| Live payload present and malformed | Do not seed. Alert the operator. The cache is deliberately not consulted, because a bad rollout must never silently keep the old caps | +| PostHog reachable, no payload | Do not seed. A reachable source with nothing to say is a real "no policy" signal | +| PostHog unreachable | Use the cached payload if there is one, otherwise do not seed | +| The deployment configured no PostHog key of its own | Use the built-in development policy | + +That last row is the one exception, and it exists for a practical reason. A local stack or +a QA deployment has no way to publish a payload, so failing closed would block it with no +way to unblock. The development policy's values live in +`api/ee/src/core/starter_credits_bridge/types.py` and are deliberately generic: a small +grant and caps loose enough not to interfere with testing. They describe nothing about any +real program. + +The check is on whether the deployment supplied its own PostHog key, not on whether +PostHog is enabled. The PostHog configuration falls back to a built-in project key, so +`enabled` is true in every checkout and could never tell a local stack from a real +deployment. + +## Per-key concurrency and throughput caps + +Every minted key carries a maximum number of parallel requests, a requests-per-minute +limit, and a tokens-per-minute limit, all from the policy payload. + +The grant alone bounds what one organization can spend in total. It does not bound how +fast. Two things break under speed rather than volume. The upstream provider's throughput +quota is shared by everything the operator's credential serves, so one organization +looping calls can starve every other funded organization. And a burst that drains a grant +in minutes turns a trial into an error message before the person has typed a second +message. + +The proxy also enforces its own upper bounds on what a key may be issued with. Raising a +policy value above the proxy's ceiling makes every mint fail with HTTP 400 until the proxy +configuration is raised and redeployed. Lowering a value is a live payload edit. That +asymmetry is intentional: loosening requires two people and a deploy, tightening does not. + +## Only new signups, and never an existing organization + +The mint sits in `provision_signup_subscription`, which runs only for organizations +created by the signup path. Creating an organization explicitly through +`POST /organizations/` runs `provision_user_subscription` instead and earns nothing. That +is the existing boundary against farming: making organizations in a loop from one account +gets no grants. + +Existing organizations are never backfilled. Backfilling is a one-off script over the same +function, and holding it back costs nothing while the new-signup population is still being +watched. + +An invited teammate is not a special case. They still sign up, and the signup path creates +their own organization unless they already belong to one, so they are seeded exactly like +any other new signup. Nobody has to seed an organization twice, and nobody has to decide +what an invitation into a funded organization means. + +Velocity caps then bound the population that does qualify. They are counted in Redis +against expiring keys: a global daily count, a global hourly count, and a per-domain daily +count that applies only to domains that are not free mail. A cap on the world's most +common mailbox provider would treat every personal address as one company and would start +refusing real people immediately. One eligibility rule rides alongside them: on a +non-free-mail domain, a digit in the local part of the address is refused, which is the +throwaway-address pattern. On free mail, digits are ordinary and stay allowed. + +Two properties of the counting are worth stating. If Redis is unreachable, seeding is +skipped, because a mint nobody can count is a mint nobody can bound. And when an attempt +consumes a counter slot but funds no key, the slot is handed back, so a proxy outage does +not quietly eat the day's allowance. + +The known gap: there is no per-address cap, because the signup hook never sees the +caller's network address. The domain rules and whatever bot resistance fronts signup carry +that load. + +## The always-on ceiling: the program team + +Every minted key joins one team on the proxy, and that team carries its own budget +ceiling. It is the bound that holds when nothing else does: if the policy is wrong, if the +velocity caps are too loose, if an abuse wave gets through, total spend still stops at the +team ceiling. + +Because it is the last bound, the bridge refuses to mint unless it can verify the ceiling +stands. It reads the team from the proxy and requires a numeric, finite, positive budget +with no reset duration. A budget that resets periodically is a rate, not a total-exposure +bound, so it is refused too. An unverifiable team refuses and alerts. + +A positive verification is trusted for ten minutes rather than for the life of the +process, so a ceiling that is removed or loosened at the proxy is caught without a +restart. + +## No repair path + +Seeding does two writes that can fail independently: it mints a key at the proxy, then it +writes the vault row. A crash between them leaves an organization with a live key that +nothing references. + +The bridge handles that inline and then stops. If the row write fails, the service blocks +the just-minted key, so an orphaned grant can never be spent. Nothing retries later. + +Options considered: + +1. A reconcile sweep that finds half-seeded organizations and completes them. +2. An operator route that re-seeds one organization on request. +3. Nothing. + +The bridge does nothing, and the reason is that anything which can mint for an existing +organization is a refill mechanism. The attack is cheap and obvious: spend the grant, +delete the connection (project members may delete their own connections), ask for a +repair, receive a fresh grant. Closing that hole means an invariant strong enough to state +in one line, "an organization's total lifetime grant never increases", and holding it +across crash boundaries. That is a signed record of the authorized remainder, a clamp on +every mint, and a record-then-replace protocol. It is real work, and all of it exists to +serve a failure that is rare. + +The accepted cost, at the measured failure rate, is roughly one unfunded signup every two +hundred days. That organization sees the same connect-your-key wall every organization saw +before the bridge existed, which is not a regression. + +The invariant is written down here because it is the contract any future repair path would +have to meet, not because anything implements it today. + +## The display name is part of the contract + +The seeded connection is named `Agenta`, and its model-key namespace is set to the same +string. That is not cosmetic. A custom provider connection publishes its models under +`//`, and the resolver rebuilds keys under exactly that namespace. +A saved agent configuration that references one of those keys is referencing the name. + +Two consequences follow. + +The namespace must equal the display name. If they differ, a user reads one name in +settings and every saved reference carries another, and the resolver cannot find a model +whose key was written under the other spelling. + +The name is permanent per row, not per deployment. An organization keeps the name it was +seeded with and the model keys that were built from it. Changing the constant renames +nothing that already exists. It only sets what the next organization gets, which means a +change after any real rollout leaves two namespaces in the field, each correct for its own +organizations. Treat the name as settled once seeding has run anywhere real. + +`managed_by` is what keeps a user from creating the same problem from the other side by +renaming the row. + +## Exhaustion is a product moment, not an error + +When the organization's spend reaches the grant, the proxy refuses the call at admission +with HTTP 429. So does a per-key rate limit, and so does an upstream provider quota error. +Only the response body separates them. + +The runner therefore classifies on the body, never on the status alone, and returns a +stable code alongside the human line (`RunErrorCode` in +`services/runner/src/engines/sandbox_agent/errors.ts`): +`starter_credits_exhausted`, `starter_credits_program_paused`, +`starter_credits_unavailable`, `rate_limited`, and `runner_error` for everything else. A +client can render a purposeful state from the code instead of parsing prose. Telling +someone their credits are gone when they were merely throttled is worse than saying +nothing, which is why the classification is narrow. + +One consequence of seeding a real connection: the frontend's connect-your-key gate counts +it, so the key wall never appears for a funded organization. It also does not come back at +exhaustion. The exhaustion moment has to carry its own message, because nothing else will. + +The path past the wall is the ordinary one. The user connects their own provider key and +points the agent at it. The seeded connection does not block adding another. + +## Failure modes + +| Situation | Behavior | +| --- | --- | +| Proxy unreachable at signup | Seeding fails inside its bound, is logged, and alerts. The organization is created unfunded and meets the ordinary key wall. Nothing retries | +| Proxy unreachable at run time | Funded runs fail with a connection error. The model is pinned, so there is no silent fallback to another provider. Organizations on their own keys are unaffected | +| Proxy up, its database down | Budget checks cannot run. The proxy must be configured to refuse rather than serve unmetered calls on the operator's credential | +| Redis unreachable | Velocity counters cannot be read. Seeding is skipped | +| Team ceiling missing, resetting, or unverifiable | Seeding is refused and the operator is alerted | +| Policy payload malformed | Seeding is refused, the operator is alerted, and the cached payload is deliberately not used | +| Mint succeeds, vault write fails | The key is blocked immediately. The organization stays unfunded | +| Signup retried while the first attempt is in flight | The alias conflict on the proxy, and the slug check in the vault, each stop the second grant | + +## Teardown + +The bridge is removed when a first-class funded path serves the same traffic. The order +matters, and two steps in it are easy to get wrong. + +1. Stop seeding first, by clearing the policy payload. New signups then flow to whatever + replaces the bridge. +2. Block every virtual key before deleting anything. Blocking is immediate and reversible. + Deleting a connection row while its key is still spendable leaves a live credential + nothing accounts for. +3. Snapshot spend per organization, and export the proxy's spend records. The proxy's + database does not survive the teardown, so anything anyone will want later has to leave + before it goes. +4. Rewrite every saved reference that carries the bridge's namespace, not only the ones + currently in use. Agent revisions, evaluations, and jobs can all hold a model key. +5. Delete only rows proven to belong to the bridge, by secret id and by the ownership + marker carried in the key's metadata on the proxy, never by slug alone. A user can + delete a slug and create their own row under the same name. +6. Watch refused attempts on the old route, not successful traffic. Blocked keys make + "zero successful traffic" true immediately while users are still broken. The signal is + refusals trending to zero. + +What survives the teardown: the exhaustion classification in the runner, the write-only +and managed vault contracts, and the exported spend history. Everything else is deleted, +including the EE module, the configuration, and the proxy itself. diff --git a/docs/design/starter-credits-bridge/proxy-and-deployment.md b/docs/design/starter-credits-bridge/proxy-and-deployment.md new file mode 100644 index 00000000000..ff584ca5ee7 --- /dev/null +++ b/docs/design/starter-credits-bridge/proxy-and-deployment.md @@ -0,0 +1,97 @@ +# The proxy and how it is routed + +The bridge does not ship a proxy. It expects one, and this page says what it has to be. +The reference implementation is LiteLLM, an open source service that speaks the OpenAI +chat completions dialect, holds real provider credentials, and issues its own virtual +keys. Any service with the same properties would do. Deployment itself (compose files, +secret storage, certificates) is the operator's, and none of it lives in this repository. + +## What the proxy must provide + +| Capability | Why the bridge needs it | +| --- | --- | +| OpenAI-compatible chat completions | It is the request shape the runner already emits, which is what makes the seeded connection an ordinary custom provider connection | +| Virtual keys with a spend ceiling | The ceiling is the grant. It is the only thing that bounds one organization's total spend | +| A model allowlist per key | An explicit list, always. An omitted list means any model, and an empty list means none | +| Per-key concurrency and rate limits | See [design.md](design.md#per-key-concurrency-and-throughput-caps) | +| A key alias, unique | The bridge sets it to the organization id, which is what makes minting idempotent | +| Key metadata the key holder cannot change | The bridge stamps the organization id and an origin marker there. It is the unforgeable side of any later operator-side inspection | +| Teams with their own budget ceiling | Every minted key joins one team whose ceiling is the program's total exposure bound | +| Block and unblock a key, immediately and reversibly | The bridge blocks a key whose vault row failed to write. An operator blocks a key that is being abused | +| Per-key spend records | The accounting record for as long as the bridge runs | +| Issuance upper bounds | Configured caps on what any key may be issued with, so a policy payload cannot mint a key larger than the proxy allows | + +Two configuration properties matter as much as the capabilities: + +- **Budget enforcement must fail closed.** If the proxy's own database is unreachable, its + default is usually to keep serving. That serves unmetered calls on the operator's + credential. Configure it to refuse. +- **One instance, or a shared counter.** Budget enforcement is only sound across instances + when they share spend state. Two unsynchronized instances each admit against their own + view of a key's spend, which breaks the grant. + +## Two addresses, one service + +The bridge holds two URLs for the same proxy, and they are not interchangeable. + +`PROXY_PUBLIC_URL` is stored on the seeded connection. A sandboxed run dials it from +outside the deployment's network, so it must be reachable from wherever sandboxes run. + +`PROXY_ADMIN_URL` is the proxy's address on the private network. The minting client dials +it, so the master key never crosses the public edge. + +The split exists because the public surface should be the inference paths and nothing +else. Publish the exact paths (chat completions, and the model probe the harness makes), +never a prefix: a prefix exposes the management routes that mint and delete keys. The +admin routes stay reachable on the private network alone. + +## Where the public surface lives + +Two routing shapes work, and the operator picks one per deployment. + +**A path on the API host.** The proxy sits behind a path on the host the deployment +already serves, and the reverse proxy strips the prefix before the request reaches it. +Nothing new is provisioned: no DNS record, no certificate. + +**A dedicated host.** The proxy gets its own hostname, serving the same paths bare. This +costs one DNS record and certificate coverage for the name. + +They differ in exactly one property, and it only matters when the sandbox platform hides +credentials by scope. Daytona scopes a sandbox secret to an exact host, not to a path. On +a shared host, a secret scoped to that host is offered on every request the sandbox makes +to it, so a process inside the sandbox could in principle get its own organization's +virtual key echoed back by an unrelated endpoint on the same host. A dedicated host serves +nothing but the proxy, so no such endpoint exists. + +Two consequences. A deployment on the path shape must ensure no endpoint on that host +echoes request headers or arbitrary request content back in a response. And the residual +exposure, if it happened, is one organization's own remaining grant, on one model, against +one proxy, with the vault read already closed. + +Whichever shape is used, strip the proxy's own response headers at the edge. They can name +the upstream route the call was forwarded to, including operator-side identifiers, and +that response reaches the sandbox. The header names have to be enumerated, and the list +has to be re-checked whenever the proxy is upgraded. + +## The seeded URL must be HTTPS + +`effective_endpoint` rejects any connection base URL that is not absolute HTTPS +(`sdks/python/agenta/sdk/agents/connections/endpoints.py`, "model connection endpoint must +be an absolute HTTPS URL"). There is no override and no local exception. + +This is a real constraint on local development. A proxy on `http://localhost` cannot be +seeded, because the resolver refuses the connection before any call is made. Put a +tunnel with a real certificate in front of it, and seed the tunnel's URL. + +## What the sandbox sees + +The virtual key is a credential, so the run path treats it as one. On Daytona, secrets are +substituted at the network edge: the sandbox environment holds a placeholder, and Daytona +swaps in the real value for one allowed host. Files, environment variables, and process +memory inside the sandbox never contain the value, so an agent that prints its environment +or greps its own disk finds the placeholder. + +That controls reading, not spending. A run inside a live sandbox can still spend its own +organization's grant, for example by scripting many calls. Spending is bounded by the +grant, the per-key limits, and the team ceiling. Those bounds are the security model; the +hiding is defense in depth on top of them. diff --git a/docs/design/starter-credits-bridge/write-only-secrets.md b/docs/design/starter-credits-bridge/write-only-secrets.md new file mode 100644 index 00000000000..cf3eb4629d9 --- /dev/null +++ b/docs/design/starter-credits-bridge/write-only-secrets.md @@ -0,0 +1,154 @@ +# The vault contract the seeded row depends on + +The starter-credits bridge writes one vault row and then never touches it again. That row +holds a credential the organization did not supply, under a name that other records point +at. Two vault attributes make it safe to leave there: `write_only` keeps its value from +being read back, and `managed_by` keeps the row from being edited or deleted. + +Both are general platform mechanisms, not bridge features. The bridge is the first thing +that sets them. The full write-up of each lives in +[docs/design/write-only-secrets](../write-only-secrets/README.md) and +[docs/design/managed-secrets](../managed-secrets/README.md); this page covers the parts the +bridge depends on and why it sets both at creation. + +## The problem + +Any project member could list the project's vault secrets, and every one of those +responses carried decrypted values. The create call handed the key straight back, and the +list response was cached with plaintext in it. There was no masking anywhere on the +server. + +For the bridge that is the last place a user can read the seeded virtual key. For the +platform it is larger than the bridge: every project member could read every teammate's +provider key. + +## Write-only: the GitHub model + +A secret can be created, replaced, and deleted. It is never read back by a user. The +runtime that spends it keeps reading it. + +### The flag + +`write_only: bool` on every secret (`api/oss/src/core/secrets/dtos.py`). + +- The default for new secrets is env-gated: `AGENTA_VAULT_WRITE_ONLY_DEFAULT` + (`api/oss/src/utils/env.py`), which ships false. An explicit `write_only` on the create + request always wins over the gate, in both directions, which is how the bridge seeds its + key write-only while nothing changes for anyone else. +- Rows that carry no flag read as `write_only: false`. Their behavior is unchanged. +- The flag is **one-way**. An update may tighten false to true. True to false returns HTTP + 400 (`WriteOnlyCannotBeDisabledError`); delete and recreate instead. The transition is + atomic: the DAO checks under a `SELECT ... FOR UPDATE` row lock + (`api/oss/src/dbs/postgres/secrets/dao.py`), and the mapper never clears a stored flag + even when handed a stale explicit false. +- It rides inside the existing encrypted `data` JSON as a sibling key, popped out at the + mapping layer. There is no schema migration. + +### Redaction + +For a write-only secret, every user-facing response (create echo, list, get, update echo) +strips the value and adds two fields: + +- `has_key: bool`. Whether any credential material is stored, including credential extras. +- `key_preview: str | null`. A masked preview of the primary value only. Values under 20 + characters mask entirely; longer ones disclose at most three leading and three trailing + characters, and never more than a quarter of the value. The policy lives in one helper, + `api/oss/src/core/secrets/redaction.py`. + +What counts as credential material is defined once, in the SDK +(`sdks/python/agenta/sdk/agents/connections/credentials.py`), and imported by the API, so +the fields the resolver consumes as credentials and the fields redaction strips cannot +drift. Non-credential configuration (URL, region, model list) stays readable, which is why +a redacted seeded connection still shows the user which proxy and which model it points +at. + +Redaction happens at the response boundary, in every outward surface: the vault routes, +webhook subscription responses, and the EE organization-provider serialization. In-process +runtime readers below `VaultService` are untouched. + +### Updates keep the stored value on omit + +On update, an omitted value field means "keep the stored value", extending the existing +carry-over pattern (`_carry_over_saved_policy` in `api/oss/src/core/secrets/services.py`). +An empty string counts as omitted. That is mandatory rather than convenient: the current +edit form re-sends an empty key when it cannot prefill a value, so treating empty as +"clear" would wipe the credential on every edit of a write-only secret through the +existing UI. Values are therefore replace-only. + +Carry-over is identity-local. An update that changes the secret's kind or its provider +family must carry an explicit new credential; omitting it returns HTTP 400 +(`SecretValueRequiredError`), and the old identity's credential extras never carry over. A +stored key for one provider can never silently become a key for another. + +### The runtime path: a grant, not a scope + +`SECRET_RESOLVE_GRANT = "secret-resolve"` (`api/oss/src/middlewares/auth.py`). + +A scope confines a token to an allowlist of paths. The runtime's credential is +general-purpose, since it authenticates workflows, tools, session coordination, and vault +reads alike, so the plaintext capability rides an additive `grants` claim that confines +nothing. + +It is minted in two places: + +- `GET /access/permissions/check` attaches it to the re-minted credential for + `action=run_service` exchanges (`api/oss/src/apis/fastapi/access/router.py`). That is the + credential every workflow service and sandbox run actually uses. +- The workflow invoke and inspect prelude (`sign_secret_token` in + `api/oss/src/core/workflows/service.py`), which covers services running with auth + middleware disabled. + +A verified Secret token carrying the grant receives plaintext from the vault read routes. +Everyone else, including session and API-key callers, gets the redacted shape. There is no +transition period for API-key callers. + +The trust line is GitHub's: anyone who can run a workload can reach the values through a +run. What the flag removes is the casual read. + +### What a standalone SDK run sees + +A process running outside the platform with a raw API key gets the redacted shape. The +resolver falls back to the provider's standard environment variable, and raises +`WriteOnlySecretError` (`sdks/python/agenta/sdk/agents/connections/errors.py`, HTTP 422) +only when that variable is absent too. Passing a redacted value to a provider would fail +with a misleading authentication error, so the resolver fails loudly and names the +remediation: switch the connection to `self_managed` and set the provider's environment +variable. + +## Managed rows + +`managed_by: str | None` names the platform component that provisioned and owns a row, for +example `"starter-credits-bridge"`. Absent means nobody owns it, which is every row that +existed before. + +- It is **server controlled**. The vault routes reject a client-supplied `managed_by` on + create and on update with HTTP 400 (`ManagedByIsServerControlledError`). Rejecting + rather than ignoring matters: a caller that sent the field believes the row will end up + managed, and dropping it silently would leave that caller wrong about what the vault + holds. +- Only in-process callers set it, by putting it on the `CreateSecretDTO` they hand + `VaultService.create_secret`. +- A managed row is read-only to users. Update and delete both return HTTP 409 + (`ManagedSecretReadOnlyError`). It is 409 and not 400 because the request is well formed; + it is the stored row's state that forbids it. +- `update_secret` and `delete_secret` take `allow_managed: bool = False` + (`api/oss/src/core/secrets/services.py`). The default applies the guard, so a route added + later is guarded without anyone remembering to opt in. Forgetting the parameter denies; + it does not permit. The owning component passes `allow_managed=True` when it needs to + re-credential or tear down its own row. + +Enforcement lives in `api/oss/src/core/secrets/managed.py` and is invoked by +`VaultService`. + +The bridge sets `allow_managed` nowhere. It seeds once, never repairs +([design.md](design.md#no-repair-path)), and its teardown is an operator procedure, so the +override exists for a future owner rather than for this one. + +## Why the bridge sets both, at creation + +| Attribute | What it stops | Why at creation | +| --- | --- | --- | +| `write_only` | Reading the virtual key back through the API | A row that starts readable and is tightened later has a window in which the key is readable, and that window is the first minutes of a new organization | +| `managed_by` | Deleting the row, renaming it, or re-pointing it | The same window applies, and a row deleted before the marker lands is gone with no repair path | + +The two compose. A managed write-only row redacts and refuses.