Repository navigation
An agent opens and closes a device through simlock #472
Description
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)
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 themessageof simlock runs the agent-device daemon #470's supervisor when it isunavailable(for examplefound 0.20.3, need 0.21.25 or later), andstartingwhile 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 MiBandrefused: send one JSON-RPC request(a body that is not JSON, a JSON array, or an object with no stringmethod); both answer withid: 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
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
src/http/app.ts:HttpGatewayDeps(:65-84) gains an optionalmounts: readonly HttpMount[].HttpMountis{ 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 sameagentAuth(:190) every other route uses, after the built-in routes and before thereturn(:719). The handler passesc.req.rawand the identity.requireAuth(src/http/auth.ts:43-59) answers 401 with no token and 403 for a worker join token, as on every/v1route.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) gainsagentDeviceUpstream?: AgentDeviceUpstream. When it is given, main uses it and does not start simlock runs the agent-device daemon #470's supervisor. Tests pass aFakeAgentDevicehere.- When
agentDevice.enabledis true, main builds the host, the in-process host link and the front map beforecreateLeasing(:285), after simlock runs the agent-device daemon #470's supervisor. They need nothing from leasing. The host needstokens, so theTokenStoreconstruction (:317, which needs onlyclock,filesystem,idGeneratorand the data directory) moves abovecreateLeasing. InstartHttpGateway(:514-528) main builds the front with that front map and host link, and passes its mount tocreateHttpApp. The front gets the samedispatchclosure andclockthe app gets. WithagentDevice.enabledfalse, 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 itsHttpMount). Also exportsAgentDeviceUpstream.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-deviceand the proxy's own base path/agent-device. - Reading an
/rpcbody: 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,.heartbeator.release, or the same withagent-device.lease.(agent-device'shttp-rpc-methods.tsaccepts both spellings). A wrapped lease call isagent_device.commandoragent-device.commandwhoseparams.commandislease_allocate,lease_heartbeatorlease_release. Anything else is a command. - Reading a lease call's fields from
params:leaseId,ttlMs,platform,target,device,udid,serialanddeviceKey. - Reading a command's lease ID:
params.meta.leaseId, elseparams.flags.leaseId, the order agent-device's ownleaseScopeFromRequestuses. Heartbeats keep the simlock lease alive #473 and The agent-device front refuses requests that reach past the agent's device, anddeviceslists 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", aresultobject whoseokistrue, and noerror(agent-device'shttp-server.tssends a failed call as anerrorenvelope). 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 ownid.data.codeisINVALID_ARGSfor a refusal andCOMMAND_FAILEDfor 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.
- The front's path prefix
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 butagentanswers 403refused: 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 withrefused: 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'sTokenIdentity, the granted device's address, the lease's TTL (the granted lease'sttlMs), 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 alease.releasefor it gotUNKNOWN_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 onelease.listper 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; alease.listthat 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
ttlMsraised to at least 5 s (agent-device's own shortest window), or none when the CLI sent none, so simlock applieslease.defaultTtlMs. AttlMsofnullcounts as absent. AttlMspresent but not a positive finite number is refused before simlock sees anything; one abovelease.maxTtlMsis sent as is, and simlock'sBAD_REQUESTanswersrefused by simlock: <message>. - The agent-device ceiling:
HostLink.status()reportsleaseMaxTtlMswhile running. It is the value simlock runs the agent-device daemon #470's supervisor passes asAGENT_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 inhost/agent-device-supervisor.ts). Every allocate the front forwards carriesttlMsequal 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.requestwith the sessionbuildHttpSession(identity, { onRequestAdmitted })builds:platform;modelfrom--devicewhen it is sent, elseclassfrom--target(mobile→phone);leaseIdandrequesterIdboth 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 noallowDownload,physical,osVersion,mode,imageTagor idempotency key. Over that wait:- When the request's signal aborts after simlock admitted the request (
onRequestAdmittedfired), the front dispatcheslease.cancelwithrequesterId= the lease ID. When it aborts before admission, the front dispatcheslease.cancelthe momentonRequestAdmittedfires. A grant that resolves after the abort is released at once withlease.releaseand never sent to agent-device. This coverscancelPending'snot-cancellableanswer while device work runs (src/leasing/lease-acquisition-coordinator.ts:609-619). After anot-cancellableanswer the front sendslease.cancelagain every 1 s until it answerscancelledornot-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_TIMEOUTanswerstimed out waiting for a device; nothing was leased. Any other simlock error answersrefused by simlock: <message>.- After the grant, the front sends the CLI's allocate on through the host link with
device,deviceKeyandtargetremoved (the refused keys above never get this far),ttlMsset to the ceiling, and the granted device named bygrant.device.address:udidon iOS,serialon 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 asemulator-5554, on Android (src/drivers/android/index.ts:904), not the AVD name indriverDeviceId. The front passes it on and never parses it. A grant with no address is released at once and answers aCOMMAND_FAILEDerror. - 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 aCOMMAND_FAILEDerror.
- When the request's signal aborts after simlock admitted the request (
- Release. The front sends the release through the host link first. On a success reply (
releasedtrue or false; it is false aftercloseended the agent-device lease) it dispatcheslease.releaseas the map entry's identity and marks the entry ended.UNKNOWN_LEASEfrom 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.
- The request path, in this order:
src/agent-device/host/:- The host loads
@agent-device/proxywithawait import()only whenagentDevice.enabledis 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 fromcreateDaemonProxyper agent-device daemon start. It builds one when it first sees agent-device running, and builds a new one whenAgentDeviceUpstream.current()reports a differentgeneration. Options:upstreamBaseUrlandupstreamTokenfromcurrent(),upstreamFetchthe host's own function that callsAgentDeviceUpstream.fetch(simlock ending a simlock lease ends the agent-device session first #474 records release scopes there), andauthenticate.authenticatedoes not check the token again:requireAuthalready did, withTokenStore.verify, the one token check. The front tags eachRequestit forwards with itsTokenIdentity(aWeakMapthe host owns), andauthenticatereturns{ tenantId: identity.requesterId }for a tagged request andnullfor one the front did not tag. forwardrewrites the request's path prefix/v1/agent-deviceto the proxy's base path/agent-device, keeping origin, query, headers, body and signal, and hands it toproxy.handle. The proxy serves only paths under/agent-device(resolveProxyRoutein the proxy) and builds upload tickets from the request URL. So on an/upload/preflightreply the host puts/v1back in front of the ticket URL's path. The ticket's headers already carry the agent's own token (the proxy'sauthenticatemode), so the uploadPUTcomes back through the front's normal token check.AgentDeviceUpstreamport:current(): { state: "running"; baseUrl: string; token: string; generation: number; leaseMaxTtlMs: number } | { state: "unavailable"; reason: string }andfetch(request: Request): Promise<Response>. The real adapter reads simlock runs the agent-device daemon #470'sAgentDeviceSupervisor.availability():runninggives itsbaseUrl,tokenandgeneration, plus the ceiling above;unavailablegives itsmessageas the reason;startinggives the reasonstarting. (offnever reaches it: with the edge off there is no mount.) It uses the globalfetch. Its in-memory fake isFakeAgentDevice.
- The host loads
src/agent-device/testing.ts:FakeAgentDeviceplays the agent-device daemon behind the real proxy. It answers/health, JSON-RPC lease allocate (keeping the given ID and recording itsttlMsand scope), heartbeat, release (checking the scope it allocated with), commands includingopenandclose(closeends the lease, as the real daemon does), and upload preflight andPUT. 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>.tgzandpackage.json:@agent-device/proxyis afile:vendor/...dependency on a tarball packed from the agent-device integration branch withpnpm --filter @agent-device/proxy pack. Itsprepackruns the package's own build, which bundles agent-device's internal packages intodist, so the tarball has noworkspace:dependency.vendor/README.mdnames 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.releasefrom the agent's ownsimlock release, an operator'srelease --all, the expiry timer, a lost device, or startup reconcile can end a simlock lease the front created. simlock wins. The front's laterlease.releasegetsUNKNOWN_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 socketlease.cancelcan cancel the front's waiting request when sent with the same principal. The front'slease.requestthen rejects, and the front answersrefused 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-requestsandsimlock listshow 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, solease.maxPerRequesternever 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.settlealso rejects waiters. Either order ends with nothing leased by a queued allocate. A grant from device work that was already running and lands duringsettleis a lease like any other: the front's release of it is refused asDAEMON_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.cancelis refused asDAEMON_STOPPINGonce the stop begins andleasing.settledoes not reject waiters, so a waiting request would stayopenacross 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.mdrule 9: the agent-device daemon is reached throughAgentDeviceUpstream, faked in tests.architecture.mdrule 10:buildHttpSessionstays the one place that turns a token identity into a session;TokenStore.verifyinrequireAuththe one token check (the proxy'sauthenticatereads the identity the front tagged); each wire rule above has one owner inshared/.architecture.mdrule 11: each wait is bounded. The queue wait bytimeoutMs330 s from the moment the front admits the allocate. Each request through the proxy by the proxy's 5-minute upstream timeout.architecture.mdrule 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.mdrules 14, 15: the edge is entered through itsindex.ts; nothing in the core imports it.safety.mdrule 10: the/rpcbody, the lease ID and the selectors are bounded and checked before they are used.testing.mdrule 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.enabledfalse, and on a gateway-mode daemon,/v1/agent-device/healthanswers 404, as today /v1/agent-device/rpcwith no simlock token answers 401, with a worker join token 403, and with an operator token 403refused: agent-device through simlock needs an agent token; the agent-device daemon receives nothing/v1/agent-device/healthwith a simlock token answers the proxy's health, with the agent-device daemon's health underupstream- with
agentDevice.enabledtrue and agent-device unavailable,/health,/rpcand an upload each answeragent-device is not available on this simlock: <reason>, nothing is leased, andPOST /v1/lease-requestsstill grants a device - the real
AgentDeviceUpstreamadapter, over simlock runs the agent-device daemon #470's supervisor with its fake ports, reportsrunningwith the supervisor's base URL, token, generation and the ceiling,unavailablewith the supervisor'smessage(found 0.20.3, need 0.21.25 or later), andunavailablewithstartingwhile 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 iosmakes a simlock lease whose ID and requester are the CLI's lease ID, owned by the token's principal, and the lease request carries noallowDownload,physical,osVersion,mode,imageTagor idempotency key - the simlock lease's TTL is the CLI's
ttlMs, 5 s when the CLI sent less, andlease.defaultTtlMswhen it sent none; the allocate the agent-device daemon receives carriesttlMsequal to the ceiling in each case - allocate
--target mobile,tvanddesktopask simlock for classphone,tvanddesktop; allocate--device "iPhone 17 Pro", alone and with--target tv, asks for that model and no class - an allocate whose
ttlMsis abovelease.maxTtlMsanswersrefused by simlock:with simlock'sBAD_REQUESTmessage and leaves no simlock lease; one withttlMs: nullgetslease.defaultTtlMs - allocate
--device "My test phone"answersrefused 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, attlMsof"abc",-1or0,backend,retainOnClose: true, aprovider*key,leaseProvider: "aws", andagent_device.commandoragent-device.commandcarryinglease_allocate,lease_heartbeatorlease_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.allocateandagent-device.lease.releaseact exactly as theagent_device.lease.*spelling - after the grant, the allocate the agent-device daemon receives names the granted device's address by
udidon iOS and byserialon Android (the emulator serial, not the AVD name), and carries nodevice,deviceKeyortarget; 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/:idfor the front's waiting request answers the allocaterefused 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 answersLEASE_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 answersreleased: 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 --allended 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 aPUTto it reaches the agent-device daemon - an
/rpcbody over 1 MiB, one that is not JSON, a JSON array, and an object with nomethodare each refused with their message andid: 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.listno longer shows is marked ended, and one it still shows takes the listed deadline and stays - (host,
upstream.test.ts)authenticatereturns the tagged identity's principal as the tenant, andnullfor a request the front did not tag - (shared) a command's lease ID is read from
params.meta.leaseId, elseparams.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 andauthenticatelines insrc/agent-device/host/upstream.test.ts, the front map lines insrc/agent-device/front/front-map.test.ts, the shared line insrc/agent-device/shared/rpc.test.ts), and each fails when the behaviour it names is removed. - With
agentDevice.enabledtrue and HTTP on,/v1/agent-device/healthwith 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.mddocuments/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
- Renewing the simlock lease on heartbeats and while a command runs, raising a heartbeat's
ttlMsto the ceiling, and what a heartbeat answers once a lease is gone (Heartbeats keep the simlock lease alive #473). - Ending the agent-device session when simlock ends a lease, and recording release scopes on the host (simlock ending a simlock lease ends the agent-device session first #474).
- Refusing other devices' selectors,
boot,shutdown, routes outside a list, worker-path installs, and filteringdevices(The agent-device front refuses requests that reach past the agent's device, anddeviceslists only its own #475). - Running the agent-device daemon, its version check and its policy file (simlock runs the agent-device daemon #470);
simlock doctor(simlock doctor says why agent-device is not available #471). - Gateway mode.
Depends on
Approval
- Approved for delivery
Written by an agent.
Activity
V3RON commentedon Oct 12, 2026
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.
V3RON commentedon Oct 12, 2026
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.
V3RON commentedon Oct 12, 2026
Spec updated: closure-check findings fixed (maintainer asked to fix all).
Written by an agent.
github-actions commentedon Oct 12, 2026
Now
task:ready: approved, technical spec present, #471 closed.feat(agent-device): the front, the host and the allocate flow (#472)
refactor(agent-device): split the allocate flow (#472)
refactor(agent-device): split the refusal check (#472)
feat(agent-device): cancel waiting allocates' lease requests on stop (#…
test: exempt the vendored agent-device tarball from the NUL check (#472)
fix(agent-device): release with the right scope, count waiting alloca…
test(agent-device): make the oldest-deadline and 5 s lease.list tests…