Repository navigation
Input socket for a lease #1017
Description
Part of #1013.
Scope
After this PR, a client with an agent token opens WS /v1/leases/{id}/input on a gateway or a standalone worker. It gets 0x83, then 0x82, and its serve-sim messages reach the worker that holds the lease. No device takes input yet: every device reports "inputUnavailable":true until #1018 and #1019 add the Android and iOS device inputs. The socket ends when the lease ends, when either end goes quiet, and when the daemon stops.
This task adds the new optional module input, the upgrade routing in ports, the lease socket seam and the device input port. It is the feature's widest task (five Behaviours rows, about 18 modules); it does not split further, because the socket, its gateway relay and its lifecycle are one path a test drives end to end.
In short
A lease holder's app opens one WebSocket for its lease and gets 0x83 (input admitted), then 0x82 {"width":1179,"height":2556,"orientation":"portrait","inputUnavailable":true}. Through a gateway, the socket rides #1016's two-way relay to the lease's worker. It closes with 4001 and the lease-end reason (explicit, killed, expired, device-lost) when the lease ends.
New term: device input, what turns messages into taps on one device. One per lease, shared by up to 8 sockets (Shared values, "Device input port"). This task adds the port and a placeholder that answers inputUnavailable on every platform.
flowchart LR
App[App] -- WS upgrade --> P[ports upgrade router NEW]
P --> H[http: lease resolve for the caller]
H --> GI[input module, gateway side NEW]
GI -- two-way relay #1016 --> WI[input module, worker side NEW]
H --> WI
WI -- WorkerContext.inputs NEW --> D[device input: none yet → inputUnavailable]
Messages a client sees, word for word: the refusal for a 9th socket is close 1013 with reason Input is unavailable for this lease; lease end is close 4001 with the reason text explicit, killed, expired or device-lost; worker unreachable is close 1011 Worker <workerId> stopped answering; daemon stop is close 1001 Simlock is stopping.
Input turned off on the lease's worker behind a gateway, before the upgrade:
$ websocat -H "Authorization: Bearer $AGENT_TOKEN" wss://gw:7480/v1/leases/L-42/input
websocat: WebSocketError: Received unexpected status code (503 Service Unavailable)
# body: {"error":{"code":"INPUT_UNAVAILABLE","message":"Worker w1 does not take input for lease L-42; turn on capabilities.input.enabled there"}}
| Request | Today | After |
|---|---|---|
upgrade /v1/leases/L-42/input, owner, input on |
404, socket destroyed | 101, then 0x83, 0x82 |
| upgrade, another agent's lease | 404 | 404 UNKNOWN_LEASE |
| upgrade, no token | 404 | 401 UNAUTHENTICATED |
| upgrade, input off on the answering daemon | 404 | 404 |
upgrade /v1/uplink with a worker token, on a gateway |
101 | 101 (unchanged) |
upgrade /v1/leases/L-42/stream |
404 | 404 (only input takes sockets) |
If this goes wrong, a user sees T3 Code's device panel say "input socket refused" or never mark input as connected.
Technical spec
Facts
Commit e1f2f43.
| # | Fact | Source |
|---|---|---|
| F1 | The gateway's only upgrade listener answers 404 and destroys the socket for any path but /v1/uplink. |
src/ports/uplink-websocket.ts:214-227 |
| F2 | It is attached to the gateway's HTTP server through onServerCreated; a worker's HttpGateway gets no such hook, so a worker upgrades nothing. |
src/daemon/main.ts:1158-1165, src/daemon/main.ts:807-811, src/http/server.ts:41-49 |
| F3 | Only uplink-websocket.ts imports ws (8.18.3), with new WebSocketServer({ noServer: true }). |
src/ports/AGENTS.md:53, package.json:82, src/ports/uplink-websocket.ts:181 |
| F4 | The uplink authenticates the bearer token before handleUpgrade, answering 401 or 403 and destroying the socket on refusal. |
src/ports/uplink-websocket.ts:249-264 |
| F5 | The uplink's worker side pings every 15 s and terminates when no pong came since the last ping. | src/ports/uplink-websocket.ts:39-41, src/ports/uplink-websocket.ts:325-340 |
| F6 | HTTP auth resolves a bearer token from request headers through Auth.authenticateRequest(headers). |
src/auth/index.ts:46-47 |
| F7 | A module mount is (Request, identity) → Promise<Response>, mounted under /v1/<module name>; it cannot take an upgrade. |
src/modules/types.ts:85-90, src/modules/host.ts:377-381 |
| F8 | A worker module returns capability, relayTarget, mount, findings, hooks; the host collects relay targets by module name. |
src/modules/types.ts:103-109, src/modules/host.ts:307-310 |
| F9 | ModuleScreens hands a module the drivers' capture, routed by platform, without the drivers; the worker builds it from DriverCatalog. |
src/modules/types.ts:120-129, src/daemon/main.ts:519 |
| F10 | beforeDeviceReclaim is how a worker module hears a lease end, with ModuleEndedLease.reason explicit, killed, expired or device-lost. |
src/modules/types.ts:44-50, src/modules/types.ts:96-101, src/modules/host.ts:312-318 |
| F11 | A gateway module reads the fleet's leases through FleetLeases.resolve(gatewayLeaseId) and hears removals through onRemoved. |
src/modules/types.ts:164-177 |
| F12 | Both daemons run moduleHost.beforeDrain() before stopping the HTTP gateway, which destroys open sockets without a close frame. |
src/daemon/main.ts:741-744, src/daemon/main.ts:1102-1107, src/http/server.ts:206-221 |
| F13 | The stream's gateway side maps RELAY_TARGET_MISSING to 503 with a "turn on capabilities.stream.enabled there" message. |
src/stream/gateway-link.ts:68-72 |
| F14 | A module's error codes are declared in its definition.ts statuses, not in the contract (STREAM_UNAVAILABLE). |
src/stream/definition.ts:58-65 |
| F15 | Optional modules are listed in one registry array; a module's capability name is its config section capabilities.<name>.enabled. |
src/module-registry/index.ts:1-7, src/core/config.ts:256-258 |
| F16 | The stream envelope is a 4-byte big-endian length, a tag byte, then the payload. | src/core/screen.ts:71-77 |
| F17 | Expo serve-sim refuses a socket past 8 with close 1013, sends 0x83 on attach, then the 0x82 config. |
expo/expo-device-hub v0.19.0-canary-20261023-1357114:packages/serve-sim/packages/serve-sim/src/device-session.ts:136, :1384-1392, :1449-1453 |
| F18 | Expo serve-sim answers 0x10 with 0x90 and JSON {requestId, ok, error?}. |
expo/expo-device-hub v0.19.0-canary-20261023-1357114:packages/serve-sim/packages/serve-sim/src/device-session.ts:1781-1806 |
| F19 | Expo serve-sim's 0x82 carries inputUnavailable and keyboardUnavailable. |
expo/expo-device-hub v0.19.0-canary-20261023-1357114:packages/serve-sim/packages/serve-sim/src/device-session.ts:2808-2826 |
| F20 | T3 Code opens /helper/ws?device=<id>, sends nothing first, marks input admitted on 0x83 and blocked by inputUnavailable in 0x82. |
pingdotgg/t3code v1316d48:packages/client-runtime/src/device/stream.ts:1075-1102 |
| F21 | T3 Code sends input only after 0x83 and while 0x82 does not say unavailable; earlier input is dropped. |
pingdotgg/t3code v1316d48:packages/client-runtime/src/device/stream.ts:1275-1282 |
| F22 | T3 Code treats close 1008 and 4401 as unauthorized and retries any other close after 1 s. | pingdotgg/t3code v1316d48:packages/client-runtime/src/device/stream.ts:1133-1151 |
| F23 | The stream module's worker and gateway files show the module shape this one copies. | src/stream/worker.ts:4-21, src/stream/gateway.ts:4-14 |
| F24 | capabilities.stream.enabled is documented on the configuration page; access control lists what an agent token may do. |
website/src/content/docs/docs/reference/configuration.mdx:314, website/astro.config.mjs:102, website/astro.config.mjs:131 |
Modules touched
Searches: upgrade, WebSocketServer, onServerCreated, ModuleMount, relayTarget, ModuleScreens, screens:, beforeDeviceReclaim, optionalModules, capabilities.stream over src/, website/, ui/src.
src/input/(new):definition.ts,index.ts,worker.ts,gateway.ts, the session (messages, sharing, heartbeat, lease end) and its manualAGENTS.md.src/core/input.ts(new),src/core/index.ts: message tags and JSON shapes from the feature's Shared values "Input messages".src/ports/: an upgrade router beside the uplink (uplink-websocket.tsand a new file), itsindex.tsandAGENTS.md.src/modules/types.ts,src/modules/host.ts: the lease socket handler onWorkerModuleandGatewayModule,WorkerContext.inputs.src/http/: the lease socket resolution next to A lease holder streams its lease's screen #1015's lease route.src/daemon/main.ts: router on both listeners,inputsfromDriverCatalog.src/drivers/catalog: anopenInputthat answers "unavailable" for every platform.src/module-registry/index.ts: addinputModule.website/src/content/docs/docs/using-devices/watching-the-screen.mdx,.../reference/configuration.mdx,.../gateway/access-control.mdx.
flowchart LR
daemon[daemon/main.ts *] --> ports[ports upgrade router *]
daemon --> host[modules/host.ts *]
ports --> http[http lease resolution *]
http --> input[input module NEW]
host --> input
input --> core[core/input.ts NEW]
input -- GatewayContext.relayTwoWay --> gw[gateway relay #1016]
input -- WorkerContext.inputs --> drivers[driver catalog *]
sequenceDiagram
participant App
participant GW as Gateway (ports, http, input)
participant W as Worker (input)
App->>GW: GET /v1/leases/L-42/input, Upgrade: websocket
GW->>GW: authenticate, resolve L-42 for the caller
alt not theirs or unknown
GW-->>App: 404 UNKNOWN_LEASE, socket destroyed
else
GW->>W: two-way relay, target input, lease L-42
alt input off on the worker
W-->>GW: RELAY_TARGET_MISSING
GW-->>App: 503 INPUT_UNAVAILABLE, socket destroyed
else
GW-->>App: 101 Switching Protocols
W-->>App: 0x83, 0x82
App->>W: 0x03 touch
W-->>GW: 0xF0 every 5 s (dropped)
Note over GW,W: 15 s with no bytes from W
GW-->>App: close 1011
end
end
Surface and extension points
src/input/index.tsexportsinputModule, anOptionalModuleDefinition(F15, F23). Its worker factory returns aWorkerModulewithcapability, a lease socket handler,relayTwoWay(the worker end of Two-way relay over the uplink #1016's two-way relay, see the feature's Two-way relay row), andhooks.beforeDeviceReclaimandhooks.beforeDrain. Its gateway factory returns a lease socket handler andhooks.beforeDrain.- Lease socket seam:
WorkerModule.socketandGatewayModule.socket, a handler(socket, identity, lease), written to the feature's Shared values "Lease socket". The socket type is a small port interface (send,close(code, reason),onMessage,onClose), so no module importsws(F3). portsgains one upgrade router per HTTP server:/v1/uplinkgoes to the uplink as today;/v1/leases/{id}/<segment>goes to http's lease resolution; anything else gets 404 and is destroyed (F1). A worker'sHttpGatewaygets the router throughonServerCreatedtoo (F2).WorkerContext.inputs: ModuleInputs, modeled onModuleScreens(F9):openInput(platform, device, signal)→ a device input (send(message),onConfig(listener),close()). This task's driver side answers "unavailable" for every platform; Android input through scrcpy's control channel #1018 and iOS simulator input through the HID injector #1019 are the two implementations, so the port is added now (architecture rule 19: "when a second implementation exists or is being built").
Contract and event changes
none. Searched src/contract/ for relay, capability and error shapes: #1016 makes the relay change and bumps the protocol; INPUT_UNAVAILABLE is a module error code declared in src/input/definition.ts (F14); the capability input uses the existing {name, state, reason?} shape. No event is added; opening and refusing sockets is logged only.
New wire fields, all from the client: each WebSocket message is one tag byte and UTF-8 JSON (feature Shared values "Input messages"); a message over 64 KiB, bad JSON, an unknown tag, or a position outside 0..1 is dropped and the socket stays open (F17's server does the same). Config capabilities.input.enabled: boolean, default false.
Other code on the same state
Searches: on("upgrade" (1 hit, F1), handleUpgrade (1), beforeDeviceReclaim (2: stream, agent-device none), beforeDrain (module host, 2 daemons), leases.onRemoved (1: agent-device relay-host-link).
- The uplink's upgrade handling (F1): now behind the router; an uplink upgrade must behave byte for byte as today.
src/ports/uplink-websocket.test.tsalready proves it and must stay green unchanged. - The stream module's
beforeDeviceReclaim(F10): both modules hear the same lease end; neither waits for the other, and each closes only its own sockets. - A lease holder streams its lease's screen #1015's lease route (feature Shared values "Lease route"): this task reuses its lease resolution for upgrades; a plain GET to
/v1/leases/{id}/inputwith no upgrade answers 426. HttpGateway.stop(F12): the input module closes its sockets with 1001 inbeforeDrain, which runs first, so no socket is destroyed without a close frame.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
| Upgrade auth (F4, F6) | no or unknown token | 401, socket destroyed | none, synchronous |
| Lease resolution | not theirs, unknown | 404 UNKNOWN_LEASE, socket destroyed |
none |
| Gateway → worker two-way relay | ok | 101, then the worker's 0x83 and 0x82 | #1016's channel accept, 30 s from the open |
| Gateway → worker two-way relay | RELAY_TARGET_MISSING |
503 INPUT_UNAVAILABLE before the upgrade (F13) |
same |
| Gateway → worker two-way relay | WORKER_UNREACHABLE, no answer |
503 WORKER_UNREACHABLE before the upgrade |
same |
| Worker heartbeat | silent | close 1011, relay ended | 15 s from the last byte from the worker |
| Uplink | closed | every socket on that worker closes 1011 | at once |
| Client ping | no pong | socket terminated, its share of the device input released | 15 s from the ping, pings every 15 s |
inputs.openInput |
unavailable (every platform in this task) | 0x83, then 0x82 with "inputUnavailable":true; messages dropped |
none |
inputs.openInput |
throws | close 1013 Input is unavailable for this lease |
the open's own bound, set by #1018/#1019 |
Device input send |
throws | every socket of the lease closes 1013; the device input closes | at once |
| Socket count | 9th socket | close 1013 before 0x83 (F17) | none |
| Lease end (F10) | any reason | 0xF1 {code:4001, reason} on the channel, then close 4001 with the reason |
the participant's own signal |
Gateway leases.onRemoved (F11) |
lease gone, socket still open | close 4001 with reason explicit |
at once |
| Daemon stop (F12) | beforeDrain |
close 1001 Simlock is stopping on every socket |
before the HTTP gateway stops |
State: the device input per lease and its socket set, in memory only. A worker restart drops them; through a gateway the uplink closes, so its sockets close 1011; standalone, the TCP connection closes. Two sockets opening at once for one lease share one device input: the first open runs, the second waits for it.
Rules in play
- Architecture rule 11, "Every wait that crosses a process boundary is bounded": the relay open (30 s), the worker silence (15 s) and the client pong (15 s) are each counted from a fixed point.
- Architecture rule 10, "A rule is enforced in exactly one place": lease ownership is checked only by http's lease resolution; the module trusts the lease it is handed.
- Architecture rule 15, "Dependencies point from the optional to the required":
inputimportsmodules,coreandcontract; nothing required importsinput. - Architecture rule 18, "A module hands out a capability, not its parts":
index.tsexports onlyinputModule. - Architecture rule 19, "An extension point is added when a second implementation is named":
ModuleInputshas Android input through scrcpy's control channel #1018 and iOS simulator input through the HID injector #1019 as its implementations. src/ports/AGENTS.md:53, "Onlyuplink-websocket.tsimportsws": the router lives beside it in ports and owns the only otherwsimport; the rule's wording changes to name both files.- Documentation rule 9, "A change to the public surface updates the website in the same change": the route,
capabilities.input.enabledandINPUT_UNAVAILABLEgo on the three pages under Modules touched. - ADR 0049 §1–§6: lease routes, the module lease route, expo serve-sim messages, the shared device input, the two-way relay's heartbeat and lease end.
Tests
Seam: the gateway and standalone worker HTTP listeners with a faked device input, in a new src/daemon/input-lease.e2e.test.ts modeled on src/daemon/stream-gateway.e2e.test.ts; the session's message handling in src/input/ modeled on src/stream/worker-link.test.ts.
- A caller opens
WS /v1/leases/{id}/inputand gets 0x83 then 0x82, or a refused upgrade: own lease; another's 404; unknown 404; operator; input off on the answering daemon 404; off on the lease's worker 503INPUT_UNAVAILABLE; no token 401; a 9th socket closed 1013; a platform with no device input gets"inputUnavailable":true. - A client following T3 Code's order drives a faked device through a gateway: waits for 0x83; touch begin/move/end, key, button arrive in order.
- A client sending a bad message keeps its socket and nothing reaches the device: bad JSON; unknown tag; x outside 0..1; 0x10 answered 0x90
{ok:false}. - An input socket closes with 4001 and the reason when the lease ends, and a reconnect gets 404:
explicit,expired,killed,device-lost. - An input socket closes when either end goes quiet or away: client misses its pong (worker and gateway); worker heartbeat stops (1011); uplink drops (1011); daemon stops (1001); the gateway sees the lease go (4001).
The uplink's upgrade, unchanged, stays proven by src/ports/uplink-websocket.test.ts.
Done when
pnpm testpasses, includingsrc/daemon/input-lease.e2e.test.tsandsrc/ports/uplink-websocket.test.tsunchanged.- With
capabilities.input.enabledtrue on a standalone worker, an agent token's upgrade to its own lease's/v1/leases/{id}/inputgets 0x83 then 0x82 with"inputUnavailable":true; another token gets 404. pnpm openapilistsGET /v1/leases/{id}/inputwith 101, 401, 404, 426 and 503INPUT_UNAVAILABLE.- The three website pages under Modules touched describe the input socket,
capabilities.input.enabledandINPUT_UNAVAILABLE.
Out of scope
- Any device actually taking input: Android in Android input through scrcpy's control channel #1018, iOS simulators and physical iPhones in iOS simulator input through the HID injector #1019, iOS rotation in iOS simulator rotation through Simulator.app #1020.
- The two-way relay itself and its protocol bump (Two-way relay over the uplink #1016).
- The lease stream route and the lease route seam for plain HTTP (A lease holder streams its lease's screen #1015).
- Input from the console; a preview
{url, token}.
Depends on
Written by an agent.
Activity