Skip to content

feat(http): expose the control plane over HTTP - #68

Open
V3RON wants to merge 2 commits into
mainfrom
feat/http-api
Open

feat(http): expose the control plane over HTTP#68
V3RON wants to merge 2 commits into
mainfrom
feat/http-api

Conversation

@V3RON

@V3RON V3RON commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What is this?

This PR gives simlock a network-facing HTTP API (closes #65). Until now the daemon was reachable only over its unix socket, so every agent had to live on the same machine as the simulators. With http.enabled turned on, a remote agent can lease a device, watch the acquisition progress, renew, and release — over plain HTTP behind a tunnel — while the daemon keeps sole authority over queueing, provisioning, and cleanup.

It ships off by default and changes nothing for existing CLI/MCP users: the gateway is a third sibling frontend over the same role interfaces, and the core is untouched except for one addition (per-request cancel).

How does it work?

Everything lives under /v1, documented in the new docs/HTTP-API.md:

  • Auth: every route (except GET /v1/healthz) requires Authorization: Bearer slk_…. Tokens are minted with the new simlock token create --role agent|operator command and stored as SHA-256 hashes in tokens.json; the token is the requester identity, so one-lease-per-agent is enforced by credential rather than by an honor-system env var.
  • Leasing is an async resource: POST /v1/lease-requests returns 201 immediately; the client then polls GET /v1/lease-requests/{id} (with ?wait= long-poll) or streams SSE from …/events through the same queued → provisioning → booting → granted stages the CLI prints today. DELETE cancels a still-queued request via a new core cancelPending, which reuses the queue timeout's safety envelope — a request with device work already in flight reports 409 REQUEST_NOT_CANCELLABLE instead.
  • Leases are detached-only: TTL plus POST /v1/leases/{id}/renew — the held-mode "connection = lease" model deliberately doesn't cross the network. Renew responses carry notices (device_unhealthy / device_recovered) drained since the last renew, and GET /v1/leases/{id}/events streams the same facts live, ending with lease_lost.
  • Operators get GET /v1/leases, /v1/devices, /v1/events(?since=|/stream) behind the operator role. nuke is deliberately not exposed over HTTP.
  • Lifecycle: the gateway binds only after startup convergence completes (HTTP handlers call the role interfaces directly, so serving earlier could observe half-converged state) and is the first thing shut down on daemon stop, via a new stopAuxiliary hook. Lease payloads carry a reserved dataPlane: null field for the agent-device integration (agent-device integration: bind the HTTP control plane into agent-device as a remote lease provider #66) so its arrival is additive.

The app layer is a pure Request → Response Hono app — the only file touching a real listener is the @hono/node-server adapter — so the entire route surface unit-tests through app.request() with fakes and a manually-advanced clock, in keeping with the repo's ports discipline. New e2e coverage drives the full loop (token → lease → renew → release, plus 401/403/409 paths) against the fake driver.

Why is this useful?

  • Remote agents — CI jobs, coding agents on other machines — can consume simlock's fair queueing and warm-pool machinery without shell access to the host; this is the control-plane half of the self-hosted-device-farm direction, with agent-device integration: bind the HTTP control plane into agent-device as a remote lease provider #66 tracking the data plane.
  • Real authentication replaces the local-trust model at the network boundary: hashed bearer tokens, roles, and ownership checks (an agent cannot see or release another requester's lease).
  • The async lease-request resource survives flaky networks: idempotency keys make retried POSTs safe, daemon-side timeoutMs means a vanished client cannot hold a queue slot, and a restarted client recovers its lease from GET /v1/leases/{id} instead of leaking it.
  • Nothing moves for existing setups: the feature is opt-in config, the socket protocol is unchanged, and the one core addition (cancelPending) reuses existing rejection machinery and emits through the existing lease.rejected event family.

Network-facing HTTP API as a sibling frontend to the CLI and stdio MCP:
detached-only leases, async lease-request resources with poll/long-poll/SSE,
bearer-token auth with agent/operator roles (simlock token CLI), renew-time
health notices, and a reserved dataPlane field for the agent-device
integration (#66). Off by default behind config http.enabled; served only
after startup convergence and closed first on daemon stop.
- tear the daemon down when the HTTP listener fails to bind, instead of
  leaving a zombie serving the unix socket after a reported startup failure
- answer POST /v1/lease-requests immediately for allowDownload requests, so
  a multi-minute runtime download inside resolveSpec cannot hold the POST open
- stop deriving lease ttlMs from ttlDeadline - grantedAt (wrong after any
  renewal); report the tracked value or the mode default, expiresAt stays
  authoritative
- cap long-polls at 60s and finish them on client abort, releasing listeners
  and timers instead of pinning them for a caller-chosen duration
- stop logging /v1/healthz (unauthenticated + synchronous log sink)
- bound the idempotency map: 200-char key cap, 10k-entry FIFO eviction, and
  entry+timer cleanup when a submission is rejected before becoming visible
- log a warning when a requested grant ttlMs cannot be applied
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP API: expose the simlock control plane over the network

1 participant