Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Agent and people MCP architecture

This document describes the MCP stack that lets agents and people act on Kubernetes clusters through crucible. It covers these parts:

  • the two MCP servers, crucible-kueue and buildit;
  • the shared resource server library, crucible-mcp-auth;
  • the controller as the OAuth issuer;
  • the per-run gateway isolation that makes agent identities trustworthy.

The audience is engineers who maintain or extend this code, and reviewers of changes to it. The text follows ASD-STE100 Simplified Technical English. Code names appear exactly as in the source, in backticks. Code references use path:line from the repository root.

Note: Every part of this stack is off unless you configure it. Section 9 lists what stays inert when a value is absent.

Glossary

Each term has one sense in this document.

TermDefinition
access checkOne SubjectAccessReview that a resource server asks the API server before it acts. The type is AccessCheck (crucible-mcp-auth/src/access.rs).
agent tokenA token that the issuer gives to a supervisor for one sandbox of one run.
allowlist profileThe set of manifest fields that crucible-kueue accepts: strict, tenant, or a TOML file.
approvalThe record that a person approved one MCP client for one resource. It is one row in people_grants, names its client_id, and owns a chain of refresh tokens.
assertionThe JWT-SVID that a supervisor sends to the token endpoint as client_assertion.
audienceThe MCP resource URL that a token is for. The aud claim holds it.
authorization codeA single-use secret that the issuer gives to an MCP client after consent. It lives 60 seconds.
bind recordOne row in run_sandboxes. It connects a sandbox id to the run and the loop pod that created the sandbox.
builder podA pod that buildit creates in the buildit namespace ([buildit] namespace) to build and run one image.
builditThe vendored image build MCP server. It runs as a child process in each loop pod.
client IDThe OAuth client_id of an MCP client. It has one of three forms: a registered name, an https:// metadata document URL, or a dyn- id that the issuer gave. The type is ClientId (crucible-controller/src/agent_auth/people/clients.rs:24).
client registryThe list of registered clients: the built-in claude-code, pi and codex, or the clients in CONTROLLER_PEOPLE_CLIENTS_FILE.
cluster tokenA Kubernetes bearer token of a person, for example the output of oc whoami -t.
consentThe page and API call where a person approves or denies an MCP client.
controllerThe crucible-controller process on the hub. It dispatches runs and is the issuer.
crucible-kueueThe MCP server that plans, submits and controls Kueue workloads on one cluster.
deploy profileThe TOML file that CONTROLLER_DEPLOY_PROFILE names. It describes clusters, namespaces and servers.
gatewayThe OpenShell gateway process that creates sandboxes and supervisors for one run.
gateway idThe OpenShell id of one gateway. It is the loop pod name, hashed when longer than 63 characters. The label openshell.ai/gateway-id carries it.
hubThe cluster where the controller runs. Its dispatch name is hub.
identity ruleThe rule in the deploy profile that tells how the controller learns kube_user on one cluster: link or { claim, prefix }.
isolated profileA deploy profile with a sandbox_namespace, which means the kubernetes driver. Each run then gets a gateway pod of its own (crucible/src/deploy/render/gateway.rs:55).
issuerThe OAuth authorization server inside the controller. CONTROLLER_AGENT_ISSUER is its identifier.
issuer keyThe ES256 private key that signs every token.
JWKSA JSON Web Key Set: the public keys that verify signatures.
kube_userThe Kubernetes username that a token acts as. Every resource server that does access checks reviews this user.
launcherThe person that a run acts for. The login comes from runs.dispatched_by.
linkA record in kube_identity_links that a cluster's TokenReview proved: this person is this username on this cluster.
loop namespaceThe namespace of loop pods ([cluster] loop_namespace).
loop podThe pod that runs the engine for one run. The controller calls it the run pod. Pack code runs here.
MCP clientA program that a person uses to call MCP servers, for example Claude Code, pi or Codex.
metadata document clientA client whose client_id is the https:// URL of its OAuth Client ID Metadata Document (CIMD), on a host in CONTROLLER_PEOPLE_CIMD_HOSTS.
overrideA hub username that a platform administrator sets for a person. It wins over the identity rule on the hub only.
people tokenA token that the issuer gives to an MCP client for one person.
plan hashThe SHA-256 of the normalized manifest. workload_submit requires it.
pod tokenThe per-run bearer (CRUCIBLE_POD_TOKEN) that a loop pod uses to talk to the controller.
push-only clusterA spoke for which the hub holds a push token and no API client.
refresh tokenA long-lived secret that an MCP client exchanges for a new token. Each use spends it.
registered clientA client in the client registry. The operator vouches for its name and its redirect URIs.
resource serverAn MCP server that verifies tokens with crucible-mcp-auth.
runOne dispatch of a playbook launch or an issue. One loop pod executes it.
sandboxAn OpenShell sandbox: an isolated pod where the agent runs.
sandbox namespaceThe namespace of sandboxes, supervisors and gateway pods ([cluster] sandbox_namespace).
scopeAn OAuth permission name, for example kueue.submit. The scope claim holds a space-separated list.
self-registered clientA client that registered itself at POST /oauth/register (RFC 7591). It has a dyn- client ID and only loopback redirect URIs. Nobody vouches for its name.
SPIFFE IDA URI that names a workload, for example spiffe://<trust domain>/openshell/gateway/<gateway id>/sandbox/<sandbox id>.
SPIREThe SPIFFE runtime that gives JWT-SVIDs to workloads on one cluster.
spokeA cluster other than the hub that the controller dispatches runs to. This document uses spoke-a and spoke-b.
supervisorThe OpenShell process in the pod of a sandbox. It controls egress and injects credentials.
tokenAn OAuth access token that the issuer signs. Other secrets are always named in full: refresh token, pod token, cluster token.
token grantThe OpenShell provider credential type that tells a supervisor to get a token from a token endpoint with its JWT-SVID.
tool policyOptional CEL validate and mutate rules over tool arguments (crucible-mcp-auth/src/policy.rs).
turn podA short pod for a grounded-rank or scope turn. Under an isolated profile it also gets a gateway pod.
workload namespaceA namespace that [kueue] workload_namespaces names. The first is the default namespace of agent workloads.

1. Overview

1.1 The problem

Agents in sandboxes must start cluster work: Jobs and RayJobs that train models, Deployments that serve models, and image builds. A Kubernetes credential must not enter the sandbox. Pack code in the loop pod must not choose who the agent is, or which namespace and queue it uses. People who use MCP clients on their laptops need the same tools, with their own Kubernetes permissions.

1.2 The shape

The controller is the one OAuth issuer for agents and for people. A sandbox supervisor proves its identity with a JWT-SVID from the SPIRE of its cluster. The controller finds the run that the sandbox belongs to and signs a short token. The token names the launcher (sub), the sandbox (act.sub), the run and the launcher's Kubernetes username (kube_user). A person gets a token through the authorization code flow with PKCE, after upstream SSO and consent. The MCP client is a public client: a registered client, a metadata document client, or a self-registered client.

The MCP servers verify each token against the issuer JWKS. Before each action, they ask the API server a SubjectAccessReview as kube_user. So the person's own RBAC is the ceiling. Each run's gateway runs in a pod of its own in the sandbox namespace. So pack code cannot create a supervisor with the SPIFFE ID of another run.

examples/optimize-loop is a worked playbook on these servers: the agent builds with buildit, benchmarks as Kueue Jobs through crucible-kueue and iterates, and a deterministic verifier checks its report against crucible-kueue's replies and the push registry (see the example).

1.3 Components and trust boundaries

flowchart LR
    subgraph Laptop["Person's machine"]
        MC["MCP client<br/>registered, metadata document<br/>or self-registered"]
        BR["Browser"]
    end
    SSO["Upstream SSO<br/>OIDC provider"]
    CIMD["Client metadata host<br/>(allowlisted only)"]
    subgraph HUB["hub cluster"]
        subgraph CTLNS["controller namespace"]
            CTL["controller<br/>issuer, /oauth/token,<br/>/.well-known/jwks.json"]
            PG[("Postgres")]
            KEY["issuer key Secret"]
        end
    end
    subgraph SPOKE["spoke-a cluster"]
        API["API server<br/>Service proxy,<br/>TokenReview, SAR"]
        subgraph SPIRENS["SPIRE namespace"]
            SPIRE["SPIRE server and agents"]
            OIDC["SPIRE OIDC discovery<br/>provider /keys"]
        end
        subgraph LOOPNS["loop namespace (pack code)"]
            LOOP["loop pod<br/>engine, broker,<br/>buildit :8848"]
        end
        subgraph BUILDNS["buildit namespace"]
            BUILDER["builder pods"]
        end
        subgraph SBXNS["sandbox namespace (gateway SA only)"]
            GW["gateway pod<br/>crucible openshell-gateway"]
            SUP["supervisor pod<br/>sandbox"]
        end
        subgraph KNS["crucible-kueue namespace"]
            KQ["crucible-kueue :8850"]
        end
        subgraph WNS["workload namespace"]
            WL["Jobs, RayJobs,<br/>Deployments, ..."]
        end
    end
    MC -- "people token" --> KQ
    MC -- "register, authorize, token" --> CTL
    BR -- "login" --> SSO
    BR -- "consent" --> CTL
    CTL --- PG
    CTL --- KEY
    CTL -- "GET metadata document" --> CIMD
    CTL -- "kube client: Sandbox reads,<br/>gateway objects, /keys proxy" --> API
    API --> OIDC
    LOOP -- "mTLS" --> GW
    GW -- "creates" --> SUP
    SUP -- "JWT-SVID" --- SPIRE
    SUP -- "assertion" --> CTL
    SUP -- "agent token" --> KQ
    SUP -- "agent token" --> LOOP
    LOOP -- "bind sandbox,<br/>pod token" --> CTL
    LOOP --> BUILDER
    KQ -- "SAR as kube_user,<br/>then acts as own SA" --> API
    KQ --> WL

The diagram shows one spoke. The hub can also host runs; it then has the same namespaces. The trust boundaries are the namespaces:

  • The controller namespace holds the issuer key. The controller refuses to start when this namespace is also the loop, rig, sandbox, crucible-kueue or workload namespace (crucible-controller/src/agent_auth/placement.rs:74).
  • The loop namespace runs pack code. The loop service account has no verbs in the sandbox namespace under an isolated profile.
  • The sandbox namespace allows pod creation only to crucible-openshell-gateway. A SPIFFE ID there is as trustworthy as that rule.
  • The crucible-kueue namespace holds the server service account. Workload verbs of that account apply only in the workload namespace.
  • The person's machine holds only a token and a refresh token, never a cluster credential.
  • A client metadata host is outside every boundary. The controller fetches a JSON document from it only when its host is in CONTROLLER_PEOPLE_CIMD_HOSTS. The document can name a client and its redirect URIs, and nothing more.

2. Trust model and identities

2.1 Principals

PrincipalProves itself withShown in tokens as
PersonAn upstream SSO login to the controller. The browser session holds it.sub (the login) and kube_user
RunThe pod token of its loop pod, at the bind endpoint onlyrun_id (the loop pod name)
Sandbox supervisorA JWT-SVID from the SPIRE of its clusteract.sub (the SPIFFE ID), sandbox, workdir

A run acts for its launcher. The launcher is runs.dispatched_by, which must be user:<login>. The controller stamps it when it creates the run row (crucible-controller/src/runs/store.rs:334). The value is the person who last asked for this dispatch: an unpark, a redispatch, a scope, or a paired GitHub approval. Without such a request, the value is the owner of the run. A run that has no person behind it gets no token (GrantError::NoLauncher).

2.2 Token types and claims

All tokens are compact JWS, algorithm ES256, with kid in the header. The issuer signs both kinds with the same key and the same iss.

ClaimAgent tokenPeople tokenContent
issyesyesCONTROLLER_AGENT_ISSUER, an http(s) origin without a path
sublauncher loginperson loginOn whose behalf the call is made
audyesyesOne MCP resource URL. The issuer writes a string; a resource server also accepts an array and passes the token when any entry names one of its --resource URLs
iat, nbf, expyesyesIssue time, start, expiry (Unix seconds)
jtiyesyes16 random bytes, hex
act{"sub": "<SPIFFE ID>"}absentThe presenter: the sandbox supervisor
azpabsentthe client IDThe OAuth client, for example claude-code
scopeyesyesSpace-separated scopes
run_idloop pod nameabsentThe run. It is the pod name because a run id can be longer than a label value
sandboxwhen knownabsentThe sandbox name from its Sandbox resource
workdirwhen boundabsentThe workspace path inside the sandbox
kube_userwhen knownalwaysThe Kubernetes username on the cluster of the audience
kube_clusterthe run's clusteryesThe cluster the token acts on. A server started with --cluster refuses a token naming another, so a token for one cluster's URL does not work at the same URL on another
namespacewhen the audience names namespacesabsentThe audience's first namespace, the default for its server
namespaceswhen the audience names namespacesabsentEvery namespace of the audience. A server that reads it confines the caller to these namespaces, within those it serves

The agent token is built in crucible-controller/src/agent_auth/issuer.rs:820. The people token is built in crucible-controller/src/agent_auth/people.rs:299.

Note: Claims name identity only. There is no role and no queue list in a token. Each resource server decides what the caller may do. crucible-kueue ignores role and queues claims if a token carries them.

2.3 Issuer keys and JWKS

The issuer key is a PKCS#8 P-256 key. The kid is the RFC 7638 thumbprint of its public half, so a new key gets a new kid with no configuration (issuer.rs:152). The JWKS at /.well-known/jwks.json publishes the current key and, when configured, the previous key (issuer.rs:820). The previous key verifies only. It signs nothing.

To rotate the issuer key, do these steps:

  1. Move the current key file to CONTROLLER_AGENT_PREVIOUS_SIGNING_KEY_FILE.
  2. Mount a new key at CONTROLLER_AGENT_SIGNING_KEY_FILE.
  3. Restart every controller replica.
  4. Wait for one token lifetime. The default is 900 seconds; people tokens can live up to 3600.
  5. Remove the previous key variable and restart again.

Warning: All replicas must mount the same keys. A replica with another key signs tokens that the other replicas do not publish.

Resource servers cache the JWKS for 600 seconds. A token with an unknown kid causes a refetch, but not more than one fetch each 30 seconds (crucible-mcp-auth/src/jwks.rs:152). A failed fetch keeps the cached keys. The client refuses a key set larger than 256 KiB and follows no redirects.

2.4 Lifetimes

ItemLifetimeWhere set
Agent token900 to 1800 seconds, default 900CONTROLLER_AGENT_TOKEN_TTL_SECS
People token1 to 3600 seconds, default 900CONTROLLER_PEOPLE_ACCESS_TTL_SECS
Authorization code60 seconds, single useCODE_TTL_SECS
Refresh token chaindies 14 days after the last refresh, or 90 days after approvalREFRESH_IDLE_SECS, REFRESH_ABSOLUTE_SECS
Self-registered clientdies 30 days after its last successful token exchange (a code or a refresh), with its approvals; naming it to /oauth/authorize does not countDcrPolicy::idle
Registration rate window20 registrations for each source address in each hourDcrPolicy::per_source, DcrPolicy::window
Metadata document cachethe Cache-Control max-age minus Age, 300 seconds without max-age, at most 3600 secondsCimdLimits
JWT-SVID assertionat most 900 seconds from iat to expMAX_SVID_LIFETIME_SECS
Cluster link flow10 minutes from start to callbackFLOW_TTL
Cluster OAuth token for a link300 seconds by default, then dropped after one review[cluster_link] access_token_max_age_secs
Gateway certificatesrun deadline in days, rounded up, plus 1 day; 30 days for a run without a deadlinevalidity_days
JWKS cache600 seconds, refetch at most each 30 secondsJwks::from_source
Allowed SAR decision cache30 seconds; denials are not cachedALLOW_TTL
OpenShift group cache15 seconds, so a removed group stops allowing within 45 seconds together with the allow cacheGROUPS_TTL

2.5 What each component verifies

The controller token endpoint verifies the assertion. Section 3 gives the full list.

Every resource server verifies a token through ResourceServer::validate (crucible-mcp-auth/src/server.rs:201) and Verifier::verify (crucible-mcp-auth/src/verify.rs:65):

  1. The header algorithm is asymmetric (RS, PS, ES or EdDSA). HS* and none never verify.
  2. The header has a kid, and the JWKS of the issuer has that key. Under an issuer trusted for runs (the controller), the header typ is at+jwt (RFC 9068 §2.1), for a run's token and a person's alike. The controller types every access token it mints, so an ID token, an assertion or any other JWT under its iss and key is refused. An issuer trusted only for people, such as an OIDC realm, is not held to it.
  3. The algorithm of the key, when the JWK declares one, is the header algorithm.
  4. The signature is correct.
  5. iss is exactly a trusted issuer string. There is no URL normalization.
  6. aud names at least one configured resource URL.
  7. exp and nbf hold, with 30 seconds of leeway.
  8. exp, iss, aud and sub are present.
  9. The token has every scope in required_scopes (kueue.read or buildit.read).

Note: the controller started typing its access tokens and the servers started requiring it in the same change. A live hub and its spokes need the controller and crucible-kueue/buildit rolled together: servers built with the check refuse an older controller's untyped tokens. An older server ignores typ, so rolling the controller first is safe.

The configuration of each trusted issuer says whom its tokens stand for: runs or people (Callers). When one issuer is trusted for both, a token with act is a run token and a token without act is a people token. The token cannot choose its kind in any other way.

Then each server applies its own rules:

Checkcrucible-kueuebuildit
Caller kindRuns and people. People only with an access reviewer (--kube-authz)Runs only
kube_userRequired for both kindsNot read
run_idRequired for runs. A people token with run_id is refusedMust equal --run-id
sandboxNot readRequired, a label value
workdirNot readOptional absolute normal path
Per-call scopekueue.read or kueue.submit per tool (crucible-kueue/src/access.rs:52)buildit.read or buildit.build per tool (buildit/src/mcp.rs:1443)
Access checksSubjectAccessReview per callNone
Unlisted tool or methodRefusedRefused

2.6 Why kube_user RBAC is the ceiling

crucible-kueue acts with its own service account. That account can create workloads in the workload namespace. Before each call, the guard turns the call into access checks. For each check it asks the API server a SubjectAccessReview as kube_user, with the groups of that user (crucible-mcp-auth/src/kube.rs:209). The server does the call only when every review allows it. So:

  • A token can do at most what its kube_user can do with kubectl. The token scopes, the tool policy, the allowlist profile and the server account reach can only reduce that.
  • An agent token acts as its launcher. A run cannot do more than the person who launched it.
  • The issuer and the reviewer both refuse a system: username (crucible-controller/src/identity/kube_identity.rs:27, crucible-mcp-auth/src/kube.rs:67). So no token can act as a node, a service account or the cluster. Groups from a token claim also lose every system: entry.
  • The server creates pods with its own identity. So the caller must be able to get each Secret and PersistentVolumeClaim that a manifest names (crucible-kueue/src/access.rs:149).
  • A new RoleBinding applies on the next call, because denials are not cached. A removed permission can still allow calls for up to 30 seconds, because allowed decisions are cached.
  • workload_status and tasks/get read the workload's Kueue Workloads, its pods and their events with the server's account, and show each only when kube_user may get and list it in the namespace. A part the caller may not read is left out, and the reply's omitted names it and why (crucible-kueue/src/access.rs, StatusPart).
  • Any caller who may use a queue can discover its hardware. This is a guarantee, and a deliberate exception to the ceiling: hardware_describe and queue_describe check only get or the submit verb (use) on the LocalQueue, and hardware_describe reads the nodes behind the queue's flavors with the server's account, so an agent can pick a flavor and a GPU product without RBAC on nodes. It returns each flavor's node labels and taints, and for the nodes those select, how many there are and how many are schedulable per GPU product, and their accelerator capacity. Node names are not returned. Tests hold the guarantee (crucible-kueue/tests/http_auth.rs, and cluster_e2e.rs against a cluster).

The issuer never decides what a person may do. It decides only who the person is on each cluster (section 5).

3. Agent token flow

3.1 From dispatch to the first MCP call

sequenceDiagram
    autonumber
    participant C as controller (hub)
    participant A as API server (spoke-a)
    participant L as loop pod (engine)
    participant G as gateway pod
    participant S as supervisor
    participant P as SPIRE agent
    participant K as crucible-kueue
    C->>A: create gateway Service in sandbox namespace
    A-->>C: ClusterIP
    Note over C: mint run CA, server cert for the ClusterIP,<br/>loop and supervisor client certs, drop CA key
    Note over C: run Secret gets loop client TLS, gateway URL,<br/>CRUCIBLE_AGENT_TOKEN_ENDPOINT, CRUCIBLE_AGENT_SVID_AUDIENCE,<br/>CRUCIBLE_AGENT_SERVERS, run_agent_tools row written
    C->>A: loop Service, NetworkPolicy, gateway pod, TLS Secrets owned by the gateway pod
    C->>A: create loop pod
    L->>G: health check over mTLS, register the CLI
    L->>G: import token-grant provider profiles (crucible-kueue, buildit)
    L->>G: CreateSandbox with those providers
    G->>A: create Sandbox and supervisor pod,<br/>label openshell.ai/gateway-id, annotation openshell.ai/sandbox-id
    G-->>L: sandbox id
    L->>C: POST /api/sandbox-bindings/runs/{run}<br/>Bearer pod token, {sandbox_id, workdir}
    C->>A: list Sandbox by sandbox-id and gateway-id labels
    A-->>C: the Sandbox and its name
    Note over C: write bind record, owner-reference the Sandbox to the gateway pod
    C-->>L: 204
    S->>P: fetch JWT-SVID, audience = issuer
    P-->>S: JWT-SVID
    S->>C: POST /oauth/token client_credentials,<br/>client_assertion = JWT-SVID, audience = kueue URL, scope
    C->>A: GET SPIRE /keys through the Service proxy (cached)
    A-->>C: SPIRE JWKS
    Note over C: verify the assertion, read gateway id and sandbox id,<br/>find the bind record, check cluster and gateway,<br/>resolve the launcher and kube_user
    C-->>S: access_token, expires_in, scope
    Note over S: inject Authorization on egress<br/>to the kueue host and port only
    S->>K: MCP request with Bearer token
    K->>A: SubjectAccessReview as kube_user
    K->>A: act with the server service account

The steps are these:

  1. Dispatch reserves the gateway. The Service comes first because its ClusterIP goes into the server certificate (crucible-controller/src/runs/workpod/gateway.rs:184).
  2. The controller folds the loop pod client material and the agent server access into the run Secret before it renders the loop pod (crucible-controller/src/runs/workpod/run.rs:813, access in crucible-controller/src/runs/workpod/agent_servers.rs). A server is granted only where the issuer can mint its tokens: it trusts the run's cluster's SPIRE, discovered or named in CONTROLLER_AGENT_SPIRE (crucible-controller/src/runs/workpod/agent_servers.rs, granted). The loop pod gets them as one CRUCIBLE_AGENT_SERVERS list. The run records the servers that it got, with the scopes of the audience.
  3. The gateway starts before the loop pod, so it boots while the loop pod schedules (gateway.rs:256).
  4. The engine reads CRUCIBLE_OPENSHELL_GATEWAY_URL and talks to the remote gateway over mTLS (crucible/src/openshell/gateway.rs:422-437). It creates one token-grant provider, one egress entry and one .mcp.json entry for each server in CRUCIBLE_AGENT_SERVERS the same way, after starting a server whose transport is the loop pod (crucible/src/openshell/agent_servers.rs). A provider holds no secret and gives the sandbox no placeholder (crucible/src/openshell/provider.rs:368).
  5. After CreateSandbox, the engine binds the sandbox (crucible/src/openshell/run.rs:469). It binds only when the loop pod carries CRUCIBLE_AGENT_TOKEN_ENDPOINT. A failed bind does not fail the turn. The sandbox then gets no token.
  6. The bind route trusts only the pod token of that run (crucible-controller/src/agent_auth/bindings.rs:298). It reads the sandbox name from the cluster, never from the request (crucible-controller/src/agent_auth/directory.rs:60).
  7. The supervisor asks SPIRE for a JWT-SVID with the audience in token_grant.jwt_svid_audience. That value is the issuer identifier.
  8. The token request has exactly the fields that OpenShell sends: grant_type, client_assertion_type, client_assertion, audience, scope. Any other field, for example client_id or resource, is refused (crucible-controller/src/agent_auth/mod.rs:149).
  9. The supervisor injects the token as a bearer header only on egress to the host and port of the provider endpoint, from a binary the provider's profile lists. The profile lists the same binaries as the sandbox policy: OpenShell injects a grant only when the profile's own rule admits the request, and with binary identity required, a rule admits only the binaries it names. The agent never sees the token.

3.2 What the token endpoint checks

issue (crucible-controller/src/agent_auth/mod.rs:314) does these checks in this order:

  1. The issuer mints agent tokens: the profile isolates gateways and describes a server, or CONTROLLER_AGENT_SPIRE names a cluster.
  2. The deploy profile is isolated and names a sandbox namespace (crucible-controller/src/agent_auth/placement.rs:48).
  3. grant_type is client_credentials.
  4. client_assertion_type is urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
  5. The assertion iss is the SVID issuer of exactly one configured cluster. That cluster's SPIRE keys verify the assertion (crucible-controller/src/agent_auth/issuer.rs:701).
  6. The assertion aud is exactly one value, the issuer identifier.
  7. The assertion has a numeric iat, not more than 30 seconds in the future.
  8. exp minus iat is not more than 900 seconds.
  9. sub is <gateway prefix><gateway id>/sandbox/<sandbox id> for the prefix of that cluster.
  10. A bind record exists for the sandbox id, and its loop pod is in the running state.
  11. The cluster of the run is the cluster whose SPIRE signed the assertion.
  12. The requested audience is the URL of a server the deploy profile describes on the run's cluster (Audience::of).
  13. The gateway id in the SPIFFE ID is the gateway id of the bound loop pod.
  14. The run has a launcher (dispatched_by is user:<login>).
  15. Each requested scope is in the audience entry. A request with no scope gets all the scopes of the entry.
  16. When the audience requires a kube_user (the profile describes its server as one that authorizes as kube_user, as [kueue] does), it is known for the launcher on the run's cluster. For other audiences, the token omits an unknown kube_user, and the controller logs a warning. Every audience is described: at startup the controller refuses one for which the profile describes no server at that URL on that cluster, comparing URLs once parsed (Audience::described_by, crucible-controller/src/agent_auth/issuer.rs).

Note: Pack code holds the run CA's client certificate, so it can make its gateway create sandboxes and request a token for any audience on its run's cluster, with that audience's scopes. The audience allowlist, per cluster, is the boundary: list only the servers a run on that cluster may use, with the scopes it may hold.

The controller reads SPIRE keys through the API server Service proxy of each cluster, with the same client it dispatches with (crucible-controller/src/agent_auth/spire.rs:21). The path is /api/v1/namespaces/<ns>/services/<scheme>:<service>:<port>/proxy/keys (issuer.rs:189). The scheme is https unless the entry starts with http://.

3.3 Refusals

Every refusal returns only the OAuth error code and a fixed description. The detail goes to the controller log as agent token refused with reason and the user_agent.

CaseErrorHTTP
No agent clusters (a people-only issuer)unsupported_grant_type400
Profile not isolated, or no sandbox namespaceunauthorized_client400
Malformed form, repeated field, unknown fieldinvalid_request400
Wrong assertion type, unknown SPIRE iss, bad signature, wrong aud, no iat, future iatinvalid_client401
Assertion lifetime over 900 secondsinvalid_client401
SPIFFE ID not under the gateway prefix of its clusterinvalid_client401
SPIRE keys could not be fetchedserver_error500
No audience, or an audience not in the allowlistinvalid_target400
Sandbox never bound (unbound sandbox)invalid_grant400
Bound loop pod not in runninginvalid_grant400
SVID from the SPIRE of another cluster than the run (wrong cluster)invalid_grant400
SVID gateway id is not the gateway of the bound run (the bind record of another run)invalid_grant400
Run dispatched for no personinvalid_grant400
Scope not listed for the audienceinvalid_scope400
kueue.* scope and no kube_user for the launcherinvalid_grant400

The bind route has its own refusals (bindings.rs:320):

CaseHTTP
No pod token, or a token that does not verify401
A pod token of another run403
Unknown run, or a run with no work-pod row404
Loop pod not in running409
Profile names no sandbox namespace409
No Sandbox with that id that this run's gateway created404
The cluster could not be asked503
Two Sandbox resources with that id, or one with no name409
The sandbox id is bound to another loop pod, or its name is bound by another live pod on the same cluster409

Note: Pack code holds the gateway client certificate of its own run. It can create sandboxes for its own run with the driver_config fields that the gateway accepts. Those sandboxes carry the gateway id of its own run, so the issuer refuses them for any other run.

4. People token flow

4.1 MCP clients

People sign in with public OAuth clients. A public client has no secret, so PKCE S256 is required. Each code, approval and refresh token is bound to the client ID that it was issued to. The people token names that client in azp.

4.1.1 Client ID forms

ClientId::parse (crucible-controller/src/agent_auth/people/clients.rs:133) puts each client_id in one of three forms. The form decides who vouches for the client.

FormShapeKindWho vouchesResolved by
Registered1 to 64 of A-Z a-z 0-9 . _ -, not starting with dyn- (clients.rs:98)registeredThe operatorThe client registry
MetadataA canonical https:// URL with a host name and a path, and no credentials, query or fragment (clients.rs:64)metadataThe host that publishes the documentA fetch of the document (section 4.1.3)
Dynamicdyn- and 16 to 64 base64url characters (clients.rs:114)dynamicNobodyA row in people_dynamic_clients (section 4.1.4)

Clients (crucible-controller/src/agent_auth/people/registry.rs:27) holds the registry, the metadata document resolver and the registrations. It has two entry points:

  • resolve (registry.rs:66) serves /oauth/authorize. It returns the client with its name and redirect rules. For a metadata document client, it fetches the document.
  • admits (registry.rs:87) serves /oauth/token. It fetches nothing. It refuses a registered client that the registry no longer lists, a metadata document client whose host is no longer allowlisted, and a registration that is no longer live. The grant that the request redeems is already bound to its client ID.
flowchart TD
    REQ["client_id from the request"] --> P{"ClientId::parse"}
    P -- "fails" --> BAD["refuse: error page<br/>or invalid_client"]
    P -- "https:// URL" --> MD{"CONTROLLER_PEOPLE_CIMD_HOSTS<br/>set and lists the host?"}
    MD -- no --> BAD
    MD -- yes --> FETCH["authorize: fetch or cache hit<br/>token: host check only"]
    FETCH --> DOC{"document valid?"}
    DOC -- no --> BAD
    DOC -- yes --> OK["Client: id, name, redirect rules"]
    P -- "dyn- id" --> DYN{"people_dynamic_clients row,<br/>used in the last 30 days?"}
    DYN -- no --> BAD
    DYN -- yes --> STAMP["stamp last_used_at"] --> OK
    P -- "registered name" --> REG{"in the client registry?"}
    REG -- no --> BAD
    REG -- yes --> OK

4.1.2 The client registry

Without configuration, the registry has three clients (clients.rs:448):

client_idNameRedirect URIs
claude-codeClaude Codehttp://localhost:<any port>/callback
pipihttp://127.0.0.1:<any port>/callback
codexCodexhttp://127.0.0.1:<any port>/callback and http://127.0.0.1:<any port>/callback/<server id>

CONTROLLER_PEOPLE_CLIENTS_FILE names a TOML file of [[clients]] tables. Each table has client_id, name, redirect_uris and an optional resources list. The file replaces the built-in list, so list the built-in clients too if you want to keep them (clients.rs:479). The controller does not start when the file is malformed, when it lists no client or one client twice, or when a resources entry is not a people resource (crucible-controller/src/agent_auth/people.rs:172). Without resources, a client can get tokens for every audience.

[[clients]]
client_id = "lab-cli"
name = "Lab CLI"
redirect_uris = ["http://127.0.0.1/oauth/done"]
resources = ["https://crucible-kueue.apps.spoke-a.example.com/mcp"]

When you remove a client, its next refresh gets invalid_client. When you remove a resource from its resources, its next refresh for that resource revokes the approval.

Redirect matching follows RFC 8252 §7.3 (RedirectRule, clients.rs:237):

  • A registered http URI on localhost, 127.0.0.1 or [::1] matches any explicit port on the same host, with the same path. It matches no query, no fragment and no credentials.
  • localhost, 127.0.0.1 and [::1] are three different hosts. A client registered on localhost cannot use 127.0.0.1.
  • A registered path that ends in /* also matches one more segment of 1 to 64 base64url characters. Codex adds such a server id to the callback path. Only the registry can use /*.
  • Any other registered URI must be https. It matches only character for character.
  • The requested URI must already be in canonical form (Client::redirect, clients.rs:348).

4.1.3 Client ID Metadata Documents

CONTROLLER_PEOPLE_CIMD_HOSTS is a comma-separated list of host names. When it is set, a client_id that is an https:// URL on one of those hosts is resolved as an OAuth Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document). When it is unset, every URL client_id is refused (ClientError::MetadataOff).

At /oauth/authorize, Cimd::client (crucible-controller/src/agent_auth/people/cimd.rs:370) returns a cached document or fetches it, and only for a signed-in browser: a request naming a metadata document without a session goes to /auth/login first, so nobody can make the controller fetch without signing in. The fetch (cimd.rs:390) has these rules:

  • The host must be on the allowlist (cimd.rs:362).
  • The resolver gives only public addresses. It drops loopback, private, link-local and other RFC 6890 special-use addresses (cimd.rs:127, cimd.rs:170). With HTTPS_PROXY set, the proxy resolves the names, so this check moves to the proxy.
  • The client uses only https and follows no redirects (cimd.rs:338).
  • The answer must be 200, JSON, at most 5 KiB, and arrive within 5 seconds.

The document must describe a public client of its own URL (client_of, cimd.rs:200):

  • client_id is the URL exactly.
  • client_name is 1 to 100 printable characters.
  • redirect_uris has at least one entry. The entries follow the registry rules, but /* is not accepted.
  • token_endpoint_auth_method is none. There is no client_secret and no client_secret_expires_at.
  • grant_types and response_types, when present, include authorization_code and code.

A valid document is cached for its Cache-Control max-age minus Age. Without max-age it is cached for 300 seconds. The limit is 3600 seconds (freshness, cimd.rs:266). A document with no-store or no-cache is not cached. A refused document is not cached.

The consent page and Settings show the client_name of the document with the host of the URL, so a document cannot look like a registered client. Codex publishes https://chatgpt.com/oauth/codex/client.json. pi publishes https://pi.dev/oauth/client.json.

4.1.4 Self-registered clients

pi 1.x, Codex and Claude Code without a client id register a client before they sign in (RFC 7591). The controller accepts these registrations at POST /oauth/register (crucible-controller/src/agent_auth/people/dynamic.rs:422). Registration is on by default when the deploy profile serves people. CONTROLLER_PEOPLE_DCR=false turns it off.

sequenceDiagram
    autonumber
    participant M as MCP client
    participant C as controller (any replica)
    participant D as Postgres
    M->>C: GET /.well-known/oauth-authorization-server
    C-->>M: registration_endpoint = issuer/oauth/register
    M->>C: POST /oauth/register {client_name, redirect_uris, ...}
    Note over C: check: public client, loopback http redirect URIs,<br/>grant and response types, at most 8 URIs
    Note over C: rate limit: 20 each hour for each source address
    C->>D: INSERT people_dynamic_clients<br/>only while fewer than about 10000 are live
    C-->>M: 201 {client_id: dyn-..., token_endpoint_auth_method: none}
    M->>C: GET /oauth/authorize?client_id=dyn-...
    C->>D: UPDATE last_used_at WHERE client_id AND not idle, RETURNING the client
    Note over C,D: the hourly sweep deletes registrations idle for 30 days<br/>and the approvals of deleted registrations

check (dynamic.rs:194) accepts only native public clients:

  • token_endpoint_auth_method is absent or none. The controller issues no secret, no registration access token and no management endpoint.
  • grant_types includes authorization_code and stays within authorization_code and refresh_token. response_types is code.
  • redirect_uris has 1 to 8 entries, each at most 512 characters. Each entry is http on localhost, 127.0.0.1 or [::1], with no query, fragment or credentials.
  • The controller removes control characters and repeated white space from client_name and keeps at most 100 characters. An empty name becomes Unnamed client.

The client ID is dyn- and 24 random bytes in base64url. The consent page and Settings show the name as " (self-registered)".

Dcr::register (dynamic.rs:287) applies two limits:

  • Each source address can register 20 times in each hour. The limit is in memory, for each replica. Past it, the answer is 429 with temporarily_unavailable.
  • About 10000 registrations used in the last 30 days can exist. Past it, the answer is 503 with temporarily_unavailable. The count and the insert are one statement but not serialized across replicas, so concurrent registrations can go slightly over: the cap is soft.

The source address is the TCP peer. When the peer is in CONTROLLER_PEOPLE_TRUSTED_PROXIES, as the cluster router is, the source is the last X-Forwarded-For hop (source, dynamic.rs:428). Any other peer, an in-cluster caller included, is its own source and cannot pick a bucket.

Dcr::client (dynamic.rs:345) reads a registration and stamps last_used_at in one statement. A registration that is not used for 30 days stops working. The hourly sweep deletes it and the approvals issued to it (Dcr::sweep, dynamic.rs:375).

Registration refusalErrorHTTP
A redirect URI that is not loopback http, or no URI, or more than 8invalid_redirect_uri400
A body that is not JSON, a secret auth method, or other grant or response typesinvalid_client_metadata400
Rate limit of the source addresstemporarily_unavailable429
10000 live registrationstemporarily_unavailable503
People tokens or registration offinvalid_request404

The consent API (crucible-controller/src/identity/api/consent.rs:63) returns these client fields:

FieldContent
client_idThe client ID
client_nameThe registry name, the client_name of the document, or the name that the client registered
client_hostThe host of the metadata document URL; null for other kinds
client_kindregistered, metadata or dynamic
redirect_originThe host:port that the code goes to

GET /api/me/mcp-sign-ins returns the same client_id, client_name, client_host and client_kind for each live approval. The approval stores the name that the person saw on the consent page.

4.2 Discovery, authorization and code exchange

sequenceDiagram
    autonumber
    participant M as MCP client
    participant K as crucible-kueue
    participant C as controller (any replica)
    participant B as browser
    participant U as upstream SSO
    participant O as cluster OAuth server
    participant H as client metadata host
    participant D as Postgres
    M->>K: POST /mcp, no token
    K-->>M: 401, WWW-Authenticate names the resource metadata URL
    M->>K: GET /.well-known/oauth-protected-resource/mcp
    K-->>M: resource, authorization_servers = [issuer], scopes_supported
    M->>C: GET /.well-known/oauth-authorization-server
    C-->>M: authorization_endpoint, token_endpoint, S256, iss parameter,<br/>registration_endpoint, client_id_metadata_document_supported
    opt no client id configured and registration on
        M->>C: POST /oauth/register
        C-->>M: client_id = dyn-...
    end
    M->>B: open /oauth/authorize?client_id, code_challenge, state, resource, redirect_uri=http://127.0.0.1:port/callback
    B->>C: GET /oauth/authorize
    alt metadata document client
        C->>H: GET client_id URL (unless cached)
        H-->>C: client metadata JSON
    else self-registered client
        C->>D: read and stamp people_dynamic_clients row
    end
    Note over C: resolve the client, match redirect_uri
    alt no native browser session
        C-->>B: 303 /auth/login?rd=...
        B->>U: OIDC login with PKCE
        U-->>B: code
        B->>C: /auth/callback, session row in Postgres
        C-->>B: 303 back to /oauth/authorize
    end
    Note over C: validate, park the request on the session
    C-->>B: 303 /consent?id=...
    B->>C: GET /api/oauth/consent?id=...
    C-->>B: client name, kind, host, redirect origin,<br/>resource, scopes, login, identity on the cluster
    opt link cluster with no link
        B->>C: POST /api/me/cluster-identities/{cluster}/link
        C-->>B: authorize_url of the cluster OAuth server
        B->>O: authorize with PKCE, scope user:info
        O-->>B: code
        B->>C: GET /oauth/cluster-link/{cluster}/callback
        C->>O: redeem code
        C->>C: TokenReview on that cluster, write link, drop the cluster token
    end
    B->>C: POST /api/oauth/consent {id, decision: approve}
    C->>D: insert people_codes row (SHA-256 of the code), bound to the client ID
    C-->>B: {redirect_url: redirect URI?code, state, iss}
    B->>M: navigate to the redirect URI
    M->>C: POST /oauth/token grant_type=authorization_code, client_id, code, code_verifier, redirect_uri, resource
    Note over C: admit the client ID (no fetch)
    C->>D: delete people_codes row, returning the grant
    Note over C: check client ID, redirect_uri, PKCE S256,<br/>resource, resolve kube_user again
    C->>D: insert people_grants with client_id and client_name,<br/>and the first people_refresh_tokens row
    C-->>M: access_token (azp = client ID), refresh_token, expires_in, scope
    M->>K: POST /mcp, Bearer token

The steps are these:

  1. crucible-kueue answers a request without a token with 401 and a challenge that names its RFC 9728 metadata. The metadata lists the trusted issuers as authorization_servers (crucible-mcp-auth/src/metadata.rs:42).
  2. The MCP client reads the RFC 8414 metadata of the controller (crucible-controller/src/agent_auth/issuer.rs:831, crucible-controller/src/agent_auth/mod.rs:445). People support adds authorization_endpoint, the authorization_code and refresh_token grants, the code response type, S256, the none client authentication, and authorization_response_iss_parameter_supported. Two fields depend on the client configuration:
    • registration_endpoint is <issuer>/oauth/register while registration is on. It is absent with CONTROLLER_PEOPLE_DCR=false.
    • client_id_metadata_document_supported is true while CONTROLLER_PEOPLE_CIMD_HOSTS is set. It is absent otherwise.
  3. /oauth/authorize (crucible-controller/src/agent_auth/people.rs:554) resolves the client (section 4.1) and matches redirect_uri against its redirect rules. state is required, at most 1024 characters. The method must be S256. resource must be a people resource of the deploy profile (DeployProfile::people_resources), and a registered client with resources must list it (validate, people.rs:373).
  4. An unknown client, a refused metadata document, an expired registration or a bad redirect gives an error page. The controller never redirects to a URI that the client did not register. When the client and the redirect are correct, the controller sends every other error to the redirect URI with error, state and iss.
  5. Without a native session, the controller sends the browser to /auth/login. After the login, the browser returns to /oauth/authorize. A session whose group refresh failed counts as no session.
  6. The request waits on the session under a random id. The consent page shows it only to the same browser and the same person. The page names the client and shows the host:port that the code goes to (section 4.1.5). No page may frame it: every SPA response and the authorize page send X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'.
  7. The approval resolves kube_user for the cluster of the audience. An unlinked link cluster answers 409. The controller parks the request on the session again, so the page can link and try again.
  8. The code is 32 random bytes, base64url. Postgres stores only its SHA-256. The stored grant names the client ID and the client name.
  9. The token endpoint admits the client ID without a fetch (registry.rs:87). It marks the code spent under a row lock, so a code works once on any replica (redeem_code in crucible-controller/src/agent_auth/people/grants.rs). A code issued to another client ID is invalid_grant (redeem_code, people.rs). The code is spent all the same. The approval the redemption starts is recorded against the code, and a spent code stays a day past its expiry. Presented again by the client it was issued to, it is invalid_grant and revokes that approval, so the refresh token the first redemption issued stops working (OAuth 2.1 §4.1.3, RFC 9700 §4.5.3); a replay that lands before the approval starts stops it from starting. Presented by another client, it is only unknown, so a code someone saw cannot revoke a person's approval.

Note: Claude Code does not step up after a 403. So a person always gets every scope that the audience lists. Requested scopes outside that list are refused; the OIDC scopes openid, profile, email and offline_access are ignored.

4.3 Refresh with rotation and reuse detection

sequenceDiagram
    autonumber
    participant M as MCP client
    participant C as controller (any replica)
    participant D as Postgres
    M->>C: POST /oauth/token grant_type=refresh_token, refresh_token, client_id
    Note over C: admit the client ID (no fetch),<br/>else invalid_client and nothing is spent
    C->>D: UPDATE people_refresh_tokens SET used_at WHERE key = sha256 AND used_at IS NULL
    alt row updated
        C->>D: read people_grants FOR UPDATE
        Note over C: refuse when revoked
        alt client_id is not the client of the approval
            C->>D: revoke the approval and delete its live token
            C-->>M: invalid_grant
        else same client
            Note over C: refuse when idle over 14 days,<br/>or older than 90 days
            alt another resource or a scope beyond the approval
                C->>D: roll back: the token stays unspent
                C-->>M: invalid_target or invalid_grant
            end
            C->>D: issue the next refresh token
            Note over C: client still allowed for the resource,<br/>read the person again: users row, stored<br/>credential not refused, kube_user on the cluster
            alt person still allowed and kube_user known
                C-->>M: new access_token and new refresh_token
            else refused
                C->>D: revoke the approval
                C-->>M: invalid_grant
            end
        end
    else token already spent
        C->>D: revoke the approval and delete its live token
        C-->>M: invalid_grant
    end

The rotation is one conditional UPDATE, so two replicas cannot both spend one refresh token (grants.rs:223). A spent token stays in the table until its approval goes. When a client presents it again, the controller revokes the whole approval (RFC 9700 §4.14.2). A live token that another client ID presents also revokes the approval (grants.rs:279). A refresh that asks for another resource or a wider scope is refused inside the same transaction, before anything commits (refresh_fits, people.rs:765; grants.rs:309), so the token it presented stays live and the client can refresh with it again.

A refresh also checks the client again. A registered client that the registry no longer lists gets invalid_client. A registered client whose resources no longer lists the resource of the approval loses the approval.

Every refresh reads the person again (crucible-controller/src/identity/people_directory.rs:35):

  • A deleted users row revokes the approval.
  • A stored offline credential that the upstream SSO refused (user_credentials.last_error) revokes the approval.
  • An unlinked cluster username revokes the approval. A new link appears in the next token.

Warning: A refresh does not ask the upstream identity provider. A person deactivated there keeps refreshing until something else refuses them: a stored offline credential the SSO then refuses, the 14-day idle limit, or the 90-day cap on an approval (REFRESH_IDLE_SECS, REFRESH_ABSOLUTE_SECS in crucible-controller/src/agent_auth/people/grants.rs). Without a stored credential (CONTROLLER_CREDENTIAL_KEY unset, or a person who never stored one), nothing else does. To cut a person off sooner, delete their users row, which also drops their stored credential and API keys.

Warning: A client that retries a refresh after a lost response presents a spent token. The controller treats that as reuse and revokes the approval. The person must approve the client again.

A background task deletes expired codes and dead approvals each hour (grants.rs:425). With registration on, the same task deletes registrations idle for 30 days and the approvals of deleted registrations. It starts only when people tokens are configured.

4.4 Several replicas

Any replica can do any step of the flow:

  • Browser sessions, the parked authorization request and a started cluster link live in the Postgres session store.
  • Codes, approvals and refresh tokens live in people_codes, people_grants and people_refresh_tokens. Each method is one statement or one transaction.
  • Every replica signs with the same issuer key and publishes the same JWKS.

Leader election (CONTROLLER_LEASE_NAME) applies only to the daemon. The API, the token endpoint and the bind route answer on every replica.

4.5 Settings and revocation

GET /api/me/mcp-sign-ins lists the live approvals of the caller, with the client of each (crucible-controller/src/identity/api/mcp_sign_ins.rs:57). Settings groups them by client.

  • DELETE /api/me/mcp-sign-ins?client_id=<client ID> revokes the approvals of one client and deletes its unused codes (grants.rs:330). A client_id that does not parse gets 400.
  • DELETE /api/me/mcp-sign-ins revokes all approvals of the caller and deletes their unused codes.

An API key cannot revoke approvals (mcp_sign_ins.rs:113).

5. Per-cluster identity

5.1 Identity rules

The deploy profile gives each cluster one identity rule. The hub rule is [cluster] identity. A spoke rule is [clusters.<name>] identity. A cluster without a rule is link (forge/src/fleet.rs:61).

RuleTOMLThe username is
linkidentity = "link"What the cluster's TokenReview said for a cluster token that the person gave
claimidentity = { claim = "login" }The SSO login of the person
claimidentity = { claim = "email" }The email that the SSO login recorded; one the IdP marks email_verified: false is not recorded, as the Kubernetes OIDC authenticator refuses it
claim with prefixidentity = { claim = "login", prefix = "sso:" }The prefix, then the claim, as the API server's --oidc-username-prefix makes it

Use a claim rule only when the cluster API server trusts the same SSO for the same people. Use link in all other cases.

A person proves a link in one of two ways:

  • Paste a cluster token: PUT /api/me/cluster-identities/{cluster} with {"cluster_token": "..."}. The controller sends a TokenReview to that cluster (crucible-controller/src/identity/kube_links.rs:108).
  • Use the OAuth server of an OpenShift cluster: POST /api/me/cluster-identities/{cluster}/link. The controller is a confidential OAuthClient there. It redeems the code with PKCE for a user:info token and sends that token to a TokenReview (crucible-controller/src/identity/cluster_link.rs:204, cluster_link.rs:237).

Either way the controller stores only the username, the source (token-review or openshift-oauth) and the time. It never stores or logs the cluster token. A token that another cluster minted does not authenticate, so it cannot link this cluster. A link to a system: username is refused.

The OAuth link is offered for a cluster only when all of these are true:

  • [cluster_link] openshift lists the cluster.
  • The controller has a client for the cluster.
  • A file with the cluster's name exists in CONTROLLER_CLUSTER_LINK_SECRETS.

The callback is GET /oauth/cluster-link/{cluster}/callback. It checks the state in constant time, the cluster, the person and the 10-minute limit. It then lands on /settings?linked=<cluster> or ?link_error=<code>. The code is a fixed word, so a crafted URL cannot put text on the page.

5.3 The hub override

A platform administrator can set the hub username of a person: PUT /api/users/{login}/kube-user with {"kube_user": "<name>"}, or null to clear it. An administrator cannot set their own. The event log records it on user:<login>. The override applies on the hub only, and it wins over the hub rule and any hub link (crucible-controller/src/identity/cluster_identity.rs:66).

5.4 Resolution

flowchart TD
    START["resolve(person, cluster)"] --> HUB{"cluster is hub and<br/>users.kube_user_override set?"}
    HUB -- yes --> OV["Known: override"]
    HUB -- no --> RULE{"identity rule of the cluster"}
    RULE -- "claim" --> CL{"claim value present<br/>and a valid username?"}
    CL -- yes --> KR["Known: prefix + claim"]
    CL -- no --> UN["Unavailable: reason"]
    RULE -- "link" --> LK{"kube_identity_links row<br/>for (sub, cluster)?"}
    LK -- yes --> KL["Known: link"]
    LK -- no --> NL["Unlinked"]

The same function decides kube_user for every consumer:

ConsumerPersonClusterWhen
People tokenThe person who approvesThe cluster of the people resourceAt code exchange and at each refresh
Agent tokenThe launcher the bound run names (user:<login> from runs.dispatched_by, read once with the binding), so sub and kube_user name the same personThe cluster of the runAt each token request (for_principal)
Agent tools recordThe same run:<id> principalThe cluster of each tool whose server acts as kube_userAfter the launch commits the run row (attribute, crucible-controller/src/runs/agent_tools.rs)
Settings and consent pagesThe callerEach cluster of the profileOn each read

Issuance and the tools record name who acts as an authz::model::Principal, and for_principal (crucible-controller/src/identity/cluster_identity.rs) resolves it: a user: principal through their sign-in, a run: principal through the person it was dispatched for. A group: or team: principal, or a run dispatched for one, has no username of its own and is refused with a typed error (PrincipalError), which the token endpoint answers with invalid_grant. A team's own identity per cluster would be one more arm of for_principal; the tables would not change.

A launcher that never logged in has no users row, so its kube_user is unavailable. Because the agent token resolves at each request, a new link applies to the next token of a run that is already live.

Note: The links, the override and the settings panels are served only when the controller mints agent tokens, people tokens are configured, or the profile has [cluster_link] (crucible-controller/src/api/mod.rs:672, crucible-controller/src/api/state.rs:306). So an agent-only deployment on a link cluster serves the paste link route. A person can link a username there, and the runs of that person then get it.

6. Per-run gateway isolation

6.1 Why each run has its own gateway

A SPIFFE ID of a sandbox is only as trustworthy as the pods that can carry it. SPIRE derives the ID from pod metadata. If pack code can create a pod in the sandbox namespace, it can claim the SPIFFE ID of any sandbox. When the gateway runs inside the loop pod, the loop service account needs pod rights in the sandbox namespace. Pack code runs as that account.

Profile load refuses a loop_namespace that is the sandbox namespace, but loop pods run where the controller's CONTROLLER_POD_NAMESPACE (hub) or a spoke kubeconfig's context namespace puts them. So dispatch checks the namespace each run's loop pod actually lands in, and refuses a run with an isolated gateway whose loop pod would run in the sandbox namespace, in that cluster's SPIRE namespace, or on the hub in the controller's own (check_loop_namespace).

So under an isolated profile, the controller starts each run's gateway in a pod of its own, as crucible-openshell-gateway. That account is the only identity with sandbox rights in the sandbox namespace. The gateway stamps its gateway id on every supervisor and Sandbox that it creates, from its own configuration. driver_config cannot set labels or annotations. The ClusterSPIFFEID puts the gateway id in the SPIFFE ID:

spiffeIDTemplate: 'spiffe://{{ .TrustDomain }}/openshell/gateway/{{ index .PodMeta.Labels "openshell.ai/gateway-id" }}/sandbox/{{ index .PodMeta.Annotations "openshell.ai/sandbox-id" }}'

The token endpoint then refuses an SVID whose gateway id is not the bound run's gateway id.

A gateway prefix is spiffe://<trust domain>/openshell/gateway/ for a discovered SPIRE. One named in CONTROLLER_AGENT_SPIRE may hold one {tenant} path segment, such as spiffe://<trust domain>/openshell/{tenant}/gateway/. It then matches IDs of the form spiffe://<trust domain>/openshell/<tenant>/gateway/<gateway id>/sandbox/<sandbox id>, where <tenant> is a DNS-1123 label, for a ClusterSPIFFEID that puts a tenant label of the gateway pod before gateway. The tenant is logged with each token (GatewayPrefix::split, crucible-controller/src/agent_auth/issuer.rs). Nothing else reads it yet. The gateway id check is the same with or without a tenant. The default prefix and the rendered template have no tenant segment, and a prefix without one refuses an ID that has one, and the other way round.

6.2 Lifecycle

flowchart TD
    D["dispatch_run"] --> PO{"isolated profile?"}
    PO -- no --> LOCAL["gateway runs inside the loop pod<br/>(GatewayAccess::Local)"]
    PO -- yes --> PUSH{"push-only cluster?"}
    PUSH -- yes --> REF["refuse: PushOnlyCluster"]
    PUSH -- no --> RES["reserve: gateway Service,<br/>ClusterIP, mint run CA and certs"]
    RES --> SEC["loop client TLS and gateway URL<br/>into the run Secret"]
    SEC --> ST["start: loop Service, NetworkPolicy,<br/>gateway pod, TLS Secrets owned by it"]
    ST --> LP["create loop pod"]
    LP -- "create fails" --> TD1["teardown"]
    ST -- "start fails" --> TD1
    LP --> RUN["run executes"]
    RUN --> COL["collection: delete loop pod, then teardown"]
    COL --> TD2["teardown"]
    TICK["discovery tick: sweep_orphan_gateways"] --> CHK{"is the work_pods row of<br/>its loop pod running?"}
    CHK -- no --> TD3["teardown"]
    CHK -- yes --> KEEP["keep"]

The phases are these:

  • reserve creates the gateway Service and mints the TLS material for its ClusterIP (crucible-controller/src/runs/workpod/gateway.rs:184).

  • start creates the loop Service, the NetworkPolicy and the gateway pod (gateway.rs:256). It then creates the two TLS Secrets with an owner reference to the gateway pod.

  • teardown deletes objects in this order (gateway.rs:337):

    1. the Sandbox resources and supervisor pods with the gateway id label;
    2. the loop Service in the loop namespace;
    3. the gateway's own objects.

    The gateway's own objects go last. A teardown that fails halfway leaves the label that the sweep finds.

  • sweep_orphan_gateways runs on the daemon discovery tick (gateway.rs:398). It finds the gateway objects in each sandbox namespace by crucible.dev/role=openshell-gateway. It removes each gateway whose loop pod row is absent or not running. The daemon registers it only under an isolated profile.

A failed launch removes its gateway at once (crucible-controller/src/runs/workpod/run.rs:1015). Collection removes it after it deletes the loop pod (run.rs:1116). Each failure logs a warning and leaves the rest to the sweep.

Turn pods (grounded rank, scope) get a gateway the same way, with certificates valid for the turn deadline (crucible-controller/src/runs/workpod/spec.rs:409). Their loop Service opens only the OTLP collector port. Turn sandboxes are never bound, so they get no agent tokens.

6.3 Objects

NamespaceObjectNameCreated byRemoved by
looploop pod<run pod>controllercollection
looprun Secretfrom secret_name(<run pod>)controllerowner reference to the loop pod
looploop Service<run pod> (hashed if long)controller startteardown
builditbuilder podsper buildbuilditbuildit clean; the pod deadline stops them
sandboxgateway Service<run pod>-gw (hashed if long)controller reserveteardown
sandboxgateway NetworkPolicy<run pod>-gwcontroller startteardown
sandboxgateway pod<run pod>-gwcontroller startteardown
sandboxserver TLS Secret<run pod>-gw-tlscontroller startowner reference to the gateway pod, and teardown
sandboxsupervisor client TLS Secret<run pod>-gw-clientcontroller startowner reference to the gateway pod, and teardown
sandboxSandbox resourcesper sandboxgatewayowner reference to the gateway pod after bind, and teardown
sandboxsupervisor podsper sandboxgatewayteardown

The gateway objects carry crucible.dev/gateway-for=<run pod> and crucible.dev/role=openshell-gateway. What the gateway creates carries openshell.ai/gateway-id=<gateway id> (crucible/src/deploy/render/gateway.rs:78).

The run Secret holds the loop pod client material under openshell-gateway-ca.crt, openshell-gateway-tls.crt and openshell-gateway-tls.key. The engine reads them as files in /etc/crucible/gateway-tls. The loop pod env gets CRUCIBLE_OPENSHELL_GATEWAY_URL (https://<gateway ClusterIP>:17670) and CRUCIBLE_OPENSHELL_GATEWAY_TLS_DIR.

The gateway pod runs the loop image with the command crucible openshell-gateway (crucible/src/deploy/render/gateway.rs:224, crucible/src/openshell/gateway.rs:886). It gets the driver env of the loop pod (CRUCIBLE_SANDBOX_*, the supervisor and runtime images, the OTLP endpoints), and these per-run values:

EnvValue
CRUCIBLE_GATEWAY_GRPC_ENDPOINTThe URL that supervisors dial back
CRUCIBLE_GATEWAY_HOST_IPThe loop Service ClusterIP: host.openshell.internal inside sandboxes
CRUCIBLE_GATEWAY_CLIENT_TLS_SECRETThe Secret the driver mounts into supervisors
CRUCIBLE_GATEWAY_SERVER_TLS_DIRWhere the server TLS Secret is mounted
CRUCIBLE_GATEWAY_IDThe gateway id

The gateway keeps its state in memory (--db-url sqlite::memory:). Under the kubernetes driver, the gateway accepts any client with a certificate from the run CA as an authorized user (crucible/src/openshell/gateway.rs:507). The run CA is the boundary.

6.4 The per-run CA

GatewayTls::mint (crucible-controller/src/runs/workpod/gateway.rs:90) uses rcgen to make:

  • a CA, constrained to path length 0, with keyCertSign and cRLSign;
  • a server certificate with the gateway Service ClusterIP as its only IP SAN, serverAuth;
  • a loop client certificate, clientAuth;
  • a supervisor client certificate, clientAuth.

All four start one day before the mint date. They end after the run deadline in whole days plus one day, or after 30 days for a run with no deadline. The controller drops the CA key after the mint. No other run trusts this CA, so a certificate of one run cannot call the gateway of another.

6.5 Network policy

The gateway NetworkPolicy (crucible/src/deploy/render/gateway.rs:362) selects the gateway pod. It admits ingress on port 17670 only from:

  • the loop pod of this run (crucible/run=<run> in the loop namespace), and
  • supervisor pods with openshell.ai/component=supervisor and this gateway id.

The loop Service exposes the ports that the rendered loop NetworkPolicy lists for sandbox traffic. These are the broker, the OTLP collector, buildit on 8848 with [buildit], and the [mcp] servers of the pack. Under an isolated profile, the loop render omits port 17670 (crucible/src/deploy/render/kube.rs:1342).

6.6 RBAC for each identity

IdentityNamespaceRightsSource
crucible-openshell-gatewaysandboxsandboxes, sandboxes/status: create, delete, get, list, patch, update, watch; events: get, list, watch; pods: create, delete, get, list, patch, watch; services, networkpolicies: create, get; persistentvolumeclaims: get; secrets: create, deleteloop render sandbox_rbac (kube.rs:1473)
crucible-openshell-gatewayclustertokenreviews: create; nodes: get, list, watch; runtimeclasses, priorityclasses, namespaces: getsame
sandbox service account ([cluster] sandbox_service_account, default crucible-sandbox under isolated gateways)sandboxnone. Sandbox and supervisor pods mount no token; the account needs only what admits them (an OpenShift SCC). The render creates itloop render sandbox_rbac
controller identity ([cluster] sandbox_readers)sandboxsandboxes: get, list, patch, delete; pods: get, list, create, delete; pods/finalizers: update; services, secrets, networkpolicies: list, create, deleteloop render sandbox_reader_rbac (kube.rs:1658)
controller identityloopservices: list, create, delete; plus the dispatch rights in docs/controller-deploy.mdsame, and the operator
controller identitySPIREservices/proxy: getthe operator
controller identityclustertokenreviews: create (system:auth-delegator), for linksthe operator
loop service accountsandboxnone under an isolated profileloop render
loop service accountbuilditpods: create, delete, get, list, watch; pods/exec: create, get; pods/log: get. The Role and RoleBinding are <service account>-buildit, for buildit builder pods. None in the loop namespaceloop render buildit_rbac with [buildit] (kube.rs:1707)

Note: pods/finalizers update is necessary on OpenShift. The TLS Secrets have an owner reference with blockOwnerDeletion to the gateway pod, and the OpenShift owner-reference admission checks that right.

Note: The loop render grants the builder pod rights only when the profile has [buildit], and only in [buildit] namespace. The render does not depend on sandbox_driver.

Warning: The loop service account is shared by every run. Rights to create pods or exec into pods in the loop namespace would let one run's pack code reach another run's loop pod and run Secret: its gateway client certificate, pod token, push authfile and kubeconfig. It could also label a pod crucible/run=<other run> to pass that run's gateway NetworkPolicy. That is why builder pods live in a namespace of their own, which profile load refuses to share with the loop, rig, sandbox or crucible-kueue namespaces. The rights in the buildit namespace still let runs see each other's builder pods there, including a push's authfile while buildit streams it into the builder pod. The clean end state is buildit under a service account of its own.

With [cluster] aws_sandbox_role_arn, the gateway pod gets its own projected token for the sts.amazonaws.com audience. The trust policy of that role must allow system:serviceaccount:<sandbox namespace>:crucible-openshell-gateway.

Pod admission in these namespaces depends on the cluster's platform (section 11.2). The gateway and loop pods set no UID: on OpenShift the namespace's SCC assigns one, and on plain Kubernetes they run as their image's user, root, so their namespaces enforce Pod Security baseline there. OpenShell's sandbox and supervisor pods set their own non-root UID, drop every capability and use RuntimeDefault seccomp, so they would pass restricted on either platform.

6.7 Push-only clusters and directly reachable clusters

The controller creates gateway objects, reads Sandbox resources and reads SPIRE keys through its own API client for the cluster. A push-only cluster sends snapshots to the hub, and the hub has no client for it. So under an isolated profile, dispatch to a push-only cluster fails with PushOnlyCluster (crucible-controller/src/runs/workpod/run.rs:557, run.rs:707).

A cluster is push-only when the hub has a push token for it and no client for it. A cluster has a client when it is the hub, a personal target, or has a kubeconfig in CONTROLLER_CLUSTERS_DIR. A cluster with both a push token and a kubeconfig dispatches normally.

Note: The token path depends on the per-cluster client. When the hub cannot reach a spoke, the sandboxes of that spoke get no tokens until the spoke is reachable again.

6.8 When sandbox_namespace is unset

AreaIsolated profilesandbox_namespace unset
GatewayA pod of its own per run and per turnInside the loop pod
Sandboxes and supervisorsSandbox namespaceLoop namespace
Sandbox RBACcrucible-openshell-gatewayThe loop service account
Loop NetworkPolicyNo gateway portAdmits sandbox pods on 17670
Agent tokensIssued on each cluster whose SPIRE the issuer trustsNot issued; with CONTROLLER_AGENT_SPIRE set, the controller does not start (PlacementError)
Token endpoint without agent clustersunsupported_grant_typeunsupported_grant_type
Bind routeServed409: the profile names no sandbox namespace
Gateway sweepRegisteredNot registered
Push-only dispatchRefusedAllowed
People tokensAllowedAllowed (they vouch for no sandbox)

Profile load also refuses a sandbox namespace that is the loop namespace, the rig namespace, the crucible-kueue namespace or the workload namespace (crucible/src/deploy/profile.rs:859).

6.9 SPIRE discovery

The issuer verifies a sandbox's JWT-SVID against the SPIRE of the cluster that issued it, so it needs each agent cluster's JWT issuer, trust domain and the Service its keys are read through. It finds them itself (crucible-controller/src/agent_auth/discovery.rs), through the same cluster client it dispatches with, for every cluster an agent server serves:

PlatformTrust domain and JWT issuerKeys
OpenShift (serves security.openshift.io)spec.trustDomain of the ZeroTrustWorkloadIdentityManager and spec.jwtIssuer of the SpireOIDCDiscoveryProvider, both named cluster (operator.openshift.io/v1alpha1)spire-spiffe-oidc-discovery-provider in zero-trust-workload-identity-manager, https:443
Kubernetesserver.trust_domain and server.jwt_issuer in the server.conf of ConfigMap spire-server/spire-server (the upstream chart)spire-spiffe-oidc-discovery-provider in spire-server, http:80

The gateway prefix is spiffe://<trust domain>/openshell/gateway/. Discovery runs at startup and each minute for every cluster not yet read, and every ten minutes for all of them. A cluster it cannot read, or whose SPIRE runs in a namespace pack code reaches, has agents off: its runs get no agent servers, the issuer refuses its SVIDs, the controller logs why, and GET /api/agent-clusters reports each cluster as ready, pending or unavailable with the reason. CONTROLLER_AGENT_SPIRE names a SPIRE installed anywhere else and replaces discovery for that cluster.

The run render grants the identities in [cluster] sandbox_readers what discovery reads, as crucible-spire-reader: get on services/proxy for the discovery Service in SPIRE's namespace, and the spire-server ConfigMap on Kubernetes, or a ClusterRole for the two operator resources named cluster on OpenShift.

7. crucible-kueue internals

7.1 Request pipeline

flowchart LR
    REQ["POST /mcp"] --> AUTH["auth::authenticate<br/>verify token, required scope kueue.read"]
    AUTH --> CAN["ClaimMap::canonicalize<br/>map configured claims to kube_user,<br/>kube_groups, run_id, namespace"]
    CAN --> DNS["default_namespace<br/>the only usable namespace, if one"]
    DNS --> ID["Identity::from_principal<br/>run or person, kube_user required"]
    ID --> GUARD["guard::enforce<br/>method allowlist, per-tool scope,<br/>tool policy, access checks"]
    GUARD --> RMCP["rmcp Streamable HTTP<br/>stateless, JSON responses"]
    RMCP --> TOOL["KueueMcp tool<br/>acts as the server service account"]
    META["GET /.well-known/oauth-protected-resource/mcp"] --> MD["RFC 9728 metadata, no auth"]
    HZ["GET /healthz"] --> OK["ok, no auth"]

The app is built in crucible-kueue/src/lib.rs:62:

  1. auth::authenticate verifies the bearer token and checks kueue.read (crucible-kueue/src/auth.rs:117). A token that fails verification gets a 401 with the RFC 9728 challenge. A token without kueue.read gets a 403 step-up challenge.
  2. ClaimMap::canonicalize copies the configured claims into the canonical names, and removes a canonical claim that the configured claim does not supply (crucible-kueue/src/config.rs:289). A token cannot set kube_user itself unless kube_user is the configured user claim.
  3. A token without namespace gets the one namespace the caller can use, when there is exactly one (auth.rs:36).
  4. Identity::from_principal refuses a people token with run_id, a people token on a server with no reviewer, and any token without kube_user (auth.rs:68). The refusal is a 403. A run's identity is its run_id; a person's is its sub. A value that is not a label value gets a hash suffix.
  5. The guard decides the request (decide, crucible-mcp-auth/src/guard.rs:148). initialize, ping, server/discover, notifications/* and responses to the server pass. tools/list passes and has injected parameters removed from each inputSchema. Every other method must be listed in KueueRules. JSON-RPC batches are refused.
  6. For a listed call, the guard checks the scope. For a tool, it then applies the tool policy. It then sends each access check to the reviewer (review, guard.rs:106). The request goes on as the guard parsed and rewrote it.
  7. A refused tool call returns a tool result with isError and the text {"status": "refused", "error", "retryable"}, so the model can read the reason. Only an access review that did not complete (the API server or the group lookup failed) is retryable; a caller the reviewer cannot check (an empty or system: kube_user, malformed kube_groups) is refused for good (ReviewError, crucible-mcp-auth/src/access.rs). An absent scope returns a 403 step-up challenge. A refused method returns JSON-RPC error -32001.

7.2 Tools

Tool or methodScopeAccess check as kube_user
whoamikueue.readnone
queues_listkueue.readlist localqueues
queue_describe, hardware_describekueue.readget or use (the submit verb) the LocalQueue
workload_status, tasks/getkueue.readget the workload; each of Kueue workloads, pods and events is shown only with get and list on it
workload_listkueue.readlist Kueue workloads
workload_logskueue.readget pods/log
workload_plan, workload_submitkueue.submituse (or get) the LocalQueue, create the kind, get each Secret and PVC the manifest names
workload_scalekueue.submitupdate the scale subresource
workload_stop, workload_resume, tasks/cancelkueue.submitupdate the workload, and patch Kueue Workloads in the namespace for a kind that does not scale (its Workload's spec.active is patched)
workload_deletekueue.submitdelete the workload

The namespace of each check is the call's namespace argument, else the token's namespace claim. Every tool takes an optional namespace. tasks/get and tasks/cancel take no namespace: they act in the namespace the task id names (7.9). Each check covers the object named; what a read returns beyond it is read with the server's account (2.6).

Note: A unit test fails if a tool has no scope in KueueRules. A new tool cannot ship unchecked.

7.3 Plan, then submit

sequenceDiagram
    autonumber
    participant A as agent
    participant K as crucible-kueue
    participant API as API server
    A->>K: workload_plan {queue, manifest}
    K->>API: SubjectAccessReviews (queue use, create kind, Secrets, PVCs)
    K->>API: get LocalQueue and its ClusterQueue
    Note over K: normalize the manifest, compute plan_hash
    K->>API: create with dryRun, plan-hash and actor annotations
    K-->>A: plan_hash, fit, pod_sets, server_changes, manifest
    A->>K: workload_submit {queue, manifest, plan_hash}
    K->>API: the same reviews
    Note over K: normalize again, compare plan_hash
    alt hash differs
        K-->>A: error that names both hashes
    else object absent
        K->>API: create
        K-->>A: submitted, uid, task_id
    else object exists with the same plan hash
        K-->>A: already submitted with this plan
    else object exists with another plan hash
        K-->>A: conflict
    end

workload_plan normalizes the manifest, runs a server-side dry run, and estimates the fit against the ClusterQueue quota as fits_now, waits_for_quota or never_fits (crucible-kueue/src/server.rs:1604). It returns plan_hash, finished_retention_seconds, server_changes, the admission mode, the pod sets, the total requests and the normalized manifest.

workload_submit normalizes the same input again and compares the hash (server.rs:1658). The hash is the SHA-256 of the normalized object as key-sorted JSON (crucible-kueue/src/manifest.rs:504). It depends only on content, so the agent must send the same queue and the same manifest. An identical resubmit is a no-op. A name that holds another plan is a conflict.

After the hash, the server stamps two annotations: <domain>/plan-hash, and for a run, <domain>/actor with the SPIFFE ID of the presenter (server.rs:288).

7.4 Manifest allowlist and profiles

The allowlist is data (crucible-kueue/src/profile.rs). Anything not on the list is refused.

Liststricttenant adds
Pod fieldscontainers, initContainers, restartPolicy, terminationGracePeriodSeconds, activeDeadlineSeconds, dnsPolicy, dnsConfig, nodeSelector, affinity, topologySpreadConstraints, securityContext, volumes, imagePullSecrets, hostname, subdomain, setHostnameAsFQDN, enableServiceLinks, shareProcessNamespace, hostAliases, readinessGates, automountServiceAccountTokentolerations, priorityClassName, resourceClaims
Container resourcesrequests, limitsclaims
Volume sourcesemptyDir, configMap, downwardAPI, ephemeral, projectedsecret, persistentVolumeClaim
Projected sourcesconfigMap, downwardAPIsecret
env[].valueFromfieldRef, resourceFieldRef, configMapKeyRefsecretKeyRef
envFrom[]configMapRef, prefixsecretRef
Reserved label domainskueue.x-k8s.io, openshell.ai, spiffe.io, and the server label domainonly the server label domain
Allowed reserved labelsnonekueue.x-k8s.io/priority-class
Allowed reserved annotationsnonekueue.x-k8s.io/podset-required-topology, kueue.x-k8s.io/podset-preferred-topology

A TOML file starts from base = "strict" or base = "tenant" and has optional [add] and [remove] tables with the same list names. The tool policy can make any rule stricter.

The pod field list has no serviceAccountName, hostNetwork, hostPID, hostIPC, nodeName, schedulerName or runtimeClassName. So a manifest cannot pick another identity, the host, or a way past Kueue. The object-level spec of each kind is also on a list, so fields such as managedBy never reach the cluster.

7.5 Normalization

normalize (crucible-kueue/src/manifest.rs:303) does these steps:

  1. Find the kind from apiVersion and kind. Seven kinds are known: Job, RayJob, LeaderWorkerSet, Deployment, StatefulSet, JobSet, PyTorchJob.
  2. Remove status, uid, resourceVersion, creationTimestamp and managedFields. Refuse generateName, ownerReferences and finalizers.
  3. Require metadata.name as a DNS-1123 label.
  4. Set metadata.namespace to the caller namespace. Refuse another value.
  5. Refuse reserved labels and annotations that the profile does not allow (manifest.rs:822).
  6. Set the object labels: kueue.x-k8s.io/queue-name, <domain>/managed-by=crucible-kueue, <domain>/run-id, <domain>/owner-kind (run or person), <domain>/launched-by. A manifest value that conflicts is refused.
  7. With --suspend (default true), create Jobs and JobSets with spec.suspend: true, and PyTorchJobs with spec.runPolicy.suspend: true, for Kueue to start on admission.
  8. For batch kinds, add activeDeadlineSeconds (default 86400) and ttlSecondsAfterFinished (default 3600) where the manifest sets none (manifest.rs:228). A JobSet gets the deadline on each replicated Job; a PyTorchJob gets both on runPolicy; a RayJob gets the TTL only with shutdownAfterJobFinishes: true.
  9. Check the object-level fields of the kind. A RayJob may not enable the in-tree autoscaler, and its head service must be ClusterIP. Volume claim templates may set only access modes, resources and volume mode.
  10. In each pod template: check reserved metadata, set <domain>/workload and <domain>/run-id, check the pod spec (manifest.rs:1142), and set automountServiceAccountToken: false. A manifest with true is refused.
  11. Check each container. Refuse privilege, privilege escalation, root and added capabilities. Accept procMount only as Default, and seccomp and AppArmor only as RuntimeDefault. Refuse a probe or hook with a host.
  12. Add pull secrets (section 7.6).
  13. For a Deployment or StatefulSet, pin spec.selector.matchLabels to the workload and run-id labels, so its controller adopts only pods of this object (manifest.rs:1122).

server_changes in the plan lists what step 12 added.

7.6 Pull secrets

--pull-secret name or --pull-secret name=registry declares a pull secret of the served namespaces. A pod template may name only declared pull secrets. When a container image is on a declared registry (a host, or a host/path prefix), the server adds that secret to the pod itself (crucible-kueue/src/manifest.rs:433). An image on another registry must be public.

Warning: The server does not create the pull secret. It must exist in the workload namespace with that name, or the pods cannot pull. crucible deploy render --kueue does not copy it.

7.7 Access checks and namespaces

The reviewer (crucible-mcp-auth/src/kube.rs) builds a SubjectAccessReview with:

  • user: the canonical kube_user, never a system: user;
  • groups: OpenShift Group objects that list the user plus system:authenticated and system:authenticated:oauth (--kube-authz openshift), or the kube_groups claim without system: entries plus system:authenticated (--kube-authz token, where the token is not an OpenShift OAuth sign-in);
  • the resource attributes of the access check.

The server serves the namespaces in --namespace, else every namespace that holds a LocalQueue. A caller can use a served namespace when its RBAC lets it list LocalQueues there (crucible-kueue/src/namespaces.rs:97-115). The answer is cached 30 seconds per identity (subject, kube_user and kube_groups), so a RoleBinding granted or removed shows in the usable list within 30 seconds; the guard's per-call access decisions are cached as section 2.6 says. Every tool call and both task methods resolve the namespace they act in (the call's namespace argument, the token's namespace claim, or the task id's namespace) and refuse one the server does not serve, or one the token's namespaces claim leaves out, with not_found and the list of usable namespaces. The guard has already run the call's SubjectAccessReviews by then, so those reach the API server first; the check comes before the server reads or writes anything with its own account. Identity::act_in (crucible-kueue/src/caller.rs) makes this check and is the only way to get a Caller, the type every tool acts with, so a tool cannot skip it. A test calls every tool the router lists in an unserved namespace and expects not_found (crucible-kueue/tests/http_auth.rs). The usable list is also cut to the claim. A server with no --namespace that cannot list LocalQueues cluster-wide serves nothing. It never passes on the API server message about the server's own account.

7.8 Status rollup and the wait

Kueue can track one object as several Workloads. A Deployment gets one Workload for each pod. rollup (crucible-kueue/src/status.rs:138) gives each Workload a phase: NoWorkload, Pending, QuotaReserved, Admitted, Evicted, Stopped, Finished. The headline is the phase furthest from the run state, with counts for each phase. So pending with admitted > 0 means partly admitted.

workload_status reports the phase and the reason. While the workload waits, it also reports the queue position and a new fit estimate. It then reports pod counts, up to five problem pods with exit messages, recent events and a next_action. detail: "full" adds all conditions, the object status and every pod. For a caller with a kube_user, the Workload fields (phase, counts, queue position, fit, readiness, next_action), the pod fields and the events each need get and list on Kueue workloads, pods and events in the namespace; a part without them is left out and listed under omitted with the reason.

With until (admitted, running, ready or finished) the server waits for up to timeout_seconds, at most 50, and polls each 2 seconds (crucible-kueue/src/server.rs:1228). The wait ends with one of these:

endedCause
reachedThe workload is at until or past it
settledThe workload is Finished or Stopped and cannot reach until alone
stuckA pod has a problem of the highest severity
timeoutThe time is over

7.9 MCP tasks

The server enables the MCP tasks extension for batch kinds (Job, RayJob, JobSet, PyTorchJob). A client that declares the extension gets a task from workload_submit. The task id is <namespace>/<Kind>/<name>/<uid> (crucible-kueue/src/task.rs:16), so a new object with the same name is a different task. Clients without the extension get a synchronous reply that still carries task_id.

The server keeps no task state. tasks/get reads the cluster each time. A task completes when Kueue marks the Workload finished. It failed unless the reason is Succeeded. tasks/cancel sets the <domain>/task-cancelled annotation and deactivates the Workload (server.rs:2175).

Serving kinds are never tasks. workload_status reports their readiness instead.

7.10 Ownership modes

ModeStop, resume, scale, delete
managed (default)Only objects with <domain>/managed-by=crucible-kueue, the caller's <domain>/run-id and the caller's kind in <domain>/owner-kind, so a person whose subject reads like a run id owns nothing of that run
rbacAny object of a served kind that the access checks allow

workload_list takes filter: mine (default, this run), managed (all objects of this server), or all (every Kueue-queued workload of a served kind). Stop and resume deactivate the Kueue Workload for Job, RayJob, LeaderWorkerSet, JobSet and PyTorchJob. For a Deployment or StatefulSet they scale to zero and keep the replica count in <domain>/stopped-replicas.

7.11 Kueue version and kinds

At startup the server reads /apis/kueue.x-k8s.io and uses v1beta2, else v1beta1. It does not start when the cluster serves neither (crucible-kueue/src/kueue.rs:49). It also lists which of the seven kinds the cluster installs (crucible-kueue/src/kinds.rs:11). whoami and the server instructions report them.

7.12 Generic configuration

The server does not need crucible. These flags make it a general Kueue MCP server for a cluster with a stock OIDC provider:

FlagDefaultUse
--label-domaincrucible.devThe domain of every stamped label and annotation
--managercrucible-kueueThe <domain>/managed-by value and the field manager of every write. Servers sharing a cluster each need their own, so each sees only its objects as managed
--user-claimkube_userThe claim that names the Kubernetes user
--user-prefixemptyThe prefix of the user, as --oidc-username-prefix on the API server
--groups-claimkube_groupsThe groups claim, with --kube-authz token
--run-id-claimrun_idThe run claim of run tokens; empty for none, which is refused with --run-issuer
--namespace-claimnamespaceThe default namespace claim
--namespaces-claimnamespacesThe claim listing the namespaces a token is confined to; a token without it is confined only by the served namespaces and RBAC
--queue-verbuseuse, or get where roles do not grant Kueue's custom verb
--ownershipmanagedmanaged or rbac
--allowlist-profilestrictstrict, tenant, or a TOML file

8. buildit per run

8.1 Where it runs

buildit runs as a child process of the engine in each loop pod of a [buildit] cluster. It is an entry of CRUCIBLE_AGENT_SERVERS like any other, named buildit, whose transport is the loop pod (local.buildit, carrying its run issuer, builder namespace and push repository). The engine starts it before it creates the providers (Granted::start_local, crucible/src/openshell/agent_servers.rs), and gives it a provider, an egress entry and a .mcp.json entry the way it does every server. It listens on the port of its URL, [buildit] url, by default http://host.openshell.internal:8848/mcp, which is the loop Service. The supervisor injects a token for that audience on egress to that host and port only.

The engine runs the buildit first on its PATH. That is the loop image's own unless [buildit] image names another: the buildit-bin init container then copies that image's /usr/local/bin/buildit into an emptyDir the loop container mounts read-only at /opt/crucible/buildit/bin, and the wrapper puts that directory first on PATH, so the engine and the run's own tasks run it. The shared cache runs from the same image unless [buildit.cache] image names one. docker.yml publishes ghcr.io/neuralmagic/crucible-buildit from every main build (docker/Containerfile.buildit), and the controller reads the deploy profile at each dispatch, so moving the pin rolls buildit for the next runs with no new loop image.

The engine starts it with these arguments (Buildit::args, crucible/src/openshell/buildit.rs):

ArgumentValue
--bind0.0.0.0:<port of the URL>
--run-issuerthe entry's <issuer>=<jwks uri>
--resourcethe entry's URL
--allowed-hostthe URL's host
--tool-policythe shipped buildit/deploy/buildit-policy.toml
--run-idthe loop pod name
--namespacethe entry's builder namespace, [buildit] namespace
--insecure-jwks-httpwhen the JWKS URI is plain http
--push-repo, --push-authfilewhen the entry has a push repository and the loop pod has a push authfile
--socket, --socket-workdir<state dir>/buildit.sock and the run's workspace (8.6)

8.2 Who may call it

realm_caller (buildit/src/mcp.rs:832) accepts a token only when all of these are true:

  • The trusted issuer is a run issuer. People tokens are refused.
  • run_id is this server's --run-id. A token of another run is refused.
  • sandbox is a label value. It names the sandbox to read from.
  • workdir, when present, is an absolute normal path.
  • sub is a label value. It becomes crucible.dev/launched-by on builder pods.

The scopes are buildit.read for status, logs and tasks/get, and buildit.build for build, run, push, clean and tasks/cancel. buildit does no access checks: it builds in its own namespace for the run's own sandboxes.

8.3 How it reads the sandbox

For build, buildit downloads the build context with openshell sandbox download <sandbox> <workdir>/<context> <dest> through the run's gateway (buildit/src/sandbox.rs:101). The context path must be relative, without ... It may not contain .git, .mcp.json, .claude, .buildit, .kube or .jira. sanitize then deletes those names but .git, special files, and symlinks that resolve outside the context (sandbox.rs:207). Of each .git directory it keeps HEAD, index, objects, packed-refs, refs and shallow, so a build can read the checkout's history (vLLM's setup.py takes its version from it), and deletes the rest: the config, where remote URLs and http headers can carry credentials, the hooks and the logs. run writes requested output paths back to <workdir>/.buildit/<build id>/ in the sandbox with openshell sandbox upload.

8.4 Builder pods

Each build gets one builder pod in the buildit namespace (buildit/src/mcp.rs:971). The pod has the labels buildit.dev/managed-by=mcp, buildit.dev/sandbox, buildit.dev/build-id and crucible.dev/launched-by. It runs buildah with automountServiceAccountToken: false, enableServiceLinks: false and an activeDeadlineSeconds of --pod-deadline (default 7200). --max-builds (default 4) limits live builder pods per sandbox. The shipped policy caps a build at 3600 seconds and a run at 1800 seconds; without a value they are 1800 and 600.

Warning: A build's RUN steps and buildit run execute the agent's code in the builder pod, with no OpenShell sandbox around it. The loop render's NetworkPolicy buildit-builders in the buildit namespace admits no ingress to builder pods, and egress only to DNS (UDP and TCP 53 and 5353) and to [buildit] egress, by default TCP 443 anywhere, for the registries a build pulls from and pushes to. A NetworkPolicy matches IP networks, not host names: to keep RUN steps away from in-cluster services, list the registries' networks, or add as except the cluster's pod network, its service network (which holds the API server's ClusterIP, the kubernetes Service in default, such as 172.30.0.1 on OpenShift or 10.96.0.1 on kubeadm), and the API server's own addresses when it is reached outside the service network. The pod and service networks are in oc get network.config cluster -o yaml on OpenShift, or the kube-controller-manager's --cluster-cidr and --service-cluster-ip-range. The loop render logs a warning when an egress entry reaches 0.0.0.0/0 or ::/0 with no except, as the default does (BuilditCfg::unfenced_egress).

The loop account may create any pod in the buildit namespace, and builder pods need AppArmor unconfined, so that namespace cannot enforce Pod Security baseline. Without more, pack code could create a privileged pod, mount the node's / or join its network, and from the node reach every other run. The loop render therefore also emits a ValidatingAdmissionPolicy and its binding to the buildit namespace, both named <namespace>-buildit-builders, from buildit/deploy/builder-admission.yaml (builder_admission, crucible/src/deploy/render/kube.rs). It admits a pod there only when:

  • it carries buildit.dev/managed-by=mcp;
  • it runs one container, builder, from quay.io/buildah/stable:latest, with no init or ephemeral containers;
  • the container is not privileged, adds no capabilities, runs as a non-root runAsUser, does not ask for seccomp Unconfined, keeps the default /proc mount, and sets no SELinux user, role or type;
  • it sets no nodeName (on create), runtime class, system priority class, sysctls, or toleration beyond the not-ready and unreachable ones admission gives every pod;
  • it shares no host network, PID or IPC namespace, and mounts only emptyDir volumes;
  • it runs as the default account with automountServiceAccountToken: false;
  • it reads no Secret or ConfigMap into its environment (valueFrom, envFrom) and names no image pull Secret;
  • AppArmor is set only as buildit sets it: the annotation for builder, unconfined (or the matching appArmorProfile field on that container).

The policy checks pod creates and updates, so an update cannot swap the image or the labels. It admits what admission defaults every pod with: those two tolerations, a cluster's global default PriorityClass, and an SELinux level an OpenShift SCC assigns. The policy needs Kubernetes 1.30 or later.

The loop render also emits a ResourceQuota in the buildit namespace from [buildit] quota, by default 16 pods and no NVIDIA or AMD GPUs. Builder pods request no CPU or memory, so a quota on requests.cpu or requests.memory would refuse every one. buildit's tests evaluate its own builder pod against the same file and expect it to pass, and expect each escape above to fail (the_builder_admission_policy_admits_buildits_pod_and_no_escape, buildit/src/mcp.rs), so the policy and the builder cannot drift apart.

On OpenShift, SCC admission does the same job when the builder pods' account (default in the buildit namespace) can use only an SCC no broader than this: allowPrivilegedContainer: false, allowedCapabilities: [], allowHostNetwork, allowHostPID, allowHostIPC and allowHostDirVolumePlugin false, volumes: [emptyDir], runAsUser: MustRunAsNonRoot, and the unconfined AppArmor annotation allowed. Do not grant that account, or the loop account, anyuid or privileged; the loop account's pods in that namespace run as whatever account the pod names, so every account there needs the same bound. The ValidatingAdmissionPolicy applies on OpenShift too, so both hold.

8.5 The push path

push publishes a build that succeeded to one repository only: --push-repo, for example registry.example.com/team/smoke. The tag is <sandbox>-<build id>. The repository must be <registry>/<path> with no tag, digest or scheme (buildit/src/mcp.rs:457).

sequenceDiagram
    autonumber
    participant A as agent
    participant B as buildit (loop pod)
    participant P as builder pod
    participant R as registry.example.com
    A->>B: push {build_id}
    Note over B: build must have exited 0
    B->>B: read the push authfile from the loop pod mount
    B->>P: exec a shell that writes stdin to a temp file and runs buildah push<br/>authfile on stdin
    P->>R: push REPO:SANDBOX-BUILD_ID
    P-->>B: sha256 digest on stdout
    Note over P: the temp files are removed on exit
    B-->>A: image REPO@sha256 digest, tag

The authfile is the loop pod's [secrets] push_authfile Secret, mounted in the loop pod only (FORGE_AUTHFILE). buildit reads it on each push and streams it to the builder pod over exec stdin (buildit/src/mcp.rs:479, mcp.rs:1089). The builder pod never mounts it, and the sandbox never sees it. Without a push repository or without the authfile, push is refused.

Warning: the push runs in the same builder pod as the agent's build steps and run commands, as the same user, and nothing stops a process a step left behind when push starts. Such a process could read the authfile while the push holds it, then push to any tag, or any repository the credential reaches. Until the push moves to a container of its own, give [secrets] push_authfile a credential scoped to the one push repository.

8.6 The run's own socket

The same buildit mcp also serves its tools on a unix socket, <state dir>/buildit.sock (mode 0600), for the run's deterministic tasks. The socket takes no token. Every call on it is the run itself, under the sandbox name loop-<first 16 hex of sha256(run id)>, which no openshell sandbox (ci-<hex>) and no other run takes. That name scopes --max-builds, clean and every lookup the way a sandbox name does, and buildit refuses any token that names it. The shipped tool policy, the builder namespace, the queue and the push repository are the same as over HTTP.

The engine exports the socket as CRUCIBLE_BUILDIT_SOCKET to command and evaluate tasks only (ShellRunner, crucible/src/plan/runner.rs). Agent turns and sandboxes never get it, and route and report tasks spawn nothing. Before such a task runs, the engine starts the run's buildit if the controller granted one (run_buildit, crucible/src/openshell/agent_servers.rs), and the task fails as a transport error when buildit does not come up. With the variable set, buildit build, status, run, push, logs and clean call the socket. Each prints one JSON object on stdout and exits nonzero on failure:

buildit build -c image/            # waits for the build to end; --no-wait returns at once
buildit context-digest image/      # the context_digest a build of image/ reports

A build on the socket reads its context from the run's workspace. The CLI sends -c as an absolute path, and buildit refuses one outside the workspace, so a task in an isolated worktree cannot build its own copy. run writes fetch_paths to <workspace>/.buildit/<build id>/.

Every build reply carries context_digest: a sha256 over the context archive buildit uploaded, one record per entry sorted by path (path, file mode bits, content hash or symlink target; owners, times and directory modes do not count). buildit keeps it on the builder pod, so status, a completed build task and push report it after a server restart too. `buildit context-digest

` stages a directory the way a build does and computes the same digest with the same code, so a script can check that what was built is what it prepared.

8.7 The shared cache

With [buildit.cache], crucible deploy --buildit-cache renders buildit cache in the loop namespace (crucible/src/deploy/buildit_cache.rs): a PersistentVolumeClaim, a Deployment that mounts it and the token key of token_secret, a Service on 8850, and a NetworkPolicy that admits builder pods from the builder namespace alone. The loop render adds an egress rule to it to the buildit-builders NetworkPolicy.

The cache is a blob store over HTTP (buildit/src/cache.rs). GET and HEAD are open; PUT and DELETE need the write token, as a bearer token or a basic-auth password, which is how ccache's http backend sends a URL's userinfo. It re-reads the token file on every write and drops the least recently read blobs past its limit.

The cache serves the dispatch clusters in [buildit.cache] clusters, each one of [buildit] clusters, and is deployed on each. The controller puts it in the buildit site of a run on one of them (BuilditSite.cache): the Service URL and, with write, the token file /etc/buildit-cache/token. Runs on other clusters are told nothing of it. With write, every loop pod mounts the token Secret there, as an optional volume so a cluster without the Secret still starts its runs. The loop passes the site to buildit as --cache-url and --cache-token-file, and buildit writes /buildit/cache.json into each build before bud: {"url": ...}, with "token" when it has one. A build uses the cache only if its RUN steps read that file, as the vLLM bench's informer does for ccache and its build store.

Warning: with write, RUN steps can read the token, so any run's agent on the cluster can store objects that other runs' builds then use. Set write only where runs trust each other.

9. Configuration reference and feature flags

9.1 Minimal configuration

An operator states what only they can decide: which clusters get which servers, where agents' workloads go, whether people get tokens, and where builds may be pushed. The controller environment adds the issuer and its key:

CONTROLLER_AGENT_ISSUER=https://crucible.example.com
CONTROLLER_AGENT_SIGNING_KEY_FILE=/etc/crucible/agent-signing/key.pem
CONTROLLER_DEPLOY_PROFILE=/etc/crucible/profile/profile.toml
[cluster]
# ...the keys every profile has (loop_namespace, service_account, supervisor_image, ...), plus:
sandbox_namespace = "crucible-sandboxes"
sandbox_readers   = ["crucible-system/crucible-controller"]

[kueue]
clusters            = ["pirate", "waldorf"]
workload_namespaces = ["crucible-kueue-e2e"]
people              = true
people_urls         = { pirate = "https://kueue.pirate.example.com/mcp" }

[buildit]
clusters  = ["pirate", "waldorf"]
push_repo = "quay.io/example/crucible-smoke"

people_urls is needed once people covers more than one cluster: every cluster's server has the same Service URL, and a person's token names its cluster by URL, so all but one cluster need a URL of their own. deploy render --kueue also needs [kueue] image.

9.2 Derived values

ValueSource
Agent token resources and their scopesThe servers in [kueue], [buildit] and [[agent_servers]] (Audience::of)
People resources[kueue] people with people_urls, and [[agent_servers]] people
Each cluster's SPIRE: SVID issuer, gateway prefix, key ServiceDiscovered from the cluster (section 6.9)
The run token issuer crucible-kueue and buildit trust, and their sandboxes' SVID audienceCONTROLLER_AGENT_ISSUER
The key set URI servers fetch<CONTROLLER_AGENT_ISSUER>/.well-known/jwks.json
The token endpoint supervisors call<CONTROLLER_AGENT_ISSUER>/oauth/token
Each cluster's platformDetected: OpenShift serves security.openshift.io
crucible-kueue's group source (--kube-authz)The cluster's platform
crucible-kueue's Service URL[kueue] namespace and cluster_domain
crucible-kueue's extra --resource and --allowed-host[kueue] people_urls
The cluster link callback baseCONTROLLER_AGENT_ISSUER
The sandbox compute driverkubernetes with a sandbox_namespace
The sandbox service accountcrucible-sandbox with a sandbox_namespace
The supervisors' SPIFFE socket/spiffe-workload-api/spire-agent.sock, delivered only with a run's agent servers
The SPIRE reader RBAC (crucible-spire-reader)Rendered for sandbox_readers on each cluster's platform
The server and builder namespacescrucible-kueue and crucible-builds

Every other key and variable in sections 9.3 and 9.4 is an override with the default shown.

9.3 Overrides: controller environment

VariableDefaultWhat it does
CONTROLLER_AGENT_ISSUERunsetThe issuer identifier, an http(s) origin with no path. Unset disables the issuer, agent tokens, people tokens and the agent tools. Cluster links depend only on [cluster_link].
CONTROLLER_AGENT_SIGNING_KEYunsetThe issuer key as inline PEM.
CONTROLLER_AGENT_SIGNING_KEY_FILEunsetThe issuer key file. Necessary when the issuer is set and the inline key is not.
CONTROLLER_AGENT_PREVIOUS_SIGNING_KEY_FILEunsetThe previous issuer key. The JWKS publishes it; it signs nothing.
CONTROLLER_AGENT_TOKEN_TTL_SECS900Agent token lifetime, 900 to 1800.
CONTROLLER_AGENT_TOKEN_ENDPOINT<issuer>/oauth/tokenWhere supervisors reach the token endpoint, for a spoke that reaches the controller through a route.
CONTROLLER_AGENT_SPIREdiscoveredAn override for a cluster whose SPIRE is not where its platform's installer puts it: comma-separated <cluster>=<svid issuer> <gateway prefix> [http://]<namespace>/<service>:<port>. Each cluster and each SVID issuer once. The prefix may hold one {tenant} segment (section 6.1). Every other cluster an agent server serves has its SPIRE discovered (section 6.9). A SPIRE whose namespace pack code reaches is refused: a named one stops the controller, a discovered one leaves agents off on its cluster.
CONTROLLER_PEOPLE_ACCESS_TTL_SECS900People token lifetime, 1 to 3600.
CONTROLLER_PEOPLE_CLIENTS_FILEunsetA TOML file of [[clients]] with client_id, name, redirect_uris and optional resources. It replaces the built-in claude-code, pi and codex. Unset: the built-in clients (section 4.1.2).
CONTROLLER_PEOPLE_CIMD_HOSTSunsetComma-separated host names that may publish Client ID Metadata Documents. Unset: every URL client_id is refused (section 4.1.3). The controller needs outbound HTTPS to these hosts. It honours HTTPS_PROXY.
CONTROLLER_PEOPLE_DCRonfalse, 0, off or no turns off POST /oauth/register and removes registration_endpoint from the metadata. true, 1, on, yes or empty keeps it on (section 4.1.4).
CONTROLLER_PEOPLE_TRUSTED_PROXIESunsetComma-separated networks or addresses whose X-Forwarded-For names a registration's source. Unset: the TCP peer is the source (section 4.1.4).
CONTROLLER_DEPLOY_PROFILEunsetThe deploy profile. The issuer does not start without it.
POD_NAMESPACEthe service account namespace fileThe controller namespace, for the issuer key placement check: it must not be the loop, rig, sandbox, crucible-kueue, workload or [buildit] namespace.
CONTROLLER_AUTH_MODEper docs/controller-auth.mdPeople authorization needs native: /oauth/authorize reads a native session only.
CONTROLLER_CLUSTER_LINK_SECRETSunsetA directory with one OAuth client secret file per cluster. Necessary when the profile has [cluster_link].
CONTROLLER_GITHUB_LOGINSunset<github login>=<crucible login> pairs. A GitHub review approval sets the dispatcher only for a listed pair.
CONTROLLER_PUSH_TOKENS, CONTROLLER_CLUSTERS_DIRunsetTogether they decide which clusters are push-only (section 6.7).

The issuer refuses to start when any of these is true:

  • Any other CONTROLLER_AGENT_* or CONTROLLER_PEOPLE_* variable is set, but CONTROLLER_AGENT_ISSUER is not.
  • CONTROLLER_AGENT_ISSUER is set, but there are no agent clusters (no isolated profile with an agent server, and no CONTROLLER_AGENT_SPIRE) and the profile serves people nowhere.
  • CONTROLLER_AGENT_SPIRE is set and the profile is not isolated.
  • A cluster whose SPIRE cannot be discovered does not stop the controller: agents are off there, GET /api/agent-clusters says why, and discovery tries again each minute.
  • CONTROLLER_PEOPLE_CLIENTS_FILE cannot be read, is not a valid [[clients]] list, or names a resources entry that is not a people resource.
  • CONTROLLER_PEOPLE_CIMD_HOSTS is set and lists no host, or an entry that is not a host name.
  • CONTROLLER_PEOPLE_DCR is not one of the values above.
  • CONTROLLER_PEOPLE_TRUSTED_PROXIES has an entry that is not an IP network or address.
  • The controller namespace is unknown, or is a loop, rig, sandbox, crucible-kueue or workload namespace.
  • A value does not parse.

The three client variables apply only when the deploy profile serves people. Without people tokens, the controller does not read them.

9.4 Overrides: deploy profile keys

KeyDefaultWhat it does
[cluster] sandbox_driverkubernetes with a sandbox_namespace, else podmanSet only for sandboxes in the loop namespace under the kubernetes driver. Profile load refuses podman with a sandbox_namespace.
[cluster] sandbox_namespaceunsetThe other half. Unset: sandboxes live in the loop namespace and no agent tokens are issued.
[cluster] sandbox_service_accountcrucible-sandbox with a sandbox namespace, else the loop'sThe account sandbox pods run as. Under isolated gateways it is never the loop's account, which does not exist in the sandbox namespace.
[cluster] sandbox_readers[]namespace/name service accounts that get the controller rights in section 6.6.
[cluster] sandbox_spiffe_socket/spiffe-workload-api/spire-agent.sockThe SPIFFE Workload API socket path that supervisors get through csi.spiffe.io. The controller delivers it only to runs that get agent servers, since the CSI driver runs only where SPIRE does.
[cluster] identity"link"The hub identity rule.
[clusters.<name>] identity"link"A spoke identity rule.
[cluster] platform, [clusters.<name>] platformdetectedkubernetes or openshift, over what is detected: the controller asks each cluster's API server (OpenShift serves security.openshift.io), and a render without cluster access takes crucible deploy render --platform, then this key, then kubernetes. The distribution the hub or a spoke runs. It decides what a render may assume of the cluster (section 11.2). Each cluster's render reads that cluster's own entry: an OpenShift hub dispatching to an OpenShift spoke and a Kubernetes spoke renders each spoke for its own platform, and nothing applies the hub's platform to a spoke. The hub's own platform affects only what is rendered for the hub itself; its ingress and deploy are the Argo chart's.
cluster nameshub, each [clusters.<name>], [kueue]/[buildit] clusters and the cluster in CONTROLLER_AGENT_SPIRE are DNS-1123 labels, all checked by one function (check_cluster_name, crucible/src/deploy/profile.rs).
[cluster] aws_sandbox_role_arnunsetThe AWS role that the gateway pod assumes for sandboxes.
[kueue] imagerequired by deploy render --kueueThe crucible-kueue image. The controller does not read it.
[kueue] clusters or cluster["hub"]The clusters where the server runs. Runs on these clusters get crucible-kueue access.
[kueue] namespacecrucible-kueueThe server namespace.
[kueue] workload_namespaces (or workload_namespace for one)required, not emptyThe namespaces crucible-kueue tokens name, the first as the namespace claim, and the only namespaces where the server account has workload verbs. Each gets its Namespace, RoleBinding and --namespace.
[kueue] run_jwks_uri<CONTROLLER_AGENT_ISSUER>/.well-known/jwks.jsonFor a server that cannot reach the issuer address.
[kueue] kube_authzopenshift on an OpenShift cluster, token elsewhereopenshift (the cluster's Group objects) or token (the token's kube_groups): where groups come from.
[kueue] cluster_domaincluster.localThe cluster DNS domain of the Service name.
[kueue] pull_secrets[]name or name=registry entries for --pull-secret.
[kueue] peoplefalsetrue serves people's MCP clients too: each cluster's server is a people resource, and the render adds --person-issuer with the controller's agent issuer.
[kueue] people_urlseach cluster's Service URL{ <cluster> = "<url>" }: where people reach the server on a cluster (a route, a port-forward). A person's token names its cluster by this URL, so people on several clusters needs one per cluster; profile load refuses two clusters at one URL. The render serves it as another --resource and --allowed-host.
[buildit] clusters or cluster["hub"]The clusters whose runs get buildit.
[buildit] namespacecrucible-buildsWhere builder pods run. Profile load refuses the loop, rig, sandbox, crucible-kueue and workload namespaces.
[buildit] egressTCP 443 to 0.0.0.0/0 and ::/0[{ cidr, ports = [443], except = [] }, ...]: where builder pods may connect besides DNS (section 8.4).
[buildit] run_jwks_uri<CONTROLLER_AGENT_ISSUER>/.well-known/jwks.jsonFor a loop pod that cannot reach the issuer address.
[buildit] push_repounsetThe one repository for push. Unset: push is refused.
[buildit] quota{ pods = "16", "requests.nvidia.com/gpu" = "0", "requests.amd.com/gpu" = "0" }The builder namespace's ResourceQuota hard limits.
[buildit.cache] clustersthe hubThe dispatch clusters the shared cache is deployed on and serves, each one of [buildit] clusters.
[buildit.cache] token_secretrequired with [buildit.cache]The loop-namespace Secret whose token key is the shared cache's write token (8.7).
[buildit.cache] writetrueBuilds get the token and may write to the shared cache. false: they read only.
[buildit.cache] size_gib200The cache's claim size; it keeps 90% of it before dropping the least recently read blobs.
[buildit.cache] storage_class, imagethe cluster's, [buildit] image or else the loop imageThe claim's StorageClass and the image buildit cache runs from.
[buildit] imagethe loop image's builditAn image holding /usr/local/bin/buildit that loop pods run instead (8.1).
[buildit] urlhttp://host.openshell.internal:8848/mcpThe URL sandboxes reach buildit at, the audience of its tokens. It listens on this port in the loop pod, and the loop pod's ingress opens it.
[[agent_servers]]noneAnother MCP server that takes agent tokens: name (its .mcp.json key, a DNS label), url, scopes (default none), clusters (default ["hub"]), requires_kube_user (default false) and namespaces (default none). It needs only this entry. Names are unique and never broker, and no two servers share a URL on a cluster.
[secrets] push_authfileunsetThe Secret that holds the push authfile, mounted in the loop pod.
[cluster_link] external_urlCONTROLLER_AGENT_ISSUERThe controller URL as browsers reach it. https, or http to loopback. Without either, cluster links refuse to start.
[cluster_link] client_idcrucible-controllerThe OAuthClient name.
[cluster_link] openshiftrequiredThe link clusters that link through their OAuth server.
[cluster_link] access_token_max_age_secs300The lifetime of the user:info token.

9.5 Loop pod and gateway pod environment

The controller sets these. Do not set them by hand.

VariableSet whenRead by
CRUCIBLE_AGENT_SERVERSThe run gets at least one server: one the profile describes on the run's cluster ([kueue], [buildit], [[agent_servers]]), and the issuer trusts the cluster's SPIREengine: a JSON list of {name, url, scopes, transport}; one provider, egress entry and .mcp.json entry each, and a start for a loop-pod transport
CRUCIBLE_AGENT_TOKEN_ENDPOINTwith CRUCIBLE_AGENT_SERVERSengine: token-grant providers, and the bind decision
CRUCIBLE_AGENT_SVID_AUDIENCEwith CRUCIBLE_AGENT_SERVERSengine: token_grant.jwt_svid_audience
CRUCIBLE_OPENSHELL_GATEWAY_URLIsolated profileengine: remote gateway
CRUCIBLE_OPENSHELL_GATEWAY_TLS_DIRIsolated profileengine: client material
CRUCIBLE_GATEWAY_*Isolated profile, gateway pod onlycrucible openshell-gateway

The engine refuses a partial set: servers without the token endpoint or the SVID audience fail the turn, and so does a list that does not parse or a server URL without an http(s) host and port (AgentServersError, crucible/src/openshell/agent_servers.rs). No list, or an empty one, gives the sandbox no provider, no egress entry and no .mcp.json entry. A granted server named like the turn's broker or one of its [mcp] servers fails the turn with an error naming it, rather than one silently replacing the other in .mcp.json (AgentServers::check_names).

9.6 crucible-kueue flags

Each flag also has a CRUCIBLE_KUEUE_* environment variable (crucible-kueue/src/bin/crucible-kueue.rs:16).

FlagDefaultWhat it does
--bind0.0.0.0:8850Listen address.
--contextunsetA kubeconfig context; unset uses in-cluster config.
--allowed-hostloopback onlyExtra Host values, for example the Service DNS name.
--run-issuerat least one of the two issuer flags<issuer>=<jwks uri> of run tokens.
--person-issuerunset<issuer>=<jwks uri> of people tokens. Needs --resource and --kube-authz.
--resourcerequiredThe canonical MCP URLs; aud must name one.
--clusterrequired with --run-issuerThe cluster this server acts on. A token's kube_cluster must name it. Without run issuers, unset accepts people's tokens for any cluster.
--jwks-caunsetExtra CA bundle for JWKS hosts.
--insecure-jwks-httpfalseAllows plain-http JWKS off loopback.
--tool-policyunsetThe CEL tool policy file.
--kube-authzunsetopenshift or token. Unset: every access check is refused, and people tokens are refused.
--namespacediscoverThe served namespaces.
--pull-secretnoneDeclared pull secrets.
--default-active-deadline86400Seconds, or none.
--default-ttl-after-finished3600Seconds, or none.
--suspendtrueCreate batch kinds suspended.
--accelerator-resource, --accelerator-product-labelNVIDIA, AMD, Habana, TPU namesWhat hardware_describe counts.
other generic flagssection 7.12

crucible deploy render --kueue --issuer <CONTROLLER_AGENT_ISSUER> [--kueue-cluster <name>] renders the server for one [kueue] clusters entry (the only one, or the one named) with --run-issuer, --resource, --allowed-host, --kube-authz, --cluster <name>, --namespace for each workload namespace and the pull secrets (crucible/src/deploy/kueue.rs:123). With [kueue] people = true, it also renders --person-issuer with the same issuer and JWKS URI as --run-issuer (kueue.rs:141).

Note: People tokens and agent tokens have the same iss. A server that trusts the issuer as both kinds treats a token with act as a run and a token without act as a person.

9.7 buildit flags

FlagDefaultWhat it does
--bind (MCP_BIND)0.0.0.0:8849The engine sets 0.0.0.0:8848.
--run-issuer, --resource, --jwks-ca, --insecure-jwks-httpAs for crucible-kueue.
--tool-policyunsetThe engine sets the shipped policy.
--run-idunsetThe only run whose tokens are accepted. Unset accepts any run.
--namespacethe client namespaceWhere builder pods go.
--push-repo (MCP_PUSH_REPO)unsetNeeds --push-authfile.
--push-authfile (MCP_PUSH_AUTHFILE)unsetNeeds --push-repo.
--pod-deadline7200Builder pod activeDeadlineSeconds.
--max-builds4Live builder pods per sandbox.

buildit also accepts an older tokens file. buildit never reads a compact JWT as a tokens-file token.

9.8 What stays inert

With no agent or people configuration, the controller does none of the work in this document. A test boots the controller with a deploy profile and no agent configuration and checks this (crucible-controller/src/lib.rs).

AreaInert whenDetail
/oauth/token, /.well-known/jwks.json, /.well-known/oauth-authorization-serverCONTROLLER_AGENT_ISSUER unsetNot routed (crucible-controller/src/lib.rs:304)
/api/sandbox-bindings/runs/{run_id}sameNot routed
/oauth/authorizethe profile serves people nowhereNot routed (crucible-controller/src/lib.rs:138)
/oauth/registerissuer unsetNot routed. With the issuer set and people tokens or registration off, it answers 404 invalid_request
Metadata document fetchesCONTROLLER_PEOPLE_CIMD_HOSTS unsetNo outbound request; every URL client_id is refused
Identity routes: cluster identities, link, callback, consent, MCP sign-ins, hub overrideno agent tokens, no people tokens and no [cluster_link]Not routed, not in the OpenAPI document (crucible-controller/src/api/mod.rs:672)
/api/agent-tools, /api/agent-clusters, /api/prefs/noticesno agent tokens: no issuer, or no agent clusters (ControllerCfg::issues_agent_tokens)Not routed
Gateway sweepprofile not isolatedNot registered
Grants sweep, registration sweepno people tokens; the registration sweep also when registration is offNot started
Loop pod env and token-grant providersno agent tokens, or the run gets no server (section 9.5)Not set, so the engine creates none and binds nothing
issues.dispatch_requested_by, runs.dispatched_byno agent tokensNot written
run_agent_toolsno agent tokens, or the run gets no serverNot written, and not read for run pages
run_sandboxesno bind requestsNot written
people_dynamic_clientsno people tokens, or registration offNot written
UI: defaults banner, run tools panel, consent page, settings panels/api/whoami reports features.agent_tools and features.identities falseNot rendered and not fetched

10. Data model

10.1 Tables and relations

erDiagram
    runs ||--o{ run_sandboxes : "run_id"
    work_pods ||--o{ run_sandboxes : "pod_name"
    people_grants ||--o{ people_refresh_tokens : "grant_id"
    runs {
        TEXT run_id PK
        TEXT dispatched_by
    }
    issues {
        TEXT key PK
        TEXT dispatch_requested_by
    }
    users {
        TEXT sub
        TEXT login
        TEXT kube_user
        TEXT kube_user_override
    }
    run_sandboxes {
        TEXT sandbox_id PK
        TEXT run_id FK
        TEXT pod_name FK
        TEXT bound_at
        TEXT sandbox_name
        TEXT workdir
    }
    kube_identity_links {
        TEXT sub PK
        TEXT cluster PK
        TEXT kube_user
        TEXT source
        TEXT linked_at
    }
    people_codes {
        BYTEA key PK
        TEXT sub
        JSONB grant_json
        BIGINT expires_at
    }
    people_grants {
        TEXT id PK
        TEXT sub
        TEXT client_id
        TEXT client_name
        TEXT resource
        TEXT cluster
        JSONB scopes
        BIGINT created_at
        BIGINT last_used_at
        BIGINT revoked_at
    }
    people_refresh_tokens {
        BYTEA key PK
        TEXT grant_id FK
        BIGINT issued_at
        BIGINT used_at
    }
    people_dynamic_clients {
        TEXT client_id PK
        TEXT client_name
        JSONB redirect_uris
        BIGINT created_at
        BIGINT last_used_at
    }
    run_agent_tools {
        TEXT run_id PK
        TEXT server PK
        TEXT cluster
        TEXT url
        TEXT kube_user
        JSONB scopes
        JSONB limits
        TEXT granted_at
    }

Only the foreign keys in the diagram exist. kube_identity_links, people_* and run_agent_tools reference nothing outside themselves. run_agent_tools is written before the run row exists, so it has no foreign key. people_grants.client_id has no foreign key to people_dynamic_clients: most client IDs are registered names or URLs. The registration sweep deletes the approvals of a dyn- client ID that has no row.

10.2 Migrations

MigrationChangeWritten byRead by
0059_run_sandboxesTable run_sandboxes: sandbox_id (PK), run_id (FK runs, cascade), pod_name (FK work_pods, cascade), bound_at; index on pod_namebindings::bind from the bind route (crucible-controller/src/agent_auth/bindings.rs:150)bindings::resolve at the token endpoint (bindings.rs:239)
0060_user_kube_userColumns users.kube_user and users.kube_user_overridekube_user_override: PUT /api/users/{login}/kube-user. kube_user: nothing nowkube_identity::override_for_sub on the hub (crucible-controller/src/identity/kube_identity.rs:64). kube_user is read by nothing
0061_run_sandbox_namesColumns run_sandboxes.sandbox_name, run_sandboxes.workdir; a unique index on the namethe bind routethe token endpoint, for the sandbox and workdir claims
0062_dispatch_principalColumns issues.dispatch_requested_by, runs.dispatched_byissues::store::request_dispatch (unpark, redispatch, bump, scope, paired approval); runs::store::stamp_dispatcher at run creation, which also clears the requestbindings::resolve (the launcher)
0063_run_sandbox_names_not_globalDrops the unique name indexThe bind transaction checks a name only against live bindings on the same cluster
0064_kube_identity_linksTable kube_identity_links (sub, cluster) PK, kube_user, source, linked_atkube_links::link (paste and OAuth link), kube_links::unlinkcluster_identity::resolve for people tokens, agent tokens, agent tools, settings and consent
0065_people_grantsTables people_codes, people_grants, people_refresh_tokens, with indexes on sub and grant_idPgGrantStore (crucible-controller/src/agent_auth/people/grants.rs)PgGrantStore
0066_run_agent_toolsTable run_agent_tools, PK (run_id, server)agent_tools::grant at dispatch (crucible-controller/src/runs/agent_tools.rs:263); agent_tools::attribute sets kube_user after the run row is committed (agent_tools.rs:283)the run DTO and the issue DTO, for the run tools panel
0067_people_grant_clientsColumns people_grants.client_id and people_grants.client_name, NOT NULL. Rows from before get claude-code and Claude Code. Index on (sub, client_id)PgGrantStore::issue_refreshrotate_refresh (the client check), live (Settings), revoke_client
0068_people_dynamic_clientsTable people_dynamic_clients: client_id (PK), client_name, redirect_uris, created_at, last_used_at; index on last_used_atDcr::register; Dcr::used stamps last_used_at after a successful code or refresh exchange (crucible-controller/src/agent_auth/people/dynamic.rs)Dcr::client at authorize and token, without stamping; Dcr::sweep deletes idle rows

Secrets in people_codes and people_refresh_tokens are stored only as SHA-256. A spent refresh token stays in its row with used_at until its approval goes, so a reuse is recognized.

Note: users.kube_user is no longer written or read. An earlier design learned it passively from cluster tokens. The hub username now lives in kube_identity_links like any other cluster, plus the override. The header comment in migrations/0060_user_kube_user.sql still describes that earlier design. It stays, because sqlx checksums each migration file and an edit would fail databases that already applied it.

11. Operations

11.1 Deploy order

Do these steps in this order. The issuer must not issue agent tokens before gateway isolation is in place.

  1. Build the images from the same commit: the loop image, the controller image and the crucible-kueue image (docker/Containerfile.kueue). The loop image must contain crucible openshell-gateway, openshell-gateway and buildit.
  2. On each cluster that hosts runs, install SPIRE with the OIDC discovery provider. Add a ClusterSPIFFEID for supervisor pods in the sandbox namespace, with jwtTtl: 5m and the template in section 6.1.
  3. In the deploy profile, set sandbox_namespace and sandbox_readers.
  4. Render the loop RBAC with crucible deploy render --manifest <render.toml> --profile <profile>. Apply the ServiceAccount, Roles and bindings on each cluster. They include crucible-spire-reader, which lets sandbox_readers discover the cluster's SPIRE (section 6.9).
  5. With [buildit], create [buildit] namespace, and make sure that step 4 applied the Role and RoleBinding <service account>-buildit there (section 6.6). Builder pods run as that namespace's default service account; on OpenShift bind it to an SCC that admits them.
  6. Render crucible-kueue with crucible deploy render --kueue. Apply it on each cluster in [kueue] clusters. Copy the declared pull secrets into the workload namespace. Create the LocalQueues.
  7. Grant people their queues with ordinary RBAC (section 11.2).
  8. Create the issuer key Secret in the controller namespace. Mount it in each replica.
  9. Set the controller environment (section 9.1, overrides in 9.3). Each cluster an agent server serves has its SPIRE discovered; check GET /api/agent-clusters after the restart. Make sure that loop pods reach the controller at [cluster] ingest_url. Restart the controller.
  10. For people tokens: set CONTROLLER_AUTH_MODE=native and [kueue] people = true (with people_urls where people reach a cluster's server by a route), render crucible-kueue again, and expose it on a Route. To change the clients, set CONTROLLER_PEOPLE_CLIENTS_FILE, CONTROLLER_PEOPLE_CIMD_HOSTS or CONTROLLER_PEOPLE_DCR (section 4.1).
  11. For OAuth links: render crucible deploy render --cluster-link <cluster> --issuer <issuer> and apply it on that cluster with a new secret. Put the same secret in the CONTROLLER_CLUSTER_LINK_SECRETS directory under the cluster name.

To make the issuer key, use this command:

openssl ecparam -genkey -noout -name prime256v1 | openssl pkcs8 -topk8 -nocrypt -out agent-signing.pem

11.2 RBAC per spoke

Section 6.6 lists the gateway, controller and loop rights. crucible deploy render --kueue gives the server account:

  • cluster-wide: clusterqueues and resourceflavors get and list; nodes get and list; subjectaccessreviews create; with openshift, groups.user.openshift.io list (crucible/src/deploy/kueue.rs:262);
  • in the workload namespace only (kueue.rs:295):
    • localqueues: get, list;
    • Kueue workloads: get, list, patch;
    • localqueues/pendingworkloads: get, list;
    • the seven kinds: create, get, list, patch, delete;
    • pods, pods/log, events: get, list.

The workload namespace's labels follow the cluster's platform (section 11.2.1).

Each person, or the launcher of each run, needs RBAC for what they do. A minimum Role for one queue is this:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: team-queue-user
  namespace: team-workloads
rules:
  - apiGroups: [kueue.x-k8s.io]
    resources: [localqueues]
    verbs: [list]
  - apiGroups: [kueue.x-k8s.io]
    resources: [localqueues]
    resourceNames: [team-queue]
    verbs: [get, use]
  - apiGroups: [kueue.x-k8s.io]
    resources: [workloads]
    verbs: [get, list]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [create, get, update, delete]
  - apiGroups: [""]
    resources: [pods, pods/log]
    verbs: [get, list]

Note: use is a verb that only crucible-kueue asks about. Where a cluster cannot grant it, run the server with --queue-verb get.

11.2.1 OpenShift and plain Kubernetes

Each cluster's distribution is detected: the controller asks its API server whether it serves security.openshift.io. [cluster] platform for the hub and [clusters.<name>] platform for a spoke override that, and a render without cluster access (crucible deploy render --kueue) takes --platform, then the key, then kubernetes. A render for a cluster reads only that cluster's entry, so one OpenShift hub can dispatch to an OpenShift spoke and a Kubernetes spoke, each rendered for itself; the hub's own platform affects only what is rendered for the hub, and the Argo chart handles the hub's ingress and deploy.

platform = "openshift" (tested on OpenShift 4.22)platform = "kubernetes" (tested on Kubernetes 1.35)
Pod admissionSCCs, which the operator grants: the sandbox account an SCC for OpenShell's supervisor, the loop account one that gives its pod a writable home, and the buildit namespace's default account one for rootless buildah (an SCC broad enough for buildah may allow privileged pods; the builder admission policy is the bound)Pod Security labels, which the operator sets: loop, rig, sandbox and workload namespaces baseline, crucible-kueue's namespace restricted, the buildit namespace privileged, with the builder admission policy as the guard
crucible-kueue workload namespace (rendered)pod-security.kubernetes.io/enforce: restricted (SCCs fill in its fields) and kueue.openshift.io/managed: "true"enforce: baseline; no Kueue label: upstream Kueue manages what its managedJobsNamespaceSelector admits
crucible-kueue kube_authz defaultopenshift: the user's Group objects, plus system:authenticated and system:authenticated:oauthtoken: the token's kube_groups claim, plus system:authenticated
Kubernetes usernamesOAuth usernames (alice)what the API server's OIDC mapping makes them, such as an email; labels carry it in label-safe form, its safe characters and a hash
Cluster-token bearer auth (CONTROLLER_KUBE_USER_AUTH)SelfSubjectReviewSelfSubjectReview
Cluster linksTokenReview paste, or the OpenShift OAuth flow ([cluster_link], an OAuthClient)TokenReview paste only
SPIREZero Trust Workload Identity Manager; its OIDC discovery Service over https on 443upstream SPIRE Helm chart; its discovery Service over http on 80 (http:// before the namespace in CONTROLLER_AGENT_SPIRE); a ClusterSPIFFEID must name spire-controller-manager's className when it watches no classless entries
Builder admission policyenforced (OpenShift 4.17+)enforced (1.30+)

crucible-kueue's own pod sets every field restricted asks for, RuntimeDefault seccomp included, so its namespace can enforce restricted on either platform. API discovery tells the platforms apart by security.openshift.io (the SCC API); config.openshift.io is no evidence, as other distributions serve it.

11.3 SPIRE requirements

  • The supervisor JWT-SVID must carry iat, and exp minus iat must be at most 900 seconds. The upstream SPIRE chart issues 1-hour JWT-SVIDs by default. Set jwtTtl: 5m on the supervisors' ClusterSPIFFEID.
  • The aud of the SVID must be exactly the issuer identifier.
  • The SPIFFE ID must be <gateway prefix><gateway id>/sandbox/<sandbox id>. Scope the ClusterSPIFFEID to the sandbox namespace.
  • The discovery provider must serve /keys. The controller reads it through the API server Service proxy. Discovery reads it at the platform's default; an install elsewhere is named in CONTROLLER_AGENT_SPIRE as <namespace>/<service>:<port>, with http:// first when the Service serves plain http.
  • Each cluster needs its own SVID issuer: a token request is matched to its cluster by the SVID's iss.

11.4 Rollback

To disable the feature with no data change, do these steps:

  1. Unset CONTROLLER_AGENT_ISSUER and CONTROLLER_AGENT_SPIRE.
  2. Restart the controller. The routes, tasks, env and writes in section 9.8 stop.
  3. Remove [kueue] and [buildit] from the profile if new runs must not get the servers.
  4. Delete the gateway objects that remain in the sandbox namespace by label crucible.dev/role=openshell-gateway, if the sweep no longer runs.

To roll the controller image back to a build without migrations 0059 to 0068, also revert the schema. The migrator does not ignore applied migrations that it does not know (crucible-controller/src/lib.rs:417), so an older controller does not start on the new schema. To roll back only to a build with 0066 and without 0067 and 0068, run the second script.

Warning: This SQL deletes the bind records, links, approvals, client registrations and agent tools records. Back up the database first. Stop every controller replica before you run it.

BEGIN;
DROP TABLE people_dynamic_clients;
DROP TABLE run_agent_tools;
DROP TABLE people_refresh_tokens;
DROP TABLE people_grants;
DROP TABLE people_codes;
DROP TABLE kube_identity_links;
ALTER TABLE runs DROP COLUMN dispatched_by;
ALTER TABLE issues DROP COLUMN dispatch_requested_by;
DROP TABLE run_sandboxes;
ALTER TABLE users DROP COLUMN kube_user_override;
ALTER TABLE users DROP COLUMN kube_user;
DELETE FROM _sqlx_migrations WHERE version BETWEEN 59 AND 68;
COMMIT;

The second script keeps the approvals of claude-code only. An older controller knows no other client.

BEGIN;
DROP TABLE people_dynamic_clients;
DELETE FROM people_grants WHERE client_id <> 'claude-code';
DROP INDEX people_grants_sub_client;
ALTER TABLE people_grants DROP COLUMN client_name;
ALTER TABLE people_grants DROP COLUMN client_id;
DELETE FROM people_codes;
DELETE FROM _sqlx_migrations WHERE version IN (67, 68);
COMMIT;
ComponentMessageWhat it tells you
controllerissuing agent access tokensThe issuer started; lists the SPIRE clusters and audiences
controllerissuing access tokens to peoplePeople tokens are on
controlleragent token issuedFields sandbox, spiffe_id, run_id, sub, kube_user, aud, scope
controlleragent token refusedField reason gives the cause the client does not see
controlleragent token minted without kube_userA non-kueue audience for a launcher with no username
controllersandbox boundA bind record was written
controllersandbox bind refused, sandbox bind could not reach the clusterA bind was refused, or the cluster did not answer
controllerthe bound sandbox could not be owned by its gateway podThe owner reference failed; teardown still deletes by label
controllerauthorization request refusedField reason, or error and reason when the error went to the redirect URI
controllerauthorization code issuedFields login, client_id, resource
controllerperson token issuedField grant is authorization_code or refresh_token; field client_id
controllerperson token refusedField reason
controllerrefresh token reused; approval revokedReuse detection fired
controllerrefresh token presented by another client; approval revokedFields issued and presented name the two client IDs
controllerdynamic client registered, client registration refusedA registration at /oauth/register, with client_id and source, or the reason
controllerclient metadata document fetchedA fetch of a metadata document, with client_id and ttl_secs
controlleridle client registrations sweptThe sweep deleted registrations idle for 30 days
controllermcp clients signed outA sign-out from Settings; client_id is absent for a sign-out of every client
controllerkubernetes username linkedA link was written
controllera failed launch left its gateway, deleting the run's gateway failedThe sweep removes the rest
controllergateway sweep: removed an orphaned gatewayThe sweep found a gateway with no live run
enginesandbox bind failed (its agent token requests will be refused)The sandbox gets no tokens
crucible-kueuetoken refused, caller authenticatedAuthentication result, with sub, presenter, run, namespace
crucible-kueuetoken identifies no caller this server servesNo kube_user, a person without a reviewer, or run_id in a people token
crucible-kueuerequest refusedA guard refusal, with the rule
crucible-kueuesubject access review (debug)Each review with user, verb, resource and decision
crucible-kueueworkload submittedA create succeeded
builditcaller authenticated, token refused, pushedAuthentication and push results

11.6 Common failures

SymptomCauseFix
The gateway pod exits at once; its log names an unknown subcommand or an absent openshell-gatewayThe loop image predates this feature and lacks crucible openshell-gatewayBuild the loop image from the same commit as the controller
agent token refused with reason about the assertion lifetimeSPIRE issues 1-hour JWT-SVIDsSet jwtTtl: 5m on the supervisors' ClusterSPIFFEID
Dispatch fails with PushOnlyClusterThe hub has a push token and no client for the clusterPut a kubeconfig for the cluster in CONTROLLER_CLUSTERS_DIR, or use a profile that is not isolated for it
The Job is admitted but its pods are in ImagePullBackOffThe declared pull secret does not exist in the workload namespaceCopy the Secret into the workload namespace with the name in [kueue] pull_secrets
The agent turn fails at the first model callThe launch named no --model, and the endpoint does not serve the provider's default_modelLaunch with --provider <name> --model <model>, or fix the provider's default model
On OpenShift the loop pod fails to stage the gateway client materialThe loop pod runs with an arbitrary UID and HOME=/Bind the loop service account to the SCC that gives the loop pod a writable home
Every token request is invalid_grant with reason "bound to no run"The loop pod never bound its sandboxes: no ingest_url in the profile, or no route to the controllerSet [cluster] ingest_url to an address loop pods reach; on a spoke expose /api/sandbox-bindings and /oauth/token
invalid_grant with a reason about kube_userThe launcher has no username on the run's clusterLink it in Settings, set a claim rule, or set the hub override
The first isolated run parks with a 403 on the server TLS SecretThe controller identity lacks pods/finalizers update in the sandbox namespaceRe-render and apply the loop RBAC
server_error with SPIRE keys unavailableNo services/proxy get in the SPIRE namespace, or a SPIRE outside its platform's default namespace with no CONTROLLER_AGENT_SPIRE entryGrant the right; check the namespace, name, port and scheme
A people token gets 403 from crucible-kueueThe server trusts the issuer only as --run-issuerAdd the same issuer as --person-issuer
A refresh suddenly returns invalid_grantReuse detection after a retried refresh, an unlinked username, or a refresh token presented as another clientApprove the client again; check the controller log for the reason
A refresh returns invalid_clientThe client registry no longer lists the client, the metadata host is no longer allowlisted, or the registration was idle for 30 daysRegister the client again, or sign in under a registered client
The authorize page says that the redirect URI is not registered for claude-codepi signs in with claude-code and a 127.0.0.1 callback. claude-code registers only localhostAdd the server to pi again without OAuth options, or with --oauth-client-id pi
Codex stops with "Dynamic client registration not supported"CONTROLLER_PEOPLE_DCR=false, and Codex has no client idPass --oauth-client-id codex, or turn registration on
The authorize page refuses an https:// client IDCONTROLLER_PEOPLE_CIMD_HOSTS is unset or does not list the host, or the document failed a checkAdd the host to CONTROLLER_PEOPLE_CIMD_HOSTS; check the page for the reason
POST /oauth/register returns 429 for many peopleEvery registration counts against the router address: CONTROLLER_PEOPLE_TRUSTED_PROXIES does not cover the router, or the router does not append X-Forwarded-ForSet CONTROLLER_PEOPLE_TRUSTED_PROXIES to the network the router connects from, and make the router append X-Forwarded-For

12. Tests

12.1 Unit tests

  • crucible-mcp-auth tests use real ES256 keys and a JWKS server on loopback (crucible-mcp-auth/src/testing/mod.rs). They cover algorithms, kid and issuer checks, audience, leeway, scope challenges, the guard decisions and the CEL policy.
  • crucible-kueue tests cover the manifest allowlist, normalization, plan hashes, pull secrets, the claim map, access checks for each tool, and task ids. crucible-kueue/tests/task_auth.rs drives the HTTP app in process.
  • buildit tests cover token callers, push targets, context paths and sanitization.
  • The people client tests cover the client ID forms, the redirect rules of each built-in client, the registry file (crucible-controller/src/agent_auth/people/clients.rs), and the registration checks, name cleanup, source address and rate limiter (people/dynamic.rs).
  • The metadata document tests serve documents on loopback. They cover the allowlist, the document checks, the timeout, the cache lifetime, and names that resolve to special-use addresses (people/cimd.rs).
  • The engine tests cover the token-grant profiles, the partial-env refusals and the bind decision.
  • The render tests cover the gateway objects and the network policies. They also prove that the loop service account has no verbs in the sandbox namespace under an isolated profile.
  • The UI has unit tests for each view (crucible-controller/ui/src/pages/*View.test.ts) and Playwright tests for consent and settings (crucible-controller/ui/e2e/consent.spec.ts, settings.spec.ts).

12.2 Postgres tests

The controller tests that touch the ledger are #[sqlx::test] tests. They need a live Postgres at DATABASE_URL; each test gets a fresh database. just dev-pg starts one on port 55432. These tests cover these areas:

  • the token endpoint against real bind records, and the bind route;
  • the people grants: single-use codes, rotation, reuse, idle and absolute expiry, and a code or refresh token from one replica that another replica redeems;
  • the client binding: a refresh as another client revokes the approval, and a sign-out of one client keeps the other clients;
  • registrations: the issued id, the rate limit and cap, and the idle expiry that deletes the approvals;
  • cluster links, consent, the hub override, dispatcher stamps and the agent tools record.

12.3 Cluster tests

These tests are #[ignore] and need a cluster. Their headers list the environment they read.

TestWhat it proves
crucible-controller/tests/kueue_oidc_e2e.rsA real JWT-SVID becomes an agent token at the controller, and the token drives crucible-kueue with RBAC as kube_user
crucible-controller/tests/kueue_gateway_e2e.rsThe controller starts a real gateway pod; a supervisor it creates gets a gateway-keyed SPIFFE ID; the token works; teardown removes everything. Prepare with just gateway-e2e-setup
crucible-kueue/tests/cluster_e2e.rsThe server against a real Kueue
buildit/tests/oidc_e2e.rsbuildit with real tokens

12.4 The full e2e harness

just kueue-e2e runs scripts/kueue-e2e.sh. A controller from this checkout runs in the cluster with its own Postgres. It dispatches e2e/kueue/pack with an isolated gateway and a crucible-kueue server of its own. The agent writes a program that prints a nonce, builds and pushes it with buildit, and runs it as a Job through crucible-kueue. The script then checks:

  • the controller issued buildit and crucible-kueue tokens to a SPIFFE ID under this cluster's gateway prefix, for the launcher's kube_user and this run;
  • the Job carries the run, launcher and queue labels, and uses only the e2e pull secret;
  • the Job runs an image pushed by digest, succeeded, and printed the nonce;
  • the agent's whoami named the same run and kube_user;
  • no kueue token appeared in the loop pod env, its Secrets or any log;
  • the builder admission policy refuses a privileged pod in the buildit namespace.

It runs on OpenShift and plain Kubernetes alike. It detects the platform (security.openshift.io served or not, or PLATFORM) and derives the rest from it, each value overridable: SPIRE's namespace, discovery Service, trust domain and JWT issuer (the Zero Trust Workload Identity Manager's resources, or the upstream spire-server ConfigMap), the ClusterSPIFFEID class, the profile's platform, the served Kueue API version, and pod admission (SCC role bindings, or Pod Security labels). A kubeconfig without a static token has its exec credential plugin run for the launch token. just kueue-e2e-cluster <name> reads a cluster's values from e2e/kueue/clusters/<name>.env, which git ignores; openshift.env.example and kubernetes.env.example show what each platform needs, and the script header lists every value.

PACK=<dir> (a pack argument to the recipes) launches another pack instead of the smoke pack, bounded by MAX_COST, MAX_TIME and TIMEOUT_SECS, with PARAMS as its --params. Its prompts get the same placeholders filled. The Job, nonce and whoami checks belong to the smoke pack; another pack passes when its run left at least one Job in the workload namespace and the plan's RESULT_TASK (default verify) ended pass, and the harness prints that task's output from the graph API either way. Every pack gets the token issuance, loop isolation and leak checks.

The harness runs beside a live deployment on the same cluster:

  • It uses its own namespaces, which carry the label crucible.dev/e2e=kueue.
  • It renames each cluster-scoped object with the suffix -kueue-e2e and labels it.
  • Its ClusterSPIFFEID selects only its own sandbox namespace.
  • It refuses to start when one of its namespaces exists without the label.
  • Teardown deletes only by the label. KEEP=1 keeps everything.

Note: A launch with a cluster token records no users row. The harness inserts the launcher's users row and a hub kube_identity_links row, as a login and a link would.

13. Multitenancy later

No API, token, configuration format or SPIFFE ID assumes a single tenant. Teams become a data change: nothing below needs a new shape, only values per team. Nothing here writes a team today, and every one of these defaults to the single-tenant behaviour.

13.1 What is tenant-ready

ShapeToday's valueWhere
Token needs per audienceEach audience carries requires_kube_user and namespaces, filled from the profile's server descriptions; the issuer names no serverAudience::described_by, DeployProfile::agent_servers
Namespaces as a setTokens carry namespaces (the first is also namespace); crucible-kueue confines a caller to the claim within the namespaces it serves; [kueue] workload_namespaces is a listIdentity::act_in, crucible-kueue/src/caller.rs
Open server list[kueue], [buildit] and any [[agent_servers]] reach the loop pod as one CRUCIBLE_AGENT_SERVERS list keyed by ServerName; buildit's URL is configurationcrucible-contract/src/agent_servers.rs
Principal-shaped identityIssuance and grant records resolve an authz::model::Principal; a person through their sign-in, a run through its dispatcher; groups and teams are refused with a typed errorcluster_identity::for_principal
Tenant in the SPIFFE IDA gateway prefix may hold one {tenant} segment; the default has noneGatewayPrefix, section 6.1
Cluster bindingAgent audiences may name a cluster, tokens carry kube_cluster, and a server started with --cluster refuses another'ssection 2.2
One cluster-name ruleProfile spokes, server tables and every controller setting use check_cluster_namecrucible/src/deploy/profile.rs
Several servers per clustercrucible-kueue's managed-by value and field manager come from --managersection 7.12

13.2 What a team rollout still adds

  • A team per run and per person. runs, run_agent_tools and people_grants carry no team. The controller already knows teams (authz::model::Principal::Team); dispatch would record the run's team and the issuer would mint it as a team claim (issuer.rs, people.rs). The tool policy sees every claim, so CEL rules can use it at once.
  • Values per team. Server descriptions, and so audiences' namespaces, [buildit] push_repo and audience scopes, would be looked up by team as well as by URL and cluster. The shapes they fill are already lists in tokens and in CRUCIBLE_AGENT_SERVERS.
  • A team's own identity. A Team arm in for_principal resolving the team's account on each cluster, with storage for those accounts (the one schema change).
  • A tenant in each sandbox's SPIFFE ID. The gateway pod would carry a tenant label, the ClusterSPIFFEID would put it before gateway, and the issuer would check the tenant it parses against the run's team.
  • People. Client registry entries or resources per team, and identity rules per group, so one cluster can trust SSO usernames for some teams and links for others.

The ceiling stays the same in each case: a token can do at most what its kube_user can do.

13.3 No replay tracking for client assertions

The token endpoint does not remember the JWT-SVID assertions it accepts (crucible-controller/src/agent_auth/issuer.rs, MAX_SVID_LIFETIME_SECS), and it should not:

  • The SPIRE agent caches JWT-SVIDs per SPIFFE ID and audience, and returns the cached one until it nears expiry (FetchJWTSVID, pkg/agent/manager/manager.go in spiffe/spire). A supervisor that exchanges for several MCP audiences, or refreshes a token, presents the same assertion again. Refusing a repeat would refuse those exchanges.
  • SPIRE sets jti on a JWT-SVID only when the registration entry opts in (jwt_svid_include_jti, which also bypasses that cache; BuildWorkloadJWTSVIDClaims, pkg/server/credtemplate/builder.go). The JWT-SVID specification does not require it. Keying replays on jti would refuse every assertion from a default entry.
  • The assertion lives at most 900 seconds (SPIRE's default JWT-SVID TTL is 5 minutes), names only this issuer as its audience, and is fetched from the Workload API socket that the OpenShell driver hands to the sandbox supervisor (provider_spiffe_workload_api_socket_path), not to the agent it sandboxes.