Skip to content

Credentials

Credentials are secrets stored encrypted at rest. The agent injects only the environment variables a worker needs and keeps secrets out of prompts and step state. Infra credentials (GitHub, Grafana, Kubernetes) never reach a coding runtime; a model provider key does, because the worker calls its provider itself.

Encryption is AES-256-GCM with a key derived from the master key. The master key is resolved from the config table first and falls back to STEELFRAME_MASTER_KEY. “Master key not configured” means no key exists anywhere; a failure reading the key is a database error, surfaced as one.

Manage credentials under Config → Credentials or the JSON API:

MethodPathPurpose
GET/api/credentialsList stored credentials (secrets never returned)
GET/api/credentials/catalogWell-known services plus master-key status
PUT/api/credentialsCreate or replace a credential
DELETE/api/credentials/{id}Delete a credential

The catalog exposes well-known services. Steelframe only looks up recognized names; unknown services are rejected.

ServiceEnvironment-scopedShapeUsed for
grafanastaging + productionURL + secretgcx metrics/logs/alerts during investigation
kubectlstaging + productionURL + secretCluster correlation via a written kubeconfig
github_app_private_keyglobalsecret only (PEM)GitHub App installation tokens
discord_webhookglobalURL onlyFailure and investigation notifications
discord_botglobalsecret only@mention Chat and 📥 Intake
cloudflare_aiglobalURL + secretClef decisions + Clairic extraction (account ID + Workers AI token)
memory_s3globalURL + secretSession-memory WAL access key + secret
model_anthropicglobalsecret onlyPi’s anthropic model provider
model_openaiglobalsecret onlyPi’s openai model provider
model_deepseekglobalsecret onlyPi’s deepseek model provider
model_xaiglobalsecret onlyPi’s xai model provider
model_openrouterglobalsecret onlyPi’s openrouter model provider
model_googleglobalsecret onlyPi’s google model provider

Shapes:

  • url_and_secret — target_url and secret are both required.
  • url_only — the webhook URL is the whole credential. Accept it in either field; it is stored as an encrypted secret with kind webhook.
  • secret_only — secret is required. github_app_private_key defaults target_url to https://api.github.com.

kind is one of token, pem, or webhook; it defaults per service.

coding_runtime_model runs any Pi catalog model (<provider>/<id>). Provider auth resolves in order:

  1. the encrypted model_<provider> credential,
  2. host Pi auth — pi auth login --provider <id> on the agent host (the subscription path; Steelframe resolves a token with pi auth print-api-key / print-bearer-token),
  3. the provider’s ambient environment variable.

The resolved value is injected into the worker as the provider’s env var (DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, or ANTHROPIC_OAUTH_TOKEN for an Anthropic subscription) and is never logged or written to a prompt. A catalog model with none of the three fails closed before a worker spawns, with an error naming the service to add.

Environment-scoped services require environment to be exactly staging or production. Global services must omit it. Lookups prefer the exact environment and fall back to a legacy global row.

The kubectl credential is a bearer token plus the API server URL; the agent renders a minimal kubeconfig (token + insecure-skip-tls-verify) in the worker workspace. Mint one ServiceAccount per cluster:

apiVersion: v1
kind: ServiceAccount
metadata:
name: steelframe-readonly
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: steelframe-readonly-view
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: ServiceAccount
name: steelframe-readonly
namespace: kube-system
---
apiVersion: v1
kind: Secret
metadata:
name: steelframe-readonly-token
namespace: kube-system
annotations:
kubernetes.io/service-account.name: steelframe-readonly
type: kubernetes.io/service-account-token

The built-in view role excludes Secrets and Nodes. When metrics-server is installed it labels system:aggregated-metrics-reader with rbac.authorization.k8s.io/aggregate-to-view: "true", so view already includes metrics.k8s.io pods and nodes and kubectl top needs no second binding. Node status is not part of view. Grant it with a role and bind that role to steelframe-readonly:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: steelframe-readonly-nodes
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: steelframe-readonly-nodes
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: steelframe-readonly-nodes
subjects:
- kind: ServiceAccount
name: steelframe-readonly
namespace: kube-system

Then store the token (it is never returned by the API):

Terminal window
TOKEN=$(kubectl -n kube-system get secret steelframe-readonly-token -o jsonpath='{.data.token}' | base64 -d)
curl -sS -X PUT "$AGENT/api/credentials" \
-H "Authorization: Bearer $OPERATOR_TOKEN" -H 'Content-Type: application/json' \
--data "$(jq -n --arg t "$TOKEN" '{service:"kubectl",environment:"production",
target_url:"https://<api-server>:6443",kind:"token",secret:$t}')"

target_url must be reachable from the agent host (public API endpoint, in-cluster address, or a tunnel).

Workers receive env at spawn. Secrets are never put in LLM prompts:

  • Grafana: GRAFANA_SERVER and GRAFANA_TOKEN.
  • Kubernetes: KUBECONFIG pointing at a written kubeconfig.
  • GitHub: a nine-variable bundle (GH_TOKEN, GIT_AUTHOR_*, GIT_COMMITTER_*, and git config headers) so worker gh and git push run under the bot identity.
  • Model provider: the provider’s env var (DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_OAUTH_TOKEN, …) for a catalog model.
  • PUT /api/credentials without a configured master key returns 412 and writes nothing.
  • A failed master-key read is 500, not “not configured”.
  • Upsert replaces in place, which is how you rotate the GitHub App private key.
  • Discord webhook URLs are redacted when returned (…/api/webhooks/••••); the stored secret still holds the full value.
  • Listing never returns secret material — only target_url (redacted where applicable), kind, and service.