Skip to content

HTTP API: expose the simlock control plane over the network #65

Description

@V3RON

HTTP API: expose the simlock control plane over the network

Why

Simlock today is reachable only over a unix socket on the machine it runs on. To let remote agents (e.g. Claude Code sessions on other machines) lease devices from a self-hosted simlock host, the control plane needs a network-facing API: request a device, watch the acquisition progress, renew, release, and observe the pool — over HTTP, behind real authentication.

This issue covers the control plane only. Driving the leased device remotely (the data plane) is a separate concern tracked in the agent-device integration issue.

Design principles

  1. Detached-style leases only. HTTP is stateless; "held lease = live connection" does not survive real networks. Remote leases are TTL-bound and kept alive by explicit renewal — the machinery detached mode already has. No WebSocket held-mode emulation.
  2. Acquisition is an async resource. A lease can take minutes to arrive (queue → provision → boot). POST creates a lease request resource; the client polls, long-polls, or streams its progress. No long-blocking POST.
  3. Thin adapter, zero core semantics. The HTTP layer is a sibling frontend to the CLI/MCP: it depends on the same role interfaces (LeaseCommands, QueueControl, CapacityReader, CatalogReader) and the event bus. The core never knows HTTP exists.
  4. Identity comes from the credential. The bearer token maps to a requesterId server-side. SIMLOCK_AGENT_ID stays a local-frontend concern; over HTTP, identity is not client-declared.
  5. No in-process TLS in v1. Bind 127.0.0.1 by default; reaching the API remotely is the operator's tunnel (Tailscale, cloudflared, reverse proxy). Authorization is still required even on loopback.

Configuration

New http section (default off):

{
  "http": {
    "enabled": false,
    "host": "127.0.0.1",
    "port": 4700
  }
}

Authentication

  • Authorization: Bearer slk_<secret> on every /v1/* route except GET /v1/healthz.
  • Token store: ~/.simlock/tokens.json (under SIMLOCK_HOME), holding SHA-256 hashes of secrets — never plaintext — plus per-token metadata: { id, hash, role, requesterId, label, createdAt }.
  • Roles: agent (catalog, own lease requests/leases, status) and operator (agent + list all leases/devices, events, doctor, cleanup, release any lease).
  • One token = one requester identity. The one-lease-per-agent rule keys off it.
  • Minted and managed via a new CLI command:
simlock token create --role agent|operator [--label <text>]   # prints the secret once
simlock token list
simlock token revoke <token-id>

Error model

Same shape and codes as the daemon protocol:

{ "error": { "code": "NO_CAPACITY", "message": "..." } }
HTTP Codes
400 USAGE, BAD_REQUEST (malformed body / validation)
401 UNAUTHENTICATED (missing/unknown token)
403 FORBIDDEN (role does not permit; or lease/request owned by another requester)
404 UNKNOWN_LEASE, UNKNOWN_REQUEST
409 REQUESTER_ALREADY_LEASED (body names the existing lease id), REQUEST_NOT_CANCELLABLE
422 UNKNOWN_MODEL, RUNTIME_MISSING, NO_DRIVER
503 NO_CAPACITY (only with noWait: true; includes Retry-After), DAEMON_STARTUP_FAILED

API specification

All routes under /v1. JSON bodies. Additive evolution only.

GET /v1/healthz

Unauthenticated liveness for tunnels/load balancers. → 200 {"ok":true}.

GET /v1/status

status --json equivalent: daemon health (starting/running), managed/running capacity per platform, queue depth. Role: agent.

GET /v1/catalog?platform=ios|android

Exactly catalog --json. Role: agent. Read-only, never downloads.

POST /v1/lease-requests

Enqueue a device request. Role: agent. Supports the Idempotency-Key header: replaying the same key for the same requester returns the original request resource instead of double-queueing.

Request body:

{
  "platform": "ios",
  "device": "iPhone 17 Pro",
  "os": "26.5",
  "ttlMs": 900000,
  "timeoutMs": 300000,
  "noWait": false,
  "allowDownload": false
}

platform and device required; os defaults to newest installed runtime; ttlMs defaults to lease.detachedTtlMs; timeoutMs (optional) is enforced daemon-side so a vanished client cannot hold a queue slot.

201, Location: /v1/lease-requests/{id}:

{ "request": { "id": "req_7d1a", "state": "queued", "queuePosition": 2, "createdAt": "2026-09-01T09:12:00Z" } }

Errors: 409 REQUESTER_ALREADY_LEASED, 422 for unknown model / missing runtime / no driver, 503 NO_CAPACITY when noWait.

GET /v1/lease-requests/{id}

Poll the request. ?wait=<seconds> long-polls: returns early on any state change, else after the wait. Role: agent (own requests only; operator sees all).

States mirror the existing LeaseProgress stages plus terminals:
queued | reclaiming | provisioning | booting | granted | failed | cancelled, with queuePosition / etaSeconds where the stage carries them.

Terminal granted embeds the lease object (below). Terminal failed embeds the error object (e.g. QUEUE_TIMEOUT).

GET /v1/lease-requests/{id}/events

Server-Sent Events stream of the same progress objects, one event per state change, ending with granted or failed. Periodic : keepalive comments (~15s) so idle tunnels don't close the stream.

DELETE /v1/lease-requests/{id}

Cancel. → 204 if the request was still cancellable; 409 REQUEST_NOT_CANCELLABLE once device work for it is in flight or it already reached a terminal state (body says which; if granted, it names the lease id — release that instead). 404 unknown.

Lease object

{ "lease": {
    "id": "lse_9f2c", "requestId": "req_7d1a",
    "platform": "ios", "device": "iPhone 17 Pro", "os": "26.5",
    "udid": "ABCD-...", "deviceId": "dev_1a2b",
    "createdAt": "2026-09-01T09:14:07Z",
    "expiresAt": "2026-09-01T09:29:07Z", "ttlMs": 900000,
    "dataPlane": null
} }

dataPlane is reserved in v1 (always null): the agent-device integration issue populates it with a connection bundle (kind, baseUrl, credential, device selector). It is in the schema now so its arrival is additive.

GET /v1/leases/{id}

Re-fetch the lease (a client that restarts mid-lease recovers its state instead of leaking the lease). Role: agent (own lease; operator any). 404 UNKNOWN_LEASE once expired/released.

POST /v1/leases/{id}/renew

Body { "ttlMs": 900000 } (optional; defaults to the lease's mode default). Resets the deadline to now + ttl — existing renew semantics.

200 { "leaseId": "lse_9f2c", "expiresAt": "...", "notices": [] }

notices carries device-health facts observed since the previous renew for this lease — {"event":"device_unhealthy"}, {"event":"device_recovered","attempts":1} — so a polling-only client hears its device blinked without holding a stream. Role: agent (own lease).

GET /v1/leases/{id}/events

SSE for live health pushes on this lease: device_unhealthy, device_recovered, lease_lost (lease_lost ends the stream). Same facts held mode relays on stderr today.

DELETE /v1/leases/{id}

Release. → 202 { "released": true, "device": { "id": "dev_1a2b", "state": "reclaiming" } } — the lease is gone now; the purge continues in the background (existing release semantics; hence 202, not 200). Role: agent (own lease); operator may release any.

Operator surface

  • GET /v1/leases — all active leases (the list --leases view).
  • GET /v1/devices — all managed devices with states and transitionAgeMs (the list --devices view).
  • GET /v1/events?since=<duration> — replay from the business-event ring buffer.
  • GET /v1/events/stream — SSE follow of the event bus.
  • POST /v1/doctor { "fix": false } and POST /v1/cleanup { "dryRun": true, "rule": "idle-destroy" } — may land as a follow-up; not required for v1.

Deliberately absent: nuke stays off HTTP entirely (SSH-only). A remote fleet-wipe endpoint is a footgun even behind auth.

Lifecycle semantics

  • Daemon restart: in-flight lease requests are in-memory and do not survive (same as today's queue). Clients get 404 on their request id and re-request; if their grant landed before the crash, the persisted detached lease answers the retry with 409 REQUESTER_ALREADY_LEASED naming the lease id, which the client then GETs. Document this recovery loop for provider implementations.
  • Idempotency keys are in-memory with a TTL; after a restart a replay creates a fresh request (the 409 backstop prevents double-grants).
  • Expiry: a remote lease that stops renewing expires via the existing TTL machinery; the device is reclaimed normally.

Implementation notes

  • Stack: hono + @hono/node-server + @hono/zod-validator (zod is already a dependency). The Hono app is a pure Request → Response function — no node:http import outside the serve adapter, keeping the ports rule intact. Unit tests drive the full stack via app.request() with fakes; no sockets.
  • Module layout: src/http/app.ts (createHttpApp(deps)), auth.ts (bearer middleware over a TokenVerifier), tracker.ts (lease-request resource state), sse.ts (event-bus → SSE bridge), server.ts (the only impure file: serve() wiring). Wired in startDaemon behind http.enabled.
  • LeaseRequestTracker: gateway-layer component that calls LeaseCommands.request() with an onProgress callback, assigns request ids (IdGenerator), records the current stage, and lets GET/long-poll/SSE observe it. No core changes for observation.
  • Core addition — per-request cancel: the queue has cancelAll (nuke) and timeout rejection but no single-request cancel. Add one, modelled exactly on the queue-timeout rejection path (same safety envelope: cancellation follows whatever timeout expiry already tolerates). Cancel succeeds only while no device work is in flight for the waiter; otherwise REQUEST_NOT_CANCELLABLE.
  • Renew notices: subscribe the tracker (or a lease-notice buffer) to the health monitor's existing lease-scoped pushes; drain per lease on each renew.
  • Time: all timers (long-poll, SSE keepalive, idempotency TTL) go through the Clock port; no Date.now()/setTimeout in logic.
  • Logging: module-scoped child logger (logger.child("http")), one structured line per request outcome — method, path, status, requesterId, duration.

Testing

  • Unit: createHttpApp with fake role interfaces, manually-advanced Clock, in-memory token store — full route coverage including auth, ownership (403 on another requester's lease), error mapping, long-poll early-return, SSE event framing, idempotency replay, cancel-vs-in-flight.
  • E2E: daemon started with http.enabled on an ephemeral port against the fake driver (SIMLOCK_DRIVERS_MODULE), driven with plain fetch: full loop token create → POST lease-request → SSE to granted → renew → DELETE lease, plus restart-recovery (404 → re-request → 409GET lease).

Out of scope (tracked elsewhere or deferred)

  • Populating dataPlane and everything agent-device-facing → agent-device integration issue.
  • MCP over HTTP; in-process TLS; multi-host brokering (per-lease dataPlane.baseUrl already leaves room); nuke over HTTP; live screen streaming.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions