Headless enrollment

Servers, containers and routers cannot complete an interactive login: there is no browser, and no person to log in. Such a machine enrolls in one of two ways: with an OIDC token that it already holds, or with a bootstrap token that an operator mints for it. This guide covers both, and then explains what happens when the credential of such a node expires.

With an OIDC token that the workload already has

If the platform gives the workload a token from an issuer that the control plane trusts, no operator step is needed. Mark workload issuers with --workload-issuer on the control plane so workload tokens can enroll, refresh, and exchange credentials (POST /register, POST /refresh, POST /token/exchange), while being refused at human operator endpoints (/user/*, /oauth/authorize).

Kubernetes projected service account tokens

On Kubernetes, mount a projected service account token with the control plane’s audience:

volumes:
  - name: agentmesh-token
    projected:
      sources:
        - serviceAccountToken:
            path: agentmesh-token
            expirationSeconds: 3600
            audience: agentmesh-audience
agentmesh-node run --control-plane https://mesh.example.com --jwt-path /var/run/secrets/tokens/agentmesh-token

List the cluster’s issuer in --issuer (controlPlane.oidcIssuer in the Helm chart) and the audience in --allowed-audiences; add the cluster issuer to --workload-issuer (controlPlane.workloadIssuer) as well to keep workload tokens off /user/* and /oauth/authorize. Bind either a specific service account (user:system:serviceaccount:<namespace>:<name>) or a namespace prefix (user:system:serviceaccount:<namespace>:*) to mesh:role:node. Routers enroll the same way with agentmesh-router --jwt-path. The Kubernetes guide shows the complete setup.

GCE VMs and Cloud Run (--cloud-provider)

On Compute Engine and Cloud Run there is no Kubernetes projected volume, so agentmesh-node can fetch an OIDC identity token directly from the instance metadata server (.../computeMetadata/v1/instance/service-accounts/default/identity?audience=<aud>&format=full):

agentmesh-node run --control-plane https://mesh.example.com --cloud-provider gcp

--cloud-provider auto probes the metadata server at startup and uses it when reachable. GCE VM metadata tokens requested with format=full carry a google.compute_engine claim that the control plane recognizes automatically, whereas Cloud Run metadata tokens do not carry that claim. On the control plane, list https://accounts.google.com in --issuer and pass the service-account suffix filter in --workload-issuer so service accounts on both GCE and Cloud Run are treated as workload tokens while human Google accounts remain able to use /user/* and /oauth/authorize:

agentmesh-control-plane \
  --issuer https://accounts.google.com \
  --workload-issuer https://accounts.google.com=.gserviceaccount.com \
  --allowed-audiences agentmesh-audience

In the mesh policy, bind the service account email or project suffix (for example, email:*@my-project.iam.gserviceaccount.com) to mesh:role:node.

SPIFFE / SPIRE (spiffe-helper + --jwt-path)

For VMs and bare metal running SPIRE (or multi-cluster meshes sharing a SPIFFE trust domain), run spiffe-helper beside agentmesh-node to fetch a JWT-SVID from the SPIFFE Workload API and keep the file at --jwt-path rotated across the SVID’s lifetime:

# spiffe-helper.conf
agent_address = "/run/spire/sockets/agent.sock"
cert_dir      = "/run/agentmesh"
jwt_svids     = [{ jwt_audience = "agentmesh-audience", jwt_svid_file_name = "jwt_svid.token" }]
agentmesh-node run --control-plane https://mesh.example.com --jwt-path /run/agentmesh/jwt_svid.token

List the SPIRE OIDC Discovery Provider URL in --issuer and --workload-issuer on the control plane, and bind the SPIFFE ID or path prefix (user:spiffe://example.org/ns/prod/*) to mesh:role:node.

OAuth client credentials

A workload with an OAuth client ID and secret can use the client-credentials grant instead, with --oidc-issuer, --client-id and --client-secret-path (or AGENTMESH_CLIENT_SECRET).

With a bootstrap token

For a machine that has no identity of its own, an operator mints a token.

1. Mint

Call the control plane API with the admin token. agentmesh-one prints the admin token at start and writes it to its data directory. A separate agentmesh-control-plane reads it from the file given by --admin-token-path or from the AGENTMESH_ADMIN_TOKEN environment variable.

curl -fsS -X POST https://mesh.example.com/admin/bootstrap-tokens \
  -H "Authorization: Bearer $AGENTMESH_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role":"mesh:role:node","ttl_hours":24,"max_usages":1,"description":"build-runner-3"}'
{"id":"62e92ffca…","token":"mesh-bt-72fb0175788dee0…","role":"mesh:role:node","expire_time":"2026-09-20T15:00:00Z"}

The plaintext token is shown once. The control plane keeps only its hash. role is the role that the token enrolls into (mesh:role:router for a router). ttl_hours defaults to 24 and max_usages to 1. The Bootstrap Tokens view in the console and agentmesh-one token create do the same thing. An OIDC user who is already enrolled can mint tokens for their own machines through POST /user/bootstrap-tokens with their ID token. Such tokens record the user as owner, and banning the user disables them.

Write the token to a file on the target machine. The node reads it from a file and not from a flag, so it does not appear in process listings or in the shell history.

2. Enroll

agentmesh-node join https://mesh.example.com --bootstrap-token-path /etc/agentmesh/bootstrap-token

or, to enroll and start in one step:

agentmesh-node run --control-plane https://mesh.example.com --bootstrap-token-path /etc/agentmesh/bootstrap-token

The node submits its public key, the token, and a signature over a fresh challenge to POST /enroll. If the control plane runs with --auto-approve-enrollment (the default in the Helm chart and in agentmesh-one), the credential comes back immediately. Otherwise the request is queued and the node polls until an administrator decides.

3. Approve

curl -fsS https://mesh.example.com/admin/enrollments -H "Authorization: Bearer $AGENTMESH_ADMIN_TOKEN"
curl -fsS -X POST https://mesh.example.com/admin/enrollments/<request-id>/approve \
  -H "Authorization: Bearer $AGENTMESH_ADMIN_TOKEN"

.../reject refuses the request. The console lists pending requests with the same two actions. Approval spends one usage of the token and checks again that the token is still valid. If the token was revoked, expired or used up between the request and the approval, the approval fails. It also fails if the node declared a label that the token’s role does not permit.

Labels

A node enrolled with a bootstrap token declares labels in its configuration file, like any other node, and the token’s role must allow them through allowed_labels. Approval by an administrator does not bypass this check. The grant decides which labels a node may carry.

Revoking a token

curl -fsS -X DELETE https://mesh.example.com/admin/bootstrap-tokens/<id> \
  -H "Authorization: Bearer $AGENTMESH_ADMIN_TOKEN"

The token stays in the list, marked as revoked, so the record of what it enrolled is kept. Nodes that already enrolled with it are not affected. To remove one of those, ban the node.

When the credential expires

A node or router enrolled with a platform token (--jwt-path, --cloud-provider, or --client-id) presents a fresh platform JWT in TokenRefreshRequest.jwt on every POST /refresh. As long as the token’s iss|sub matches the enrolled record, the control plane re-attests the member in place, updates its stored claims and extends its session (--workload-session-ttl, 48h by default) without re-enrolling. If the member was offline longer than the session or key grace period, its renewal loop automatically re-enrolls with a fresh platform token under the same peer ID.

A node enrolled with a bootstrap token has no login or platform token source to fall back on. Its credential is refreshed automatically while it runs. But if the node is off for longer than the control plane’s key grace period (--key-grace-period, one hour by default), it comes back with a credential signed by a retired key, and /refresh refuses it. An SDK member in the same position enrolls again on start when its enroll call is given a token, and otherwise fails with CredentialRetiredError naming the state directory. There are three ways out, in order of preference.

Enroll again. Mint a new token and run agentmesh-node join with it. The control plane already knows the peer ID, so it mints a new credential directly instead of queueing a new approval, as long as the token’s role matches and the node is not banned. Each re-enrollment spends one token usage, so max_usages limits how often a machine can be brought back this way.

Widen the grace period. A longer --key-grace-period on the control plane gives a quiet node more time to refresh. The trade-off is that a machine that has been lost or stolen can keep renewing for the same time.

Allow autonomous recovery. A node whose record has autonomous_recovery set may refresh on proof of its own key alone, even after the signing key that issued its credential is gone. Set it on the token so that every node enrolled with it inherits the flag, or set it on one node afterwards:

# on the token, at mint time
-d '{"role":"mesh:role:node","max_usages":10,"autonomous_recovery":true}'

# on an enrolled node
curl -fsS -X POST https://mesh.example.com/admin/nodes/<peer-id>/autonomous-recovery \
  -H "Authorization: Bearer $AGENTMESH_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled":true}'

This is off by default, because a node that can always recover holds a credential that never expires in practice. Only a ban stops it. It suits fleets where an operator round trip per stale node is impractical and the machines are controlled in other ways.

Banning

curl -fsS -X POST https://mesh.example.com/admin/revoke \
  -H "Authorization: Bearer $AGENTMESH_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"peer_id":"12D3KooW…"}'

The node’s next refresh fails and its daemon exits. Connected peers drop it as soon as the ban event reaches them. POST /admin/nodes/<peer-id>/unban reverses the ban. agentmesh-control-plane admin ban --peer <id> and unban do the same directly in the database, for cases where the API is not reachable.