Skip to content
View as .md

Secrets

Encrypted credentials stored in Supabase Vault, scoped to a house. They exist independently of environments — you can store a GITHUB_TOKEN the moment you join a house, before any environment is configured. At dispatch, environments declare which secrets they need via bindings; the system resolves bound names against the house store, decrypts via Vault, and injects the values into the sandbox as env vars in the per-turn fish -lc.

secrets {
house_id, name, # UNIQUE(house_id, name) where deleted_at is null
author_id, # provenance only (who created it) — gates nothing
vault_secret_id → vault.secrets # encrypted value lives here, never in Electric
deleted_at # soft delete; the unique index filters this
}

The house is the privacy boundary: any member can resolve any secret in the house, and a name is unique per house — GITHUB_TOKEN means one value, whoever created it. author_id records who created the secret (audit) but no longer gates visibility or uniqueness. Schema source: SecretRowSchema in packages/core/schemas/secret.ts. The secrets table stores only metadata — encrypted values never appear in Electric SQL sync.

Resolution: each bound name resolves to exactly one secret — uniqueness per house means there is no precedence to arbitrate. Missing required binding → dispatch fails with a clear error naming the secret. Missing optional binding → warning surfaced on the run record’s environment_snapshot and in the dispatch response. The resolve_secrets_for_environment Postgres function runs service-role (bypasses RLS) since Vault access is needed; the dispatch endpoint verifies caller identity first. SPRITES_TOKEN is stripped from pi env — pi never sees it. Threads without an environment fall back to house-wide resolution via resolve_secrets_for_scope (returns every secret in the house). Local .env file injection continues to work for unauthenticated/offline usage.

Environments don’t hold secret values — they hold a list of names (secret_bindings: [{name, required?}], required defaults to true). A binding stores only the name; the value resolves against the house store at dispatch, so create order doesn’t matter — a binding can reference a name that doesn’t exist yet. One secret, many environments — rotate once, every environment that binds it gets the new value on the next dispatch.

Terminal window
arbe secret list # names, rotation age
arbe secret view <name> # metadata only; --reveal exists but isn't wired
arbe secret set <name> # upsert via stdin (rotation = same name)
arbe secret delete <name> # soft delete
echo "sk-or-..." | arbe secret set OPENROUTER_API_KEY
echo "ghp-..." | arbe secret set GITHUB_TOKEN

Values are always read from stdin, never argv. set is an upsert — if a secret with that name already exists, it rotates the value instead of creating a duplicate. Names are immutable (there’s no PATCH — a secret has no mutable metadata) — renaming would silently break every binding; delete and recreate instead. Storing is independent of binding — once the secret exists, attach it via arbe env bind-secret <env> <NAME> (add --optional to warn instead of fail at dispatch) or pass --secret NAME / --optional-secret NAME to arbe env create. Sandbox pi defaults to OpenRouter, so the usual binding is OPENROUTER_API_KEY. Remove with arbe env unbind-secret.

API: GET/POST /api/secrets, GET/DELETE /api/secrets/:id, PUT /api/secrets/:id/value (rotate), plus the internal POST /api/secrets/resolve (bearer-auth — the only endpoint that returns decrypted values). POST and PUT return the metadata row; DELETE returns { id } so callers can update local state without a refetch.

Visibility ≠ dispatch. Being able to dispatch a run with an environment does not imply being able to read secret values. arbe env view lists bound secret names + metadata via a join, never plaintext. House admins can delete secrets they don’t own but cannot reveal their values.

Storage. Encryption uses Supabase Vault (pgsodium AEAD with a Supabase-managed key). vault.decrypted_secrets provides on-the-fly decryption inside Postgres — no application-layer crypto needed. Vault labels use the format name/house_id/author_id to avoid collisions. Soft-delete sets deleted_at on the metadata row and hard-deletes the Vault entry (encrypted values don’t need tombstones). A cascade trigger cleans up secrets when a house is soft-deleted.

Code: packages/core/schemas/secret.ts, packages/core/schemas/environment.ts (SecretBindingSchema), apps/www/src/routes/api/secrets/.
See system/environments, system/dispatch, system/auth.