Skip to content

An agent opens and closes a device through simlock #472

Closed

Description

@V3RON

Part of #466.

Scope

After this PR, an agent points the agent-device CLI at a simlock worker with its simlock token. open leases a matching device from simlock, waiting in simlock's queue when every match is taken. Commands run on that device through simlock. close ends the agent-device lease and then frees the simlock lease, so simlock wipes the device and offers it to the next agent. When agent-device is turned on in simlock's config but missing, too old, starting or crashed, every agent-device URL says it is not available and why, and the rest of simlock works as before. When it is turned off, the agent-device URLs answer 404, as today.

Heartbeats pass through to agent-device but do not yet renew the simlock lease (#473). simlock ending a lease on its own does not yet end the agent-device session (#474). The request rules after open and the devices filter come in #475.

This task is over the usual size (35 Tests lines). It stays whole: the route, the host, the allocate and the release are one flow, and none can be tested without the others.

In short

simlock serves agent-device's proxy URL at /v1/agent-device/*. The front is the part that checks the simlock token and turns an agent-device allocate into a simlock lease.request and a release into a simlock lease.release. The host runs @agent-device/proxy in front of the agent-device daemon that #470 starts. The front reaches the host through the host link, in-process on a worker. The proxy's authenticate callback makes each simlock token's principal the agent-device tenant, so two tokens never share an agent-device session. One simlock lease and one agent-device lease share one ID, the 32-hex ID the CLI picks.

sequenceDiagram
  actor A as agent-device CLI
  participant F as front (new)
  participant L as simlock leasing
  participant H as host + proxy (new)
  participant D as agent-device daemon
  A->>F: allocate(id, --platform ios --device "iPhone 17 Pro")
  F->>L: lease.request(leaseId = requesterId = id, model) (new)
  L-->>F: grant: device address
  F->>H: allocate(id, udid = address, ttlMs = ceiling) (new)
  H->>D: allocate, tenant = principal
  D-->>A: agent-device lease (same id)
  A->>F: snapshot / click
  F->>H: forward unchanged
  A->>F: close, then release(id)
  F->>H: release(id) first (new)
  F->>L: lease.release(id) (new)
Loading

What the agent runs, before and after:

# before: simlock has no such route
$ agent-device connect proxy --daemon-base-url http://<worker>:<port>/v1/agent-device
error: 404

# after
$ export AGENT_DEVICE_DAEMON_AUTH_TOKEN=<simlock token>
$ agent-device connect proxy --daemon-base-url http://<worker>:<port>/v1/agent-device
$ agent-device open MyApp --platform ios --device "iPhone 17 Pro"
$ agent-device snapshot
$ agent-device install ./MyApp.app
$ agent-device close
$ agent-device open MyApp --platform tv
error: refused: only iOS and Android

How allocate maps to a simlock lease request. "Today" is agent-device's own proxy.

Agent passes Today After
--platform ios / android any device of that platform the daemon sees platform; any device simlock grants
--target mobile / tv / desktop that kind of device class phone / tv / desktop
--device "iPhone 17 Pro" a device named so model, no class
--device "iPhone 17 Pro" --target tv a device named so model, no class: the model wins
--device "My test phone" (not a model) a device named so, if one exists refused by simlock: <simlock's message>, nothing leased
--platform macos / web / any other allowed refused: only iOS and Android, nothing asked of simlock
no --platform the daemon picks refused: name --platform ios or android
--udid / --serial on open that device refused: simlock picks the device
a deviceKey (the CLI's saved device) that device dropped; simlock picks the device
every match leased fails at once waits in simlock's queue, up to 330 s
an older CLI that sends no lease ID allowed refused: this agent-device is too old for simlock; upgrade it
a lease call sent inside agent_device.command runs it refused: send lease calls as lease RPCs
a ttlMs that is not a positive finite number the daemon's rule refused: ttlMs must be a positive number of milliseconds
a ttlMs above simlock's lease.maxTtlMs the daemon's rule refused by simlock: <simlock's message>
backend, retainOnClose: true, any provider* or aws* key, or a leaseProvider other than proxy that kind of lease refused: simlock leases only plain devices
an operator token allowed refused: agent-device through simlock needs an agent token (403)

Messages the front prints, set here:

  • agent-device is not available on this simlock: <reason>. <reason> is the message of simlock runs the agent-device daemon #470's supervisor when it is unavailable (for example found 0.20.3, need 0.21.25 or later), and starting while the supervisor is starting or restarting the agent-device daemon.
  • timed out waiting for a device; nothing was leased.
  • refused by simlock: <simlock's message> for any simlock refusal of the lease request.
  • The refusals in the table above.
  • refused: request body over 1 MiB and refused: send one JSON-RPC request (a body that is not JSON, a JSON array, or an object with no string method); both answer with id: null.

If this task goes wrong, a user notices that open takes a device another agent holds, that close leaves the device leased in simlock list, or that a device stays leased after the agent's CLI gave up waiting.

Technical spec

Modules touched

flowchart LR
  APP["http/app.ts: mounts *"] --> FR["agent-device/front *"]
  MAIN["daemon/main.ts: wiring *"] --> APP
  MAIN --> ED["agent-device/index.ts *"]
  FR --> DSP["daemon dispatcher: lease.request / cancel / release / list"]
  FR --> SES["http/dispatcher-session.ts: buildHttpSession"]
  FR --> HL["front/host-link.ts: HostLink, in-process *"]
  HL --> HO["agent-device/host: proxy, authenticate *"]
  HO --> PX["@agent-device/proxy (new dependency) *"]
  PX --> UP["host/upstream.ts: AgentDeviceUpstream *"]
  UP --> SUP["host: AgentDeviceSupervisor (#470)"]
  FR --> SH["agent-device/shared *"]
  HO --> SH
Loading
sequenceDiagram
  actor A as CLI
  participant F as front
  participant L as simlock leasing
  participant H as host + proxy
  participant D as agent-device daemon
  A->>F: POST /v1/agent-device/rpc allocate(id)
  F->>L: lease.request (timeoutMs 330000)
  alt CLI hangs up while waiting
    F->>L: lease.cancel(requesterId = id), at once or on admission
    opt grant lands anyway
      F->>L: lease.release(id)
    end
  else granted
    F->>H: allocate naming the granted device (not aborted by a hang-up)
    H->>D: allocate
    alt success reply, CLI still there
      D-->>A: the agent-device lease
    else success reply, CLI gone
      F->>H: release(id) with the reply's lease scope
      F->>L: lease.release(id)
    else error reply
      F->>L: lease.release(id)
      F-->>A: agent-device's error
    else no usable reply
      F->>H: release(id) with the allocate's scope
      F->>L: lease.release(id), unless agent-device refuses that release
      F-->>A: COMMAND_FAILED
    end
  end
Loading
  • src/http/app.ts:
    • HttpGatewayDeps (:65-84) gains an optional mounts: readonly HttpMount[]. HttpMount is { prefix: string; handle(request: Request, identity: TokenIdentity): Promise<Response> }. The type names no agent-device word.
    • createHttpApp (:179) registers each mount at /v1/<prefix> and /v1/<prefix>/*, behind the same agentAuth (:190) every other route uses, after the built-in routes and before the return (:719). The handler passes c.req.raw and the identity. requireAuth (src/http/auth.ts:43-59) answers 401 with no token and 403 for a worker join token, as on every /v1 route.
    • withConsole (src/http/server.ts:80-101) already sends all of /v1/* to the app. No change there.
  • src/daemon/main.ts:
    • StartDaemonOptions (:103-123) gains agentDeviceUpstream?: AgentDeviceUpstream. When it is given, main uses it and does not start simlock runs the agent-device daemon #470's supervisor. Tests pass a FakeAgentDevice here.
    • When agentDevice.enabled is true, main builds the host, the in-process host link and the front map before createLeasing (:285), after simlock runs the agent-device daemon #470's supervisor. They need nothing from leasing. The host needs tokens, so the TokenStore construction (:317, which needs only clock, filesystem, idGenerator and the data directory) moves above createLeasing. In startHttpGateway (:514-528) main builds the front with that front map and host link, and passes its mount to createHttpApp. The front gets the same dispatch closure and clock the app gets. With agentDevice.enabled false, main builds none of them and passes no mount. The gateway-mode app (startHttpFrontend, :779) gets no mount.
  • src/agent-device/index.ts: exports what main needs to build the worker-mode edge in the two steps above (host, in-process host link and front map; then the front, which returns its HttpMount). Also exports AgentDeviceUpstream.
  • src/agent-device/shared/, one owner for each rule; later tasks call these and never re-read the fields themselves:
    • The front's path prefix /v1/agent-device and the proxy's own base path /agent-device.
    • Reading an /rpc body: at most 1 MiB, the proxy's own limit; it must be one JSON-RPC 2.0 request object.
    • Classifying a call. A lease call is agent_device.lease.allocate, .heartbeat or .release, or the same with agent-device.lease. (agent-device's http-rpc-methods.ts accepts both spellings). A wrapped lease call is agent_device.command or agent-device.command whose params.command is lease_allocate, lease_heartbeat or lease_release. Anything else is a command.
    • Reading a lease call's fields from params: leaseId, ttlMs, platform, target, device, udid, serial and deviceKey.
    • Reading a command's lease ID: params.meta.leaseId, else params.flags.leaseId, the order agent-device's own leaseScopeFromRequest uses. Heartbeats keep the simlock lease alive #473 and The agent-device front refuses requests that reach past the agent's device, and devices lists only its own #475 call it; this task tests it directly.
    • A lease ID is 32 lowercase hex characters. Anything else is no lease ID.
    • Deciding whether an agent-device reply is a success: the body is one JSON object with jsonrpc: "2.0", a result object whose ok is true, and no error (agent-device's http-server.ts sends a failed call as an error envelope). Lease replies never stream, so they are read whole.
    • The front's replies. On /rpc, a JSON-RPC error envelope { jsonrpc: "2.0", id, error: { code: -32000, message, data: { code, message } } }, with the request's own id. data.code is INVALID_ARGS for a refusal and COMMAND_FAILED for not available or timed out. On any other route, { ok: false, error, code }, the proxy's own shape. The CLI reads the body, not the status.
  • src/agent-device/front/:
    • The request path, in this order: requireAuth (401, or 403 for a worker join token); the token's role, where anything but agent answers 403 refused: agent-device through simlock needs an agent token (an operator's token passes simlock's owner check on any lease, so through the edge it could end another agent's lease); on /rpc, reading the body (over 1 MiB or malformed answers its refusal); then the host link's availability, answering not available with the reason; then the call's class. A wrapped lease call is refused with refused: send lease calls as lease RPCs. Allocate and release go to the lease code below. Every other request, heartbeats included, goes to the host link unchanged, body streamed, with the client's abort signal.
    • The front map (front/front-map.ts), in memory only: lease ID → the principal's TokenIdentity, the granted device's address, the lease's TTL (the granted lease's ttlMs), the latest deadline the front has seen for it, and, once the lease ended, when. An entry is added when the agent-device allocate succeeds. An entry's lease has ended when the front released it, when a lease.release for it got UNKNOWN_LEASE, or when a check below finds simlock no longer lists it. An ended entry is dropped one TTL after it ended. The map prunes on each read and write, and runs no timer for it. On each allocate, every entry not yet ended whose latest deadline has passed is checked with one lease.list per principal, dispatched with that principal's session, for at most 100 principals (oldest deadline first), in parallel, each bounded by 5 s: a listed lease updates the entry's deadline; an unlisted one marks it ended now; a lease.list that rejects or times out leaves its entries as they were. The allocate goes on either way. So the map holds live leases and leases ended within one TTL.
    • The TTL the front asks simlock for: the CLI's ttlMs raised to at least 5 s (agent-device's own shortest window), or none when the CLI sent none, so simlock applies lease.defaultTtlMs. A ttlMs of null counts as absent. A ttlMs present but not a positive finite number is refused before simlock sees anything; one above lease.maxTtlMs is sent as is, and simlock's BAD_REQUEST answers refused by simlock: <message>.
    • The agent-device ceiling: HostLink.status() reports leaseMaxTtlMs while running. It is the value simlock runs the agent-device daemon #470's supervisor passes as AGENT_DEVICE_LEASE_MAX_TTL_MS, read from the one function that computes it there (if simlock runs the agent-device daemon #470 computes it inline, this task moves it into an exported function in host/agent-device-supervisor.ts). Every allocate the front forwards carries ttlMs equal to it, so an agent-device lease never expires before its simlock lease.
    • Allocate. The front refuses, before simlock sees anything, the inputs the table marks refused. Otherwise it dispatches lease.request with the session buildHttpSession(identity, { onRequestAdmitted }) builds: platform; model from --device when it is sent, else class from --target (mobile → phone); leaseId and requesterId both the CLI's lease ID; the TTL above; timeoutMs: 330000, the CLI's own allocate deadline, so a client that never hangs up still leaves the queue. It sends no allowDownload, physical, osVersion, mode, imageTag or idempotency key. Over that wait:
      • When the request's signal aborts after simlock admitted the request (onRequestAdmitted fired), the front dispatches lease.cancel with requesterId = the lease ID. When it aborts before admission, the front dispatches lease.cancel the moment onRequestAdmitted fires. A grant that resolves after the abort is released at once with lease.release and never sent to agent-device. This covers cancelPending's not-cancellable answer while device work runs (src/leasing/lease-acquisition-coordinator.ts:609-619). After a not-cancellable answer the front sends lease.cancel again every 1 s until it answers cancelled or not-found, the request is granted (then released at once), or 330 s have passed, so a request whose device work failed and went back to the queue still leaves it.
      • QUEUE_TIMEOUT answers timed out waiting for a device; nothing was leased. Any other simlock error answers refused by simlock: <message>.
      • After the grant, the front sends the CLI's allocate on through the host link with device, deviceKey and target removed (the refused keys above never get this far), ttlMs set to the ceiling, and the granted device named by grant.device.address: udid on iOS, serial on Android. The address is what the driver read when it made the device ready (src/core/domain.ts:229-236): the simulator's udid on iOS (src/drivers/ios/index.ts:874), and the emulator's adb serial, such as emulator-5554, on Android (src/drivers/android/index.ts:904), not the AVD name in driverDeviceId. The front passes it on and never parses it. A grant with no address is released at once and answers a COMMAND_FAILED error.
      • This forward is not aborted when the CLI hangs up. It is bounded by the proxy's 5-minute upstream timeout instead, so the front always learns whether agent-device made the lease.
      • A success reply with the CLI still there: the front adds the map entry and answers with agent-device's reply unchanged.
      • A success reply after the CLI hung up: the front sends a release through the host link with the lease scope the reply names (tenantId, runId, leaseProvider, clientId, deviceKey), then releases the simlock lease. If that release fails, the simlock lease stays and ends at its TTL, since no heartbeat will come.
      • An error reply: agent-device made no lease (it settles a failed allocate itself). The front releases the simlock lease and answers agent-device's reply.
      • No usable reply (a non-JSON body, an unreachable daemon, the upstream timeout): agent-device may have made the lease. The front sends a release through the host link with the scope the CLI's allocate carried (runId, leaseProvider, clientId; the proxy sets the tenant). It releases the simlock lease unless agent-device answers that release with an error envelope other than a lease-gone reason (LEASE_NOT_FOUND, LEASE_EXPIRED, LEASE_REVOKED): then agent-device says it holds the lease, and the simlock lease stays and ends at its TTL. An unreachable daemon also releases the simlock lease: a daemon that cannot be reached is dead or restarting, and a restarted daemon has no leases. Either way the CLI gets a COMMAND_FAILED error.
    • Release. The front sends the release through the host link first. On a success reply (released true or false; it is false after close ended the agent-device lease) it dispatches lease.release as the map entry's identity and marks the entry ended. UNKNOWN_LEASE from simlock counts as released. On anything else it keeps the simlock lease and returns agent-device's reply. A release naming a lease ID the map does not hold for this principal goes to agent-device only, and no simlock lease is touched.
    • HostLink: { status(): { state: "running"; leaseMaxTtlMs: number } | { state: "unavailable"; reason: string }; forward(request: Request): Promise<Response> }, and its in-process adapter, which calls the host directly.
  • src/agent-device/host/:
    • The host loads @agent-device/proxy with await import() only when agentDevice.enabled is true, so a proxy package that fails to load leaves simlock running, with agent-device unavailable (could not load @agent-device/proxy: <error>). It holds one proxy from createDaemonProxy per agent-device daemon start. It builds one when it first sees agent-device running, and builds a new one when AgentDeviceUpstream.current() reports a different generation. Options: upstreamBaseUrl and upstreamToken from current(), upstreamFetch the host's own function that calls AgentDeviceUpstream.fetch (simlock ending a simlock lease ends the agent-device session first #474 records release scopes there), and authenticate. authenticate does not check the token again: requireAuth already did, with TokenStore.verify, the one token check. The front tags each Request it forwards with its TokenIdentity (a WeakMap the host owns), and authenticate returns { tenantId: identity.requesterId } for a tagged request and null for one the front did not tag.
    • forward rewrites the request's path prefix /v1/agent-device to the proxy's base path /agent-device, keeping origin, query, headers, body and signal, and hands it to proxy.handle. The proxy serves only paths under /agent-device (resolveProxyRoute in the proxy) and builds upload tickets from the request URL. So on an /upload/preflight reply the host puts /v1 back in front of the ticket URL's path. The ticket's headers already carry the agent's own token (the proxy's authenticate mode), so the upload PUT comes back through the front's normal token check.
    • AgentDeviceUpstream port: current(): { state: "running"; baseUrl: string; token: string; generation: number; leaseMaxTtlMs: number } | { state: "unavailable"; reason: string } and fetch(request: Request): Promise<Response>. The real adapter reads simlock runs the agent-device daemon #470's AgentDeviceSupervisor.availability(): running gives its baseUrl, token and generation, plus the ceiling above; unavailable gives its message as the reason; starting gives the reason starting. (off never reaches it: with the edge off there is no mount.) It uses the global fetch. Its in-memory fake is FakeAgentDevice.
  • src/agent-device/testing.ts: FakeAgentDevice plays the agent-device daemon behind the real proxy. It answers /health, JSON-RPC lease allocate (keeping the given ID and recording its ttlMs and scope), heartbeat, release (checking the scope it allocated with), commands including open and close (close ends the lease, as the real daemon does), and upload preflight and PUT. It checks the upstream token and records each request with its tenant. It can be set unavailable with a reason, fail its next allocate or release, be unreachable, hold an allocate until the test lets it go, hold a command until the test lets it go, and restart (new base URL, token and generation; every lease and session gone). Later tasks extend it.
  • vendor/agent-device-proxy-<version>-<commit>.tgz and package.json: @agent-device/proxy is a file:vendor/... dependency on a tarball packed from the agent-device integration branch with pnpm --filter @agent-device/proxy pack. Its prepack runs the package's own build, which bundles agent-device's internal packages into dist, so the tarball has no workspace: dependency. vendor/README.md names the integration branch, the commit and the command. It is swapped for the npm release later. simlock runs the agent-device daemon #470 does not use the proxy; only this task's host does.
  • docs/HTTP-API.md: a section for /v1/agent-device/*. It says how to connect, that every route needs the simlock token, that each agent needs its own simlock token, and the messages above.

Assumption: agent-device PR #3487's allocate takes an exact device the way other agent-device commands do, as udid on iOS and serial on Android, and its reply names that device and the lease's scope.

Assumption: an allocate with no platform is refused with refused: name --platform ios or android, since simlock needs a platform and the CLI's allocate may send none.

Contract and event changes

None. src/contract/ does not change, so the protocol does not change. The front acts through lease.request, lease.cancel, lease.release and lease.list, which emit their events as for any client. The new routes are HTTP routes outside the daemon contract.

Other code on the same state

The state: simlock leases and lease requests the front creates, the wait queue, the front map, and the agent-device daemon's leases and sessions.

  • lease.release from the agent's own simlock release, an operator's release --all, the expiry timer, a lost device, or startup reconcile can end a simlock lease the front created. simlock wins. The front's later lease.release gets UNKNOWN_LEASE, which counts as released, and the next allocate's check finds the lease gone; either marks the entry ended. The agent-device lease stays until the agent releases it or simlock ending a simlock lease ends the agent-device session first #474 lands.
  • DELETE /v1/lease-requests/:id (src/http/app.ts:363) and socket lease.cancel can cancel the front's waiting request when sent with the same principal. The front's lease.request then rejects, and the front answers refused by simlock: <message> with nothing leased.
  • cancelPending (src/leasing/lease-acquisition-coordinator.ts:609-619) refuses to cancel a request whose device work has started. The grant then lands after the hang-up, and the front releases it.
  • GET /v1/lease-requests and simlock list show the front's leases and requests as ordinary ones, owned by the token's principal. Capacity and lease limits apply as for any client. The requester is the lease ID, so lease.maxPerRequester never blocks a second device for one token.
  • Daemon stop: stopAuxiliary (src/daemon/main.ts:476) stops the HTTP gateway first, which aborts open requests, so the front cancels its waits. leasing.settle also rejects waiters. Either order ends with nothing leased by a queued allocate. A grant from device work that was already running and lands during settle is a lease like any other: the front's release of it is refused as DAEMON_STOPPING, the lease persists across the stop as leases do, and it expires at its TTL, since no agent heartbeats it. Accepted: it needs a stop in the seconds a device is being prepared.
  • Daemon stop, decided by the maintainer (2026-10-13): today lease.cancel is refused as DAEMON_STOPPING once the stop begins and leasing.settle does not reject waiters, so a waiting request would stay open across the stop. This task adds the stop-time path: the front cancels the lease requests its allocates wait on before the daemon refuses calls (or leasing cancels them on stop), so no request an agent-device allocate waited on is left open to a restart.
  • simlock runs the agent-device daemon #470's supervisor can restart the agent-device daemon. While it restarts, the front answers not available with starting. Afterwards the host builds a new proxy for the new generation. An allocate in flight during the restart gets no usable reply and is handled as above. The restarted daemon has no leases; what their next heartbeat answers is Heartbeats keep the simlock lease alive #473.
  • The HTTP request log (src/http/app.ts:205) logs agent-device requests like any other route.

Rules in play

  • architecture.md rule 9: the agent-device daemon is reached through AgentDeviceUpstream, faked in tests.
  • architecture.md rule 10: buildHttpSession stays the one place that turns a token identity into a session; TokenStore.verify in requireAuth the one token check (the proxy's authenticate reads the identity the front tagged); each wire rule above has one owner in shared/.
  • architecture.md rule 11: each wait is bounded. The queue wait by timeoutMs 330 s from the moment the front admits the allocate. Each request through the proxy by the proxy's 5-minute upstream timeout.
  • architecture.md rule 12: every exit from an allocate leaves the simlock lease either in the front map with an agent-device lease, released, or, when agent-device says it still holds the lease, left to end at its TTL.
  • architecture.md rules 14, 15: the edge is entered through its index.ts; nothing in the core imports it.
  • safety.md rule 10: the /rpc body, the lease ID and the selectors are bounded and checked before they are used.
  • testing.md rule 2: each refusal test starts from an allocate that is granted, then changes one input.

Tests

Seam: the whole worker daemon in-process. startDaemon with a FakeDriver, HTTP on loopback, agentDevice.enabled, and a FakeAgentDevice passed as agentDeviceUpstream; requests sent with fetch, as the CLI sends them. Model: src/daemon/main.test.ts, "startDaemon HTTP gateway startup readiness" (:1018-1090). New file: src/agent-device/edge.test.ts. Five lines use smaller seams: the adapter line and the authenticate line drives the real AgentDeviceUpstream over #470's supervisor and its fake ports in src/agent-device/host/upstream.test.ts (new), modelled on #470's src/agent-device/host/agent-device-supervisor.test.ts; the two front map lines drive the map with a FakeClock and a stub lease.list in src/agent-device/front/front-map.test.ts (new), modelled on src/leasing/lease-lifecycle.test.ts; the command lease ID line calls the shared/ reader in src/agent-device/shared/rpc.test.ts (new).

  • with agentDevice.enabled false, and on a gateway-mode daemon, /v1/agent-device/health answers 404, as today
  • /v1/agent-device/rpc with no simlock token answers 401, with a worker join token 403, and with an operator token 403 refused: agent-device through simlock needs an agent token; the agent-device daemon receives nothing
  • /v1/agent-device/health with a simlock token answers the proxy's health, with the agent-device daemon's health under upstream
  • with agentDevice.enabled true and agent-device unavailable, /health, /rpc and an upload each answer agent-device is not available on this simlock: <reason>, nothing is leased, and POST /v1/lease-requests still grants a device
  • the real AgentDeviceUpstream adapter, over simlock runs the agent-device daemon #470's supervisor with its fake ports, reports running with the supervisor's base URL, token, generation and the ceiling, unavailable with the supervisor's message (found 0.20.3, need 0.21.25 or later), and unavailable with starting while the supervisor starts
  • after the agent-device daemon restarts with a new base URL, token and generation, the next request reaches the new daemon
  • commands sent with two simlock tokens reach the agent-device daemon as two tenants, each its token's principal, though the client named another tenant
  • allocate --platform ios makes a simlock lease whose ID and requester are the CLI's lease ID, owned by the token's principal, and the lease request carries no allowDownload, physical, osVersion, mode, imageTag or idempotency key
  • the simlock lease's TTL is the CLI's ttlMs, 5 s when the CLI sent less, and lease.defaultTtlMs when it sent none; the allocate the agent-device daemon receives carries ttlMs equal to the ceiling in each case
  • allocate --target mobile, tv and desktop ask simlock for class phone, tv and desktop; allocate --device "iPhone 17 Pro", alone and with --target tv, asks for that model and no class
  • an allocate whose ttlMs is above lease.maxTtlMs answers refused by simlock: with simlock's BAD_REQUEST message and leaves no simlock lease; one with ttlMs: null gets lease.defaultTtlMs
  • allocate --device "My test phone" answers refused by simlock: with simlock's message, and leaves no simlock lease
  • each input refused before simlock (--platform macos, --udid, --serial, no platform, no lease ID, a lease ID that is not 32 lowercase hex, a ttlMs of "abc", -1 or 0, backend, retainOnClose: true, a provider* key, leaseProvider: "aws", and agent_device.command or agent-device.command carrying lease_allocate, lease_heartbeat or lease_release) answers its message from the table, simlock stores no lease request, and the agent-device daemon receives nothing (one test per input)
  • allocate and release sent as agent-device.lease.allocate and agent-device.lease.release act exactly as the agent_device.lease.* spelling
  • after the grant, the allocate the agent-device daemon receives names the granted device's address by udid on iOS and by serial on Android (the emulator serial, not the AVD name), and carries no device, deviceKey or target; the CLI gets the daemon's reply with the same lease ID
  • with every matching device leased, allocate waits, and is granted and answered once that device is released and reclaimed
  • a CLI that hangs up while its allocate waits, and one that hangs up before simlock admitted the request, leave simlock's queue, and neither request is granted when a device frees
  • a CLI that hangs up after device work started for it gets no agent-device lease: the grant that lands later is released at once and never reaches the agent-device daemon; when that device work fails and the request goes back to the queue, the front's repeated cancel removes it, and it is never granted
  • DELETE /v1/lease-requests/:id for the front's waiting request answers the allocate refused by simlock: with simlock's message, and nothing is leased
  • stopping the daemon while an allocate waits ends with no simlock lease, and the lease request it waited on is cancelled, not open, in state.json (a cancelled record kept for its retention is fine), so a restart does not pick it up
  • an allocate still waiting 330 s after the front admitted it answers timed out waiting for a device; nothing was leased, and simlock's queue is empty
  • an agent-device allocate that answers an error, and a grant that names no device address, release the simlock lease, and the CLI gets the error
  • an agent-device allocate with no usable reply (an unreachable daemon, a non-JSON body) releases the simlock lease and answers COMMAND_FAILED; when agent-device answers the follow-up release with a scope error, the simlock lease stays until its TTL, and when it answers LEASE_NOT_FOUND, the simlock lease is released
  • a CLI that hangs up while agent-device makes its lease leaves neither lease: the agent-device lease is released with the reply's scope, then the simlock lease; when that release fails, the simlock lease stays until its TTL
  • a heartbeat reaches the agent-device daemon unchanged, its reply reaches the CLI, and the simlock lease's deadline does not move
  • release, and release after close (which the daemon answers released: false), go to the agent-device daemon first, then release the simlock lease, and the device is reclaimed and free
  • a release the agent-device daemon fails keeps the simlock lease and answers the daemon's error
  • a release after simlock release --all ended the simlock lease answers the daemon's reply
  • a release from a second token naming the first token's lease ID leaves the first token's simlock lease in place
  • a command's reply, a streamed progress body included, reaches the CLI unchanged, and a CLI that hangs up during it aborts the request the agent-device daemon was answering
  • an upload preflight answers a ticket URL under /v1/agent-device/upload/direct/ on simlock's own origin, carrying the agent's own simlock token, and a PUT to it reaches the agent-device daemon
  • an /rpc body over 1 MiB, one that is not JSON, a JSON array, and an object with no method are each refused with their message and id: null, also while agent-device is unavailable, and neither simlock nor the agent-device daemon sees them
  • (front map) an ended entry is gone one TTL after it ended, at the next read or write
  • (front map) at an allocate, an entry past its latest deadline whose lease lease.list no longer shows is marked ended, and one it still shows takes the listed deadline and stays
  • (host, upstream.test.ts) authenticate returns the tagged identity's principal as the tenant, and null for a request the front did not tag
  • (shared) a command's lease ID is read from params.meta.leaseId, else params.flags.leaseId, and a value that is not 32 lowercase hex is no lease ID

Done when

  • Every line under Tests is a test in src/agent-device/edge.test.ts (the adapter and authenticate lines in src/agent-device/host/upstream.test.ts, the front map lines in src/agent-device/front/front-map.test.ts, the shared line in src/agent-device/shared/rpc.test.ts), and each fails when the behaviour it names is removed.
  • With agentDevice.enabled true and HTTP on, /v1/agent-device/health with a simlock token answers the proxy's health; with it false, 404.
  • An allocate followed by a release leaves the device free in simlock list --devices, shown by the release test.
  • docs/HTTP-API.md documents /v1/agent-device/*, the one-token-per-agent rule and the messages above.
  • No line needs real hardware: End-to-end tests: agent-device through a simlock worker on real iOS and Android devices #477 runs these flows with the real CLI on iOS and Android.

Out of scope

Depends on

Approval

  • Approved for delivery

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 12, 2026
  2. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    Spec updated: fixes from the spec check (off vs broken split, aliases and wrapped lease calls, deviceKey and --device/--target, TTL floor and ceiling, hang-up before admission and after agent-device made the lease, proxy rebuilt per generation, bounded front map, shared/ rules owned here, missing tests).

    Written by an agent.

  3. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    Spec updated: fixes from the second spec check; agent tokens only; vendored proxy tarball, loaded lazily; request order, body refusals, ttlMs and lease-kind refusals, repeated cancel.

    Written by an agent.

  4. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    Spec updated: closure-check findings fixed (maintainer asked to fix all).

    Written by an agent.

  5. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 12, 2026
  6. github-actions commented on Oct 12, 2026

    @github-actions

    Now task:ready: approved, technical spec present, #471 closed.

  7. self-assigned this
    on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions