Repository navigation
A lease holder streams its lease's screen #1015
Description
Part of #1013.
Scope
A lease holder watches its own lease's screen with its agent token, on a standalone worker and through a gateway, at GET /v1/leases/{id}/stream. Every stream that a lease end stops now says why in its last frame, and the console shows that reason. This task also adds the lease route seam (Shared values: Lease route on #1013) that #1017 reuses.
In short
An agent token can now open GET /v1/leases/L-42/stream for a lease it owns and get the same frames the device route gives an operator (F1, F7). Another agent's lease and an unknown lease answer 404 UNKNOWN_LEASE with Unknown lease: <id>, as GET /v1/leases/{id} does (F3, F19). When the lease ends, the last frame on every stream of that device is a new tag 0x07 holding {"reason":"explicit"} (or killed, expired, device-lost), then the body ends. A leased phone that drops off also gets device-lost. The console says why, in the words of Shared values: ended frame on #1013, and not as an error.
flowchart LR
C[Caller, agent token] --> H["http: /v1/leases/{id}/stream (NEW)"]
H -->|lease.get: owner or operator, else 404| S[stream module lease handler NEW]
S -->|worker| Cap[captures by device]
S -->|gateway: relay with leaseId| W[worker relay target /stream/leases/<workerLeaseId> NEW]
W --> Cap
E[lease ends] -->|0x07 frame, then end CHANGED| Cap
Before and after, through a gateway:
$ curl -s -H "Authorization: Bearer $AGENT" https://gw:7480/v1/leases/L-42/stream -o /dev/null -w '%{http_code}\n'
404 # before: no such route
200 # after: the lease's picture
$ curl -s -H "Authorization: Bearer $OTHER_AGENT" https://gw:7480/v1/leases/L-42/stream
{"error":{"code":"UNKNOWN_LEASE","message":"Unknown lease: L-42"}}
| Request | Today | After |
|---|---|---|
| agent, own lease | 404 | 200 |
| agent, another's lease | 404 | 404 UNKNOWN_LEASE |
| unknown lease | 404 | 404 UNKNOWN_LEASE |
| operator, any lease | 404 | 200 |
capabilities.stream.enabled false on the answering daemon |
404 | 404, as any unrouted path |
| agent, device route | 404 UNKNOWN_DEVICE |
404 UNKNOWN_DEVICE (unchanged) |
If this goes wrong, a user would notice an agent seeing another agent's screen, or a picture that stops with no reason.
Technical spec
Facts
Commit e1f2f43.
| # | Fact | Source |
|---|---|---|
| F1 | The only stream route is the device route, and only an operator may use it: an agent gets 404 UNKNOWN_DEVICE on the worker and on the gateway |
src/stream/definition.ts:25-29, src/stream/worker-link.ts:118-120, src/stream/gateway-link.ts:39-41 |
| F2 | Module routes mount only at /v1/<prefix> and /v1/<prefix>/*, behind agentAuth; the prefix is the module's name |
src/http/app.ts:781-785, src/modules/host.ts:272-275, src/modules/host.ts:377-381 |
| F3 | GET /v1/leases/{id} resolves through lease.get, which answers 404 UNKNOWN_LEASE alike for an unknown lease and one the caller does not own; an operator reads every lease |
src/http/app.ts:803-811, src/http/lease-declarations.ts:295-303 |
| F4 | A gateway's lease record carries workerId and workerLeaseId, the worker's own lease id |
src/contract/schemas.ts:1386-1390, src/gateway/dispatcher.ts:547-554 |
| F5 | A gateway module relays with an optional lease id: relay(workerId, request, tenant, leaseId?), and the worker target receives (request, tenant, leaseId) |
src/modules/types.ts:202, src/modules/host.ts:223-229, src/modules/types.ts:60 |
| F6 | The worker's relay target serves relayed requests as an operator, trusting the gateway's check | src/stream/mounts.ts:37-46 |
| F7 | Captures are keyed by device; a lease end calls stopOnDevice(deviceId), which aborts each slot and closes it, and reads no reason |
src/stream/captures.ts:20-25, src/stream/captures.ts:96-103, src/stream/captures.ts:112-125, src/stream/worker.ts:18 |
| F8 | The lease end the stream hook gets carries reason: explicit, killed, expired or device-lost |
src/modules/types.ts:43-49 |
| F9 | A device stopping (shutdown, absence, quarantine, delete) also stops its captures, with no lease end | src/stream/create.ts:16-21, src/stream/create.ts:64-71 |
| F10 | A stream body ends by closing the feed and the controller; no final frame is written | src/stream/feed.ts:108-113 |
| F11 | Frame tags are 0x01–0x06; a frame is a 4-byte big-endian length, a tag byte and the payload | src/core/screen.ts:7-18, src/core/screen.ts:71-77 |
| F12 | The console skips a tag it does not know, handles tag 5 as screen meta, and tells its sink onEnd(reason) once |
ui/src/data/stream.ts:228-237, ui/src/data/stream.ts:66-74 |
| F13 | The console's test pins its tag table to SCREEN_FRAME_TAG |
ui/src/data/stream.test.ts:12, ui/src/data/stream.test.ts:625 |
| F14 | The worker answers a relayed path /stream/devices/<id>; any other path is no stream |
src/stream/service.ts:32-36, src/stream/service.ts:52 |
| F15 | An unrouted path gets Hono's default 404: src/http registers no not-found handler |
git grep -n notFound e1f2f43e6 -- 'src/http/*.ts' → no match |
| F16 | The worker's registry view lists its leases, so a module can check a lease against the worker's own record | src/modules/types.ts:113-116 |
| F17 | The website says the stream is operator-only and view-only, and lists what agents may do | website/src/content/docs/docs/using-devices/watching-the-screen.mdx:8, :12, :69-71; website/src/content/docs/docs/gateway/access-control.mdx:16 |
| F18 | Module routes enter the OpenAPI document from each definition's http.routes |
src/stream/definition.ts:22-29, src/http/openapi.ts:26 |
| F19 | UNKNOWN_LEASE reads Unknown lease: <id> on a worker and on a gateway |
src/core/registry.ts:154, src/gateway/dispatcher.ts:571 |
| F20 | When a leased physical device goes absent, the registry commits the lease end, then emits device.absence-detected with previousState: "leased"; the bus runs subscribers at once, so the stream's device-stop subscriber runs before the lease-end participants are called with device-lost |
src/core/registry.ts:579-589, src/bus/index.ts:512-516, src/stream/create.ts:67-70, src/leasing/lease-release-coordinator.ts:170-173 |
| F21 | The health monitor's lost device ends the lease through the participants with device-lost, with no device-stop event first |
src/leasing/lease-release-coordinator.ts:112-113 |
| F22 | The console passes the reader's end reason straight to the viewer, and the reader stores that reason as the stream's error | ui/src/state/screen.ts:175-176, ui/src/data/stream.ts:119-126 |
| F23 | A capture whose slot is stopped before its first frame answers 409 DEVICE_NOT_RUNNING "The device stopped before its picture started" |
src/stream/worker-link.ts:72-89, src/stream/refusal.ts:39-41 |
| F24 | A gateway passes a worker's refusal on with the worker's code and message | src/stream/refusal.ts:64-82 |
| F25 | A worker answers a relayed path it does not know with 404 UNKNOWN_DEVICE "This path names no stream" |
src/stream/service.ts:32-36, src/stream/service.ts:52, src/stream/refusal.ts:44-48 |
Modules touched
Searches: mount.prefix, HttpMount, ModuleMount, beginOnDevice, stopOnDevice, relayedDeviceTargetOf, SCREEN_FRAME_TAG, onEnd( across src/, ui/src/ and website/.
src/http/app.ts: the lease route:/v1/leases/{id}/<module name>for each loaded module that has a lease handler, behindagentAuth; resolves the lease withlease.get(F3), then calls the handler with(request, identity, lease).src/modules/types.ts,src/modules/host.ts:WorkerModule.leaseRouteandGatewayModule.leaseRoute, collected like mounts (F2). The host refuses to load a module whose name is a core lease sub-route (renew,exec), as it refuses a name that clashes with a core first segment; the core route wins (decided in delivery, pending the maintainer).src/stream/: the lease handler on worker and gateway, the relayed path/stream/leases/<workerLeaseId>, theendedframe on lease end, the route declaration.src/core/screen.ts: tagended: 0x07.ui/src/data/stream.ts: on0x07,onEndwith the reason.src/stream/AGENTS.md,src/http/AGENTS.md, website pages in F17.
flowchart LR
http[http app CHANGED] --> host[modules host CHANGED]
host --> stream[stream module CHANGED]
stream --> screen[core/screen CHANGED]
ui[console stream reader CHANGED] --> screen
stream -->|gateway relay| relay[gateway relay]
sequenceDiagram
participant C as Agent
participant G as Gateway http
participant SG as stream (gateway)
participant W as Worker stream
C->>G: GET /v1/leases/L-42/stream
G->>G: lease.get L-42 for the caller
alt not the caller's, or unknown
G-->>C: 404 UNKNOWN_LEASE
else
G->>SG: leaseRoute(request, identity, lease)
SG->>W: relay /stream/leases/w1-7 (leaseId w1-7)
alt lease gone on the worker
W-->>C: 404 UNKNOWN_LEASE
else worker unreachable
SG-->>C: 503 WORKER_UNREACHABLE
else
W-->>C: frames … 0x07 {"reason"} , end
end
end
Contract and event changes
None: no src/contract/ shape changes (searched src/contract for stream, lease.get). The HTTP surface gains GET /v1/leases/{id}/stream, declared in src/stream/definition.ts so the OpenAPI document lists it (F18), with 200 (the stream's content types and headers, as the device route), 404 UNKNOWN_LEASE, 409 DEVICE_NOT_RUNNING, 415 STREAM_NOT_SUPPORTED, 500 INTERNAL, 502 and 503 as the device route. The ended frame payload is JSON {"reason": string}, one of the four values in F8, at most 64 bytes; a reader that cannot parse it ends with no reason. No event changes.
Other code on the same state
Searches: stopOnDevice (3 hits: captures.ts, create.ts, captures.ts lease end), beginOnDevice (2 hits: captures.ts, worker-link.ts), registry.snapshot.leases in src/stream (0 hits).
- Device-stop events (F9) and lease end (F7) both call
stopOnDevice. Lease end writes0x07with its reason, then ends.device.absence-detectedwithpreviousState: "leased"also writes0x07 {"reason":"device-lost"}: on that path the lease end is already committed and its participants run after the event (F20). Every other device stop ends with no frame. A slot already stopped writes nothing more, so the lease end that follows an absence adds no second frame. - The device route and the lease route share the capture table by device: a lease end stops both, as today.
- A lease route open races its own lease end: the handler registers its slot (
beginOnDevice) first, then checks the lease is still inregistry.snapshot.leases(F16), then opens. A lease that ended before the check answers 404UNKNOWN_LEASE; one that ends after is stopped bystopOnDevicewith the0x07frame. - Input socket for a lease #1017 adds a lease socket on the same lease route path resolution (Shared values: Lease socket on Let a lease holder watch and interact with its device's screen #1013).
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
http → lease.get |
ok | the lease goes to the module's lease handler | as GET /v1/leases/{id} today |
http → lease.get |
UNKNOWN_LEASE |
404 UNKNOWN_LEASE, no module called |
— |
| gateway stream → relay to the worker | worker unreachable | 503 WORKER_UNREACHABLE (F5, src/stream/gateway-link.ts:56-75) |
the relay's deadline, as the device route |
| gateway stream → relay | worker has no stream module | 503 STREAM_UNAVAILABLE naming the worker, as the device route |
— |
| worker lease target | lease not in the registry | 404 UNKNOWN_LEASE |
— |
| worker lease target | capture opens | frames, then 0x07 on lease end |
the 30 s first-frame and silence bounds of today |
| worker lease target | the lease ends before the first frame | 409 DEVICE_NOT_RUNNING, as today (F23); no body exists to carry 0x07 |
the 30 s first-frame bound |
| gateway stream → relay | the worker answers 404 UNKNOWN_LEASE for its own lease id |
404 UNKNOWN_LEASE with Unknown lease: <gateway lease id>: the gateway puts back the id the caller asked for, instead of passing the worker's message (F24) |
— |
| gateway stream → relay | a worker of an older Simlock answers the lease path 404 UNKNOWN_DEVICE (F25) |
passed on as is; no test: a fleet runs one version, and this ends with the update | — |
State written: none persisted. A daemon restart ends every stream as today.
Rules in play
- Architecture rule 10, "A rule is enforced in exactly one place": the ownership check lives only in the http lease route (
lease.get); the stream module never checks owners. - Architecture rule 15, "Dependencies point from the optional to the required": http knows only module names and a lease handler, never
stream. - Architecture rule 21, "A host never names an implementation": the lease route matches a loaded module's name, with no
streamliteral insrc/http. - Documentation rule 9, "A change to the public surface updates the website in the same change": the pages in F17 and the OpenAPI declaration (F18).
- ADR 0045 §4 (lifetime) stays; ADR 0049 §1, §2 and §6 are what this task builds.
Tests
Seam: src/daemon/stream-gateway.e2e.test.ts (a real gateway and worker over HTTP, the screen faked), extended with a standalone worker daemon; the console row on ui/src/data/stream.test.ts. The "lease gone on the worker" case cannot be held open between two real daemons (the gateway's own lease.get answers first once its view refreshes), so it runs in src/stream/gateway-link.test.ts with a relay that answers as that worker; its 404 message names the gateway lease id. The device-lost cases drive both F20 (a phone marked absent) and F21 (the health monitor).
- A caller GETs
/v1/leases/{id}/streamon a standalone worker and gets the picture or a refusal: own lease 200; another's 404UNKNOWN_LEASE; unknown 404; operator 200; stream capability off 404. - A caller GETs
/v1/leases/{id}/streamon a gateway and gets the lease's worker's picture or a refusal: own lease 200; another's 404UNKNOWN_LEASE; worker unreachable 503WORKER_UNREACHABLE; worker without the stream module 503STREAM_UNAVAILABLE; lease gone on the worker 404UNKNOWN_LEASE; and, at the worker's own stream lease handler, a/stream/leases/<id>request for a lease missing from the worker's registry answers 404UNKNOWN_LEASE(decided in delivery, pending the maintainer). - A viewer of either stream route sees a 0x07 frame with the reason, then the end, when the lease ends:
explicit,expired,killed;device-lostby the health monitor and by physical absence; lease route and device route; a device that was not leased going absent writes no 0x07 frame (decided in delivery, pending the maintainer). The 0x07 cases may run at the stream module's surface (src/stream/create.test.ts) where the device-lost cases need a real health monitor or enrolled phone (decided in delivery, pending the maintainer). - A module whose name is a core lease sub-route (
renew,exec) is refused at load, andPOST /v1/leases/{id}/renewstill renews (decided in delivery, pending the maintainer). - A console viewer sees why the picture stopped when a 0x07 frame arrives: 0x07 with a reason (each of the four shows its Shared values text, and the stream's error stays empty); an unknown tag is still skipped.
The tag pin in ui/src/data/stream.test.ts:625 (F13) covers the new tag in both tables.
Done when
pnpm testpasses with the five tests above.- The OpenAPI document lists
GET /v1/leases/{id}/streamwith its statuses. watching-the-screen.mdxandaccess-control.mdxsay a lease holder can watch its own lease, and how a stream ends.src/stream/AGENTS.mdandsrc/http/AGENTS.mddescribe the lease route.
Out of scope
- Input (Input socket for a lease #1017, Android input through scrcpy's control channel #1018, iOS simulator input through the HID injector #1019).
- Sharing one capture between viewers (One capture per lease, shared by every viewer of its screen #602).
- A
0x07frame for ends other than a lease end (device shutdown, quarantine, delete, capture error). - A worker of an older Simlock behind a newer gateway (Failure modes).
Depends on
Written by an agent.
Activity
test: a lease stream on a standalone worker gives its holder the pict…
feat(stream): a standalone worker serves a lease's stream (#1015)
test: a gateway lease stream refuses another's lease, an unreachable …
test: a lease the worker no longer holds names the gateway's lease id (…
feat(stream): a gateway names its own lease id when the worker lost t…
test: a viewer of either stream route sees a 0x07 frame with the reas…
feat(stream): a lease end writes a 0x07 frame with its reason before …
test: a console viewer sees why the picture stopped when a 0x07 frame…
feat(ui): the console says why a lease stream ended (#1015)
docs(stream): the lease stream route is declared and described (#1015)
refactor(stream): the gateway link's relay path and lease id helpers (#…
test: a module whose name is a core lease sub-route is refused at load (
feat(modules): the host refuses a module named like a core lease sub-…
docs(stream): the relay door and the gateway link say which route ref…
test(stream): the worker answers UNKNOWN_LEASE for a lease it lacks, …
docs(http): bring the manual to 150 lines (#1015)
feat(stream): a lease holder watches its own lease's screen (#1025)
feat(input): iOS simulator input through the HID injector (#1037)