Appearance
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, the bootstrap/liveness/rotation/ snapshot wire shapes, and the ssh session activity row. 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
| Operation | Auth | Purpose |
|---|---|---|
POST /v1/register | bootstrap token (body) | redeem token, receive identity, NSK, signing key, peer snapshot, mesh CIDR |
POST /v1/nodes/{id}/heartbeat | NSK | liveness; returns reconcile and rotate_keys hints |
PUT /v1/nodes/{id}/endpoint | NSK | NAT endpoint observation; expires at stale_after |
GET /v1/nodes/{id}/state | NSK or operator | reconciliation pull — the baseline delivery channel; carries the pending-dispatch executions and sessions blocks |
GET /v1/nodes/{id}/events | NSK or operator | signed event stream (currently descoped, answers 501) |
GET /v1/nodes/{id}/reachability | NSK or operator | the node's own health projection |
PUT /v1/nodes/{id}/capabilities | NSK | capability manifest snapshot |
POST /v1/nodes/{id}/integrity-violations | NSK | tamper-evidence batches |
POST /v1/nodes/{id}/metrics | NSK | metric samples (JSON array) |
POST /v1/nodes/{id}/logs | NSK | log lines (NDJSON) |
POST /v1/nodes/{id}/audit | NSK | audit events (NDJSON) |
GET /v1/nodes/{id}/secrets/{name} | NSK | NSK-rewrapped secret envelope |
GET /v1/nodes/{id}/peers/{peer_node_id}/psk | NSK | NSK-rewrapped pairwise edge-PSK envelope |
PUT / DELETE /v1/nodes/{id}/state/reports/{key} | NSK | node-authored state reports |
POST /v1/keys/rotate | NSK | mesh key-rotation completion |
POST /v1/nodes/{id}/executions/{execution_id} | NSK | execution status callback (ack → started → terminal) |
POST /v1/nodes/{id}/sessions/{session_id} | NSK | per-kind session activity callback; a tcp or ssh session_started row also reports the listener endpoint the node bound |
GET /v1/artifacts/plexd/{version} | NSK or operator | indexed release record with per-architecture checksums and verdict |
GET /v1/artifacts/plexd/{version}/sigstore | NSK or operator | verbatim 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, andGET /v1/nodes/{id}/reachabilitywith 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 is403 node_id_mismatchand a revoked one401 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}/statecarries anexecutionsblock of the Node's pending action dispatches and asessionsblock of the live mediated sessions it must serve (both always present,[]when empty). The agent acknowledges an execution through the NSK callbackPOST /v1/nodes/{id}/executions/{execution_id}and reports session activity throughPOST /v1/nodes/{id}/sessions/{session_id}, deriving both callback paths from its configured base — the pull blocks carry no callback URL. For atcporsshsession the agent also reports the listener it bound: thesession_startedrow carries an optionallistener_endpoint, thehost:porton 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 refused409 session_listener_pendingat 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 againstplexsphere.nodes.mesh_ipfor 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 with400 invalid_session_activity. An agent that binds on a wildcard address therefore reports its mesh address, not0.0.0.0. A repeatedsession_startedoverwrites the stored value, so the latest report wins. Asession_endedrow must not carry the key: a coordinate is admitted only on thesession_startedrow of atcporsshsession, and the platform refuses the whole callback with400 invalid_session_activityotherwise. The populated member of the callback body must match the session's kind, so atcprow posted for ansshsession is refused the same way. 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'spublic_keychanges, and after the agent completes its own key rotation. An unknown, soft-deregistered, or foreign-Domain peer is404 peer_not_found; an endpoint whose per-Node PSK has not landed yet is409 psk_not_readyand the agent retries on its next reconcile. A429 per_node_rate_limitedmeans the agent exceeded its per-Node fetch budget; it waits theRetry-Afterinterval and resumes, and must not hot-loop. A403is terminal and never retried —node_deregisteredin 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 dataplexsphere/mesh/edge-psk/v1|<id>|<peer_node_id>|<epoch>, where<epoch>is theX-Plexsphere-PSK-Epochresponse 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.
SSH session listener
An ssh entry in the sessions block of the reconciliation pull asks the agent to serve an SSH listener for that session. Every client of the listener — plexctl ssh, the browser attach gateway, an operator's own OpenSSH — follows one login contract, and the agent's obligations are the five below. The control-plane half (the report admission and the kind comparison) is described in the Access Orchestrator context.
a. Report
Once the agent has bound a listener for a live ssh entry, it posts {"ssh":{"phase":"session_started","listener_endpoint":"<mesh-ip>:<port>"}} to POST /v1/nodes/{id}/sessions/{session_id}. The host is the node's own mesh address, the same bind rule a tcp report follows, and the latest report wins. When the listener closes, the agent posts {"ssh":{"phase":"session_ended","terminated_by":"<reason>"}} with no listener_endpoint; <reason> is ttl_expired, idle_timeout, plexd_close, or operator_revoke.
For each exec request the agent posts a command row: command_executed when the command starts ({"ssh":{"command":"<command>","started_at":"<time>"}}) and command_exited when it finishes, adding exit_code and completed_at. An interactive shell produces no per-command rows. A row carries either the command fields or phase, never both; the platform refuses a mixed row with 400 malformed_session_activity_request. It refuses a phase other than session_started or session_ended, and a terminated_by on a session_started row, the same way.
b. Authentication
The listener speaks SSH-2 and offers only the password method (RFC 4252 section 8). The SSH user must equal the entry's target.ssh.user, and the password is the compact session token the operator was issued. The listener accepts the password only when all of these hold:
- The EdDSA signature verifies against the Domain signing key the node pinned at registration (
signing_public_keyof thePOST /v1/registerresponse), or its rotated successor. - The protected header's
algisEdDSA. - The
kindclaim isssh. - The
jticlaim equals thesession_idthe listener serves. nbf <= now < exp.- The token's
targetclaim names the same user:target.userequals the SSH user.
Every other authentication method is refused. The token is the only credential, so a listener never accepts a key or an empty password.
This rests on one deployment invariant: the signing key handed out at registration must be the key session tokens are signed with. Registration serves the key configured as PLEXSPHERE_SIGNING_PUBLIC_KEY_B64, so that value must be the public half of the Domain key the Signing Service signs session tokens with. The control plane does not check this at boot; a mismatch shows up as every login being refused.
c. Service
With an empty or absent allowed_commands, the listener accepts pty-req, window-change, shell, and exec with any command.
With a non-empty allowed_commands, the listener refuses shell and accepts exec only when the command string equals a list entry byte for byte. pty-req and window-change stay allowed.
In both modes the listener refuses direct-tcpip, tcpip-forward, subsystem (including sftp), x11-req, and agent forwarding.
d. Host key
The agent may use any host key type it chooses. Clients do not pin it: the server is authenticated by the mesh address the client reached, which the control plane bound to the reporting node when it settled the listener_endpoint, and which WireGuard authenticates on the wire.
e. Lifetime
The listener, and every connection on it, closes when the entry drains from the pull, at the entry's expires_at, or after idle_timeout_seconds without traffic. The session_ended row's terminated_by names which:
ttl_expiredwhen the listener closed atexpires_at.idle_timeoutwhen it closed afteridle_timeout_secondswithout traffic.operator_revokewhen it closed because the agent received asession_revokedevent for the session.plexd_closefor every other close, including an entry that drained from the pull without such an event.
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 surface | Disposition |
|---|---|
GET /v1/ping | probe GET /v1/health instead |
POST /v1/nodes/{id}/deregister | decommission is operator-driven; no node-initiated removal |
POST /v1/nodes/{id}/drift | kept 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}/metadata | never consumed; dropped |
POST /v1/nodes/{id}/report (bulk) | replaced by per-key state reports |
POST /v1/nodes/{id}/tunnels/{sid}/ready / closed | replaced by the session activity callback |
POST /v1/nodes/{id}/executions/{eid}/ack / result | replaced by the single execution callback |
GET /v1/artifacts/plexd/{version}/{os}/{arch} (binary) | the binary is served outside /v1 at /downloads/plexd/<platform> from the release the control plane pins; the registry record and bundle stay as they are |
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.