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; the console says why.
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 |
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).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 writes0x07then ends; a device stop ends with no frame. If both come, the first to reach a slot decides; a slot already stopped writes nothing more. - 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 |
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.
- 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. - A viewer of either stream route sees a 0x07 frame with the reason, then the end, when the lease ends:
explicit,expired,killed,device-lost; lease route and device route. - A console viewer sees why the picture stopped when a 0x07 frame arrives: 0x07 with a reason; 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 four 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 stop, capture error).
Depends on
Written by an agent.
Activity