agent-device integration: bind the HTTP control plane into agent-device as a remote lease provider
Depends on #65 (the HTTP control plane). This issue is the plan for making a remote agent's agent-device CLI lease devices from a simlock host and drive them over the wire — simlock as the single authority over devices, agent-device as the hands.
Verified context (agent-device 0.20.6 source, callstack/agent-device)
- agent-device's daemon has a first-class provider adapter contract,
ProviderDeviceRuntime (packages/contracts): provider id, leaseLifecycle {allocate, heartbeat, release}, deviceInventoryProvider, ownsDevice, getInteractor, optional installApp/installInstallablePath/configurePortReverse/recoverExpiredLease. Lease commands dispatch on the request's leaseProvider string.
- The provider registry is compiled in (
src/provider-device-runtimes.ts): WebDriver providers (BrowserStack, AWS Device Farm) always; Limrun dynamically imported iff LIMRUN_API_KEY is set. No config/env hook loads an external runtime module.
- A
--remote-config profile already carries leaseProvider as a first-class field, and the "provider not available" gate only rejects built-in required providers that failed to load — so an external provider id flows through without touching the closed ConnectProvider unions, connect adapters, or help surface.
agent-device proxy (host-side) already covers the data plane: remote install / install-from-source, snapshots, artifacts, tunneling via cloudflared/ngrok — but with one shared token for the whole daemon and its own first-come-first-served 5-minute-inactivity device leases (no queue, no provisioning, no reclaim).
Hook mapping
| agent-device provider hook |
simlock HTTP API (#65) |
leaseLifecycle.allocate |
POST /v1/lease-requests → poll/SSE until granted |
leaseLifecycle.heartbeat |
POST /v1/leases/{id}/renew (reads notices for device-health facts) |
leaseLifecycle.release |
DELETE /v1/leases/{id} |
deviceInventoryProvider |
GET /v1/catalog |
recoverExpiredLease |
GET /v1/leases/{id} → 404 = gone, clean up locally |
Simlock's detached lease mode (token + TTL + renew) maps 1:1 onto agent-device's lease model; held mode is not used remotely.
Phased plan
Phase 0 — interim, zero upstream changes. Operator runs simlock (HTTP enabled) and agent-device proxy on the host, tunnel in front of both. The remote agent leases out-of-band (POST /v1/lease-requests via curl/script), then agent-device connect proxy and opens the granted udid. Two lease clocks, honor-system scoping via the shared proxy token — but a full working loop to dogfood.
Phase 1 — upstream PR: external providers via config. Generalize the Limrun loading path: a ~/.agent-device/config.json key (e.g. externalProviders: [{provider, module, options}]) that dynamic-imports a module exporting createProviderRuntime(options, context), validates the ProviderDeviceRuntime shape, and merges it into the composition. Est. ~300–500 LOC + the trust-model conversation (absolute paths only, no auto-install, explicit opt-in). No connect <name> CLI surface needed — connect --remote-config with leaseProvider: "simlock" covers connection UX.
Phase 2 — the simlock provider module. Implements the hook mapping above against #65. dataPlane in the lease grant becomes a real connection bundle:
"dataPlane": {
"kind": "agent-device-proxy",
"baseUrl": "https://host.example/agent-device",
"token": "<credential>",
"device": { "platform": "ios", "udid": "ABCD-..." }
}
Phase 3 — scoped data-plane tokens. Second upstream change: the proxy honors per-lease credentials scoped to one device, minted at grant and revoked at release, turning "never touch someone else's leased device" from advisory into enforced.
Open questions (need agreement before Phase 1+)
- Data-plane scoping in the meantime: accept advisory scoping for v1 (bundle carries the shared proxy token; isolation is honor-system, as it is locally today) and document it loudly — or block on Phase 3?
getInteractor feasibility (biggest technical unknown): can a provider runtime route interaction through agent-device's existing remote-daemon/proxy transport, or do provider runtimes assume they implement their own interactor (as Limrun does)? Needs a timeboxed spike with a stub provider before committing to the Phase 1 PR — the answer may flip the architecture from leaseProvider to "proxy flow with external admission".
- Device-lifecycle handshake: does agent-device
open cleanly adopt a device simlock already booted? Must simlock force-close the agent-device session before reclaim/erase (almost certainly yes — erasing under a live XCTest runner is undefined)? How do simlock's health-monitor reboots interact with agent-device's iOS runner ownership (runner is already owned by another agent-device daemon is a documented failure mode)? Needs a written contract; known-pitfalls material.
- Contract stability + packaging:
ProviderDeviceRuntime is an internal contract today — the Phase 1 PR conversation must include making it public/semver-stable. Where does the provider module live (@simlock/agent-device-provider? inside this repo?) and what is its compatibility matrix with agent-device versions?
- Identity/token UX at fleet scale: one token per agent session is the intended model — how are tokens provisioned to remote Claude Code sessions? Is one-lease-per-token still right when a requester legitimately wants two devices (app + companion)?
- Who supervises the host stack: does simlock spawn/supervise
agent-device proxy and the tunnel (new process-supervision surface), or is that the operator's launchd config? Affects where dataPlane.baseUrl/token come from at grant time.
- Timing numbers: provider heartbeat cadence vs
lease.detachedTtlMs (renew interval must clear a few missed beats), and the proxy's 5-minute inactivity lease vs simlock's lease TTL — simlock's lease must strictly outrank and outlive the proxy session.
- Documented tunnel path: pick one tested recommendation (Tailscale for persistent self-hosting, cloudflared for quick trials) rather than "any tunnel works".
Out of scope here
Everything the HTTP control plane itself needs — that's #65. Multi-host fleets (the per-lease baseUrl leaves room for a future broker). Live screen streaming for humans.
agent-device integration: bind the HTTP control plane into agent-device as a remote lease provider
Depends on #65 (the HTTP control plane). This issue is the plan for making a remote agent's agent-device CLI lease devices from a simlock host and drive them over the wire — simlock as the single authority over devices, agent-device as the hands.
Verified context (agent-device 0.20.6 source,
callstack/agent-device)ProviderDeviceRuntime(packages/contracts):providerid,leaseLifecycle {allocate, heartbeat, release},deviceInventoryProvider,ownsDevice,getInteractor, optionalinstallApp/installInstallablePath/configurePortReverse/recoverExpiredLease. Lease commands dispatch on the request'sleaseProviderstring.src/provider-device-runtimes.ts): WebDriver providers (BrowserStack, AWS Device Farm) always; Limrun dynamically imported iffLIMRUN_API_KEYis set. No config/env hook loads an external runtime module.--remote-configprofile already carriesleaseProvideras a first-class field, and the "provider not available" gate only rejects built-in required providers that failed to load — so an external provider id flows through without touching the closedConnectProviderunions,connectadapters, or help surface.agent-device proxy(host-side) already covers the data plane: remoteinstall/install-from-source, snapshots, artifacts, tunneling via cloudflared/ngrok — but with one shared token for the whole daemon and its own first-come-first-served 5-minute-inactivity device leases (no queue, no provisioning, no reclaim).Hook mapping
leaseLifecycle.allocatePOST /v1/lease-requests→ poll/SSE untilgrantedleaseLifecycle.heartbeatPOST /v1/leases/{id}/renew(readsnoticesfor device-health facts)leaseLifecycle.releaseDELETE /v1/leases/{id}deviceInventoryProviderGET /v1/catalogrecoverExpiredLeaseGET /v1/leases/{id}→404= gone, clean up locallySimlock's detached lease mode (token + TTL + renew) maps 1:1 onto agent-device's lease model; held mode is not used remotely.
Phased plan
Phase 0 — interim, zero upstream changes. Operator runs simlock (HTTP enabled) and
agent-device proxyon the host, tunnel in front of both. The remote agent leases out-of-band (POST /v1/lease-requestsvia curl/script), thenagent-device connect proxyandopens the granted udid. Two lease clocks, honor-system scoping via the shared proxy token — but a full working loop to dogfood.Phase 1 — upstream PR: external providers via config. Generalize the Limrun loading path: a
~/.agent-device/config.jsonkey (e.g.externalProviders: [{provider, module, options}]) that dynamic-imports a module exportingcreateProviderRuntime(options, context), validates theProviderDeviceRuntimeshape, and merges it into the composition. Est. ~300–500 LOC + the trust-model conversation (absolute paths only, no auto-install, explicit opt-in). Noconnect <name>CLI surface needed —connect --remote-configwithleaseProvider: "simlock"covers connection UX.Phase 2 — the simlock provider module. Implements the hook mapping above against #65.
dataPlanein the lease grant becomes a real connection bundle:Phase 3 — scoped data-plane tokens. Second upstream change: the proxy honors per-lease credentials scoped to one device, minted at grant and revoked at release, turning "never touch someone else's leased device" from advisory into enforced.
Open questions (need agreement before Phase 1+)
getInteractorfeasibility (biggest technical unknown): can a provider runtime route interaction through agent-device's existing remote-daemon/proxy transport, or do provider runtimes assume they implement their own interactor (as Limrun does)? Needs a timeboxed spike with a stub provider before committing to the Phase 1 PR — the answer may flip the architecture fromleaseProviderto "proxy flow with external admission".opencleanly adopt a device simlock already booted? Must simlock force-close the agent-device session before reclaim/erase (almost certainly yes — erasing under a live XCTest runner is undefined)? How do simlock's health-monitor reboots interact with agent-device's iOS runner ownership (runner is already owned by another agent-device daemonis a documented failure mode)? Needs a written contract; known-pitfalls material.ProviderDeviceRuntimeis an internal contract today — the Phase 1 PR conversation must include making it public/semver-stable. Where does the provider module live (@simlock/agent-device-provider? inside this repo?) and what is its compatibility matrix with agent-device versions?agent-device proxyand the tunnel (new process-supervision surface), or is that the operator's launchd config? Affects wheredataPlane.baseUrl/token come from at grant time.lease.detachedTtlMs(renew interval must clear a few missed beats), and the proxy's 5-minute inactivity lease vs simlock's lease TTL — simlock's lease must strictly outrank and outlive the proxy session.Out of scope here
Everything the HTTP control plane itself needs — that's #65. Multi-host fleets (the per-lease
baseUrlleaves room for a future broker). Live screen streaming for humans.