Skip to content

plexd agent contract

The plexd agent is the first external integration of the platform API. It predates this control plane — it was developed against a mock — and is being ported onto the binding contract, which is the OpenAPI document at api/openapi/plexsphere-v1.yaml. This page is the consolidated agent-eye view of that contract: which operations make up the node-agent surface, how each is authenticated, which integration gaps are open on the control-plane side, and which mock-era surfaces were deliberately not adopted.

The machine-checked twin of this page is the workspace gate in tests/workspace/plexd_agent_contract_test.go, which pins the operation inventory, the per-operation auth posture, the absence of the retired paths, and the bootstrap/liveness/rotation/ snapshot wire shapes. Contract movement shows up there as an explicit table edit.

Credential model

A node holds exactly one long-lived credential: the Node Secret Key (NSK) returned once by POST /v1/register. The bootstrap token redeemed at registration is minted by an operator via the bootstrap-tokens surface and consumed on first use. After registration the NSK rides in Authorization: Bearer on every agent call and doubles as the AES-256-GCM key under which the secrets surface rewraps payloads.

Operation inventory

OperationAuthPurpose
POST /v1/registerbootstrap token (body)redeem token, receive identity, NSK, signing key, peer snapshot, mesh CIDR
POST /v1/nodes/{id}/heartbeatNSKliveness; returns reconcile and rotate_keys hints
PUT /v1/nodes/{id}/endpointNSKNAT endpoint observation; expires at stale_after
GET /v1/nodes/{id}/stateNSK or operatorreconciliation pull — the baseline delivery channel; carries the pending-dispatch executions and sessions blocks
GET /v1/nodes/{id}/eventsNSK or operatorsigned event stream (currently descoped, answers 501)
GET /v1/nodes/{id}/reachabilityNSK or operatorthe node's own health projection
PUT /v1/nodes/{id}/capabilitiesNSKcapability manifest snapshot
POST /v1/nodes/{id}/integrity-violationsNSKtamper-evidence batches
POST /v1/nodes/{id}/metricsNSKmetric samples (JSON array)
POST /v1/nodes/{id}/logsNSKlog lines (NDJSON)
POST /v1/nodes/{id}/auditNSKaudit events (NDJSON)
GET /v1/nodes/{id}/secrets/{name}NSKNSK-rewrapped secret envelope
GET /v1/nodes/{id}/peers/{peer_node_id}/pskNSKNSK-rewrapped pairwise edge-PSK envelope
PUT / DELETE /v1/nodes/{id}/state/reports/{key}NSKnode-authored state reports
POST /v1/keys/rotateNSKmesh key-rotation completion
POST /v1/nodes/{id}/executions/{execution_id}NSKexecution status callback (ackstarted → terminal)
POST /v1/nodes/{id}/sessions/{session_id}NSKper-kind session activity callback; a tcp session_started row also reports the listener endpoint the node bound
GET /v1/artifacts/plexd/{version}NSK or operatorindexed release record with per-architecture checksums and verdict
GET /v1/artifacts/plexd/{version}/sigstoreNSK or operatorverbatim Sigstore bundle for independent verification

Per-operation details live on the tag pages: nodes, node-events, capabilities, integrity, observability, secrets, actions, access, artifacts.

Known integration gaps

No control-plane gap currently blocks a real agent from operating end to end. The three surfaces that once did — the node-credential read surfaces, the dispatch channel, and PSK delivery — have all closed; each is recorded below with the shape it landed in:

  • Node-credential read surfaces (closed). The five reads marked "NSK or operator" above are now dual-credential. A node authenticates GET /v1/nodes/{id}/state, GET /v1/nodes/{id}/events, and GET /v1/nodes/{id}/reachability with its NSK: the NSK arm applies an equality gate — the authenticated node must equal the addressed {id} — with no ReBAC round-trip, so a cross-node NSK is 403 node_id_mismatch and a revoked one 401 nsk_revoked. The two plexd artifact reads accept a registered node's valid, non-revoked NSK directly — the release registry is platform-global and read-only, so the NSK arm makes no authorization check. The operator arm (session cookie / API token / OIDC plus the operator-shaped relation) is unchanged on all five.
  • Dispatch channel (closed). Execution dispatches and session setups now ride the reconciliation pull: GET /v1/nodes/{id}/state carries an executions block of the Node's pending action dispatches and a sessions block of the live mediated sessions it must serve (both always present, [] when empty). The agent acknowledges an execution through the NSK callback POST /v1/nodes/{id}/executions/{execution_id} and reports session activity through POST /v1/nodes/{id}/sessions/{session_id}, deriving both callback paths from its configured base — the pull blocks carry no callback URL. For a tcp session the agent also reports the listener it bound: the session_started row carries an optional listener_endpoint, the host:port on the node's mesh address. The control plane never invents that coordinate. It persists the reported value as the session's authoritative dial target and serves it on the session read, so an operator's client dials the listener the agent actually opened, and a session no report has landed for is refused 409 session_listener_pending at the browser-attach gateway. Because the platform dials what it stores, the reported host must be the mesh address the platform allocated that node — the value is checked against plexsphere.nodes.mesh_ip for the node whose NSK authenticated the call, and a coordinate on any other address (a loopback, a metadata endpoint, a sibling node's mesh address) is refused with 400 invalid_session_activity. An agent that binds on a wildcard address therefore reports its mesh address, not 0.0.0.0. A repeated session_started overwrites the stored value, so the latest report wins. A session_ended row must not carry the key: a coordinate is admitted only on the session_started row of a tcp session, and the platform refuses the whole callback with 400 invalid_session_activity otherwise. The pull is the baseline delivery channel; the Signed Event Bus slice that would push the same dispatches with lower latency is a later optimisation and stays descoped for now.
  • PSK delivery (closed). Peer projections still carry no preshared keys, but a node now fetches the pairwise WireGuard edge PSK over the NSK-authenticated GET /v1/nodes/{id}/peers/{peer_node_id}/psk. The control plane persists one wrapped PSK per Node; the server derives the per-tunnel edge key from the two endpoints' per-Node PSKs via HKDF-SHA256 (both ends derive a byte-identical value) and serves it rewrapped under the calling Node's NSK as a 60-byte AES-256-GCM envelope, so only NSK-wrapped ciphertext crosses the wire. Because the projections stay key-free by design, the agent runs a fixed re-fetch rule: fetch the edge PSK for a peer P when P first appears in a projection, when P's public_key changes, and after the agent completes its own key rotation. An unknown, soft-deregistered, or foreign-Domain peer is 404 peer_not_found; an endpoint whose per-Node PSK has not landed yet is 409 psk_not_ready and the agent retries on its next reconcile. A 429 per_node_rate_limited means the agent exceeded its per-Node fetch budget; it waits the Retry-After interval and resumes, and must not hot-loop. A 403 is terminal and never retriednode_deregistered in particular says the agent's own Node has been revoked, so retrying would poll the control plane forever after an operator decommissioned it. The agent must open the envelope with the additional authenticated data plexsphere/mesh/edge-psk/v1|<id>|<peer_node_id>|<epoch>, where <epoch> is the X-Plexsphere-PSK-Epoch response header used verbatim, and must refuse an envelope whose epoch is older than the one already in force for that edge — a mesh key rotation does not rotate the NSK, so a captured pre-rotation envelope would otherwise still open and reinstall the retired key. See nodes for the full response contract.

Retired mock-era surfaces

The agent's mock-era contract contained surfaces that were not adopted into the binding API. They stay out unless a recorded disposition brings one back:

Mock-era surfaceDisposition
GET /v1/pingprobe GET /v1/health instead
POST /v1/nodes/{id}/deregisterdecommission is operator-driven; no node-initiated removal
POST /v1/nodes/{id}/driftkept out; the agent applies corrections locally and nothing consumes a drift feed — a node-authored state report key (PUT /v1/nodes/{id}/state/reports/{key}) is the adoption path if visibility is ever needed
GET /v1/nodes/{id}/metadatanever consumed; dropped
POST /v1/nodes/{id}/report (bulk)replaced by per-key state reports
POST /v1/nodes/{id}/tunnels/{sid}/ready / closedreplaced by the session activity callback
POST /v1/nodes/{id}/executions/{eid}/ack / resultreplaced by the single execution callback
GET /v1/artifacts/plexd/{version}/{os}/{arch} (binary)replaced by the release-verdict record plus Sigstore bundle; binaries come from the release channel

Dev-stack and test posture

The confidentiality direction is one-way: the public agent is consumed into this repository (pinned by /PLEXD_VERSION, deployed by the dev stack), and nothing from this repository ever runs in or flows into the public agent repository — the agent's own CI tests exclusively against its contract-faithful mock.

The pinned release runs against the real control plane in the golden-flow e2e suite (tests/e2e/dev/golden-flow/chainsaw-test.yaml). The dev stack deploys two agents against two seeded Resources in one Project, so each of them reads the other in its peer projection. The suite asserts both registrations and one capability manifest per node carrying the binary version, then each node's reconciliation pull carrying its sibling as a peer alongside the agent-authored status.mesh state report. It also asserts both reachability projections reaching healthy under the agents' own heartbeat cadence, an operator-triggered mesh key rotation one agent completes and its peer picks up in the next snapshot, and a metrics batch the agent logs a receipt for once the platform has acknowledged it.

Two legs are not exercised against the real agent. The execution and session callbacks wait on an upstream release that consumes the executions and sessions blocks of the reconciliation pull; the control-plane side is complete. The NAT endpoint report hard-depends on a reachable STUN server and would flake in CI, so PUT /v1/nodes/{id}/endpoint carries no e2e assertion. Edge-PSK fetch has no client in the pinned release, so its stub suite (tests/e2e/mesh/psk-fetch/) stays that surface's coverage until an upstream release adopts the endpoint.