Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/relay-inbound-validated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@tapflowio/protocol': minor
'@tapflowio/relay': minor
---

Check every message the relay receives against the contract, and make the inbound frame a discriminated union

The outbound direction has been compile-checked since #419 — `sendTo` refuses a message outside its
union. Nothing checked the inbound direction: the relay's `RelayMessage` was a flat interface where
`type` was the only required member, so every field it read was optional by construction and every
field it needed came with a `!`. That is how the two type systems could disagree about the same wire
field — `format?` in the relay against a required `format` in the protocol — with nothing to report it.

`@tapflowio/protocol/validate` is a second entry point, imported only by the relay, that parses an
inbound frame into a discriminated union at the door. It is a parse rather than a cast on purpose:
narrowing the union with `as` would have turned the relay's one visible `msg.payload as ChromePayload`
into an invisible `msg.payload`, with the compiler vouching for JSON that arrived over a socket.

What a user can observe:

- **A malformed command is refused before it reaches a device, and the caller is told which field was
wrong.** A `device:boot` with no payload, an `open-url` with no URL, an `app:install` whose `buildId`
is an object — these were forwarded to an agent before, and the agent's own guard answered if it had
one. The relay answers now, in the shape that request's waiter reads, so the diagnosis arrives sooner
and does not depend on which agent is on the other end. A request that has no reply at all is dropped
and logged with the field that failed. No client shipped here can produce any of these; a third-party
one can.
- **A command with no usable session id or request id is refused outright**, including the empty
string, which type-checks and which an LLM driving the MCP tools could produce. Answering one is not
possible — the reply's own required fields would be missing, and every client discards such a frame —
so it is dropped with a log rather than turned into a caller waiting out its deadline.
- **A key appended to a browser message no longer reaches a device.** Browser-origin frames are
forwarded as the parse product, so anything the contract does not declare is gone before an agent
sees it. Agent-origin frames are forwarded unchanged, so a field a newer agent adds still survives a
relay that does not know it.
- **Nothing else changes.** Every well-formed frame routes exactly as before.

`@tapflowio/protocol` gains a `./validate` subpath and, with it, a runtime dependency on `zod` — its
first dependency of any kind. The main entry is unchanged: still types only, still fully erased by
`import type`, and it does not reach `zod`. A consumer that imports only `@tapflowio/protocol` gains
nothing in its bundle and one package in its install.

Agent payloads are deliberately not validated, and that is a decision with a reason rather than a gap:
`AgentRegister.platform` is `string` — open, so a third-party platform can register through
`AgentRegistry.register()` — while `ChromePayload` is a closed two-member union. A platform this
project promises to support has no valid `session:chrome` variant to send, and refusing one would cost
it bezel and buttons for the life of the session. The six messages the relay consumes are validated,
each with a default for every field the relay previously read through a `??`, so an agent older than a
field keeps working exactly as it did.
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
produced it — the set is deliberately smaller than any one agent's internals. (`not-session-owner`
is the eighth member and is the relay's alone: it refuses such a frame at its door, before any agent
sees it.) Prose stays welcome in `message` and may now be omitted.
- **`@tapflowio/relay` no longer exports `RelayMessage` or `MessageType`.** They were the relay's own
copy of the wire contract — a flat interface where `type` was the only required member, and a
hand-maintained list of 63 literals beside it — and they disagreed with `@tapflowio/protocol` about
the same fields, which is the drift this release closes. Nothing in tapflow imported them; this
affects code outside it that did.
`Migrate:` import the message types from `@tapflowio/protocol` instead, which declares one interface
per message and unions them by direction — `BrowserToRelay`, `AgentToRelay`, `BrowserInbound` and so
on. A `RelayMessage` used as "any frame on this socket" becomes the union for that socket's
direction, and narrowing on `type` gives the individual message.

### Changed

- **The relay now checks every message it receives against the contract, and refuses the ones that
break it.** Every frame is checked for its type, its address and its correlator; a command sent by a
browser is checked in full, down to its payload. Until now it checked only what it *sent*. A command with a missing payload, an empty
session id, or a build id that was not a number was forwarded to a device anyway — or answered with a
reply whose own required field was missing, which every client discards, turning a diagnosis into a
caller waiting out its deadline. A refused command is now answered where the request has a reply, so
the caller is told which field was wrong instead of waiting; where it has none, the frame is dropped
and the log names the field. Well-formed messages are unaffected.
- **A field appended to a browser message no longer reaches a device.** Anything the contract does not
declare is removed before the relay forwards it on. Messages coming *from* an agent are forwarded
untouched, so an agent newer than its relay does not lose fields it adds.
- **`@tapflowio/protocol` has a second entry point, `@tapflowio/protocol/validate`.** It holds the
relay's inbound parser, and it brings the package its first runtime dependency (`zod`). The main
entry is unchanged — types only, fully erased by `import type`, and it does not reach `zod` — so a
consumer that imports only `@tapflowio/protocol` gains nothing in its bundle.
- Split stable dashboard vendor dependencies into smaller chunks to reduce maximum bundle size and improve cache reuse across releases.
- **A refused session now says which session it refused and why.** Opening a device someone else already
has open, or one whose Mac is under load, used to produce a generic failure the dashboard could not
Expand Down
3 changes: 2 additions & 1 deletion packages/ios-agent/src/IOSAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,8 @@ export class IOSAgent implements DeviceAgent {
* hands back a `string` the case can close over.
*
* It is unvalidated JSON, so the check is real work rather than ceremony: the declaration is required and
* every in-repo sender is typed against it, but nothing validates inbound (#444), and `mcp-server`'s tool
* every in-repo sender is typed against it, and #444 made the relay refuse an empty one — but this agent may
* be talking to a relay older than that, and `mcp-server`'s tool
* schemas are bare `z.string()` so a model can produce `''`. */
private correlatorOf(msg: { type: string; requestId?: string }): string | null {
if (typeof msg.requestId === 'string' && msg.requestId !== '') return msg.requestId
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Connects to the relay over WebSocket + REST (`TapflowClient`), registers MCP too
## HOW

- Entry: `src/index.ts` — reads `TAPFLOW_RELAY_URL` and `TAPFLOW_TOKEN` env vars, connects `TapflowClient`, calls `registerTools`, starts `StdioServerTransport`.
- Client: `src/client.ts` — WebSocket connection to relay + REST calls for build/app data. Its `send()` takes `BrowserToRelay` from [`@tapflowio/protocol`](../protocol/AGENTS.md), so a new outbound message goes in that union first. Receiving stays loose (`Record<string, unknown>` + a predicate) — a **deferral, not a settled decision**, tracked in #512. Narrowing it would catch a live defect (`error` is matched on a `sessionId` that message does not have, so one session's failure can be reported against another's join) but it also makes `message: string`, turning this file's `?? 'failed'` fallbacks into unreachable code. Deleting those while nothing validates inbound JSON removes a real defence, so the validators (#444) come first.
- Client: `src/client.ts` — WebSocket connection to relay + REST calls for build/app data. Its `send()` takes `BrowserToRelay` from [`@tapflowio/protocol`](../protocol/AGENTS.md), so a new outbound message goes in that union first. Receiving stays loose (`Record<string, unknown>` + a predicate) — a **deferral, not a settled decision**, tracked in #512. Narrowing it would catch a live defect (`error` is matched on a `sessionId` that message does not have, so one session's failure can be reported against another's join) but it also makes `message: string`, turning this file's `?? 'failed'` fallbacks into unreachable code. Deleting those used to remove a real defence, because nothing validated inbound JSON; #444 landed that validation at the relay, so the prerequisite this deferral named is met and #512 is now a judgement about this file alone.
- Tools: `src/tools.ts` — all MCP tool definitions. One `registerTools(server, client)` call registers everything.
- Screenshots are saved to a temp file and returned as MCP `image` content with base64 encoding.
- **The screenshot's format is read from its magic bytes, not from the request or the reply** (#508).
Expand Down
Loading
Loading