GridMsg::ClipboardSet is read flat, but felis wraps it in a write field — every OSC 52 arrives empty #5

Closed
opened 2026-09-07 21:11:30 +09:00 by natsukium · 0 comments
Owner

Found by cross-checking the felis-broker.v1 JSON contract between
felis-web-gateway follow-felis-105b0899 (577138be) and this repo's
follow-felis-105b0899 (edfb8dfa). Both pin felis
105b089979369fd310757375af3df7900ae7e523.

The mismatch

felis's wire reset moved GridMsg::ClipboardSet's two fields into a
ClipboardWrite struct behind a write field.
crates/felis-protocol/src/messages/grid.rs:

    /// `OSC 52` clipboard write, carried to the initiating client only …
    ClipboardSet { write: ClipboardWrite },

pub struct ClipboardWrite {
    pub selection: ClipboardSelection,
    /// Decoded bytes (post-base64); the spec does not constrain charset.
    pub data: Vec<u8>,
}

GridMsg is externally tagged with no serde attributes and
ClipboardSelection keeps its hand-written Serialize that emits the raw
u8 bits, so the JSON is:

{"ClipboardSet": {"write": {"selection": 1, "data": [104, 105]}}}

The gateway relays that verbatim — src/main.rs's decode_frame calls
felis_grid::transcode::body_to_json, which is serde_json::to_value on the
decoded GridMsg for every family but the RowDelta row recode.

This component still reads the pre-reset flat body —
web/felis-terminal.ts:2034, in routeGrid:

      case "ClipboardSet": {
        const b = bodyOf(msg, name);
        const selection = Number(b["selection"] ?? 0);
        const write: ClipboardWrite = {
          text: new TextDecoder().decode(new Uint8Array((b["data"] ?? []) as number[])),
          clipboard: (selection & SELECTION_CLIPBOARD) !== 0,
          primary: (selection & SELECTION_PRIMARY) !== 0,
        };

bodyOf(msg, "ClipboardSet") now returns {write: {...}}, so
b["selection"] and b["data"] are both undefined.

Why it breaks a live session

Every OSC 52 write on a live session reaches the page as an empty one:
selection falls back to 0, so clipboard and primary are both false,
and data falls back to [], so text is "".

  • Terminal.onClipboard subscribers fire with {text: "", clipboard: false, primary: false} — a lie rather than a no-op. web/mobile.html:287 is a
    shipped consumer.
  • The clipboardMode === "write" path is gated on write.clipboard, which is
    now permanently false, so navigator.clipboard.writeText is never called.
    An opted-in embed silently stops receiving OSC 52 writes.

Nothing surfaces: routeGrid has no failure path, and the shadow screen's own
typed apply handles the frame correctly, so the grid keeps painting.

Fix

Unwrap write before reading the fields, e.g.

        const b = (bodyOf(msg, name)["write"] ?? {}) as Record<string, unknown>;

Nothing changes on the gateway: it forwards the felis-typed value and
docs/broker.md names the current shape.

Not covered by a test

routeGrid's reply/event arms are TypeScript with no fixture behind them;
tests/apply.rs exercises ShadowScreen::apply, which decodes
ClipboardSet through the typed enum and therefore cannot see this.

Found by cross-checking the `felis-broker.v1` JSON contract between felis-web-gateway `follow-felis-105b0899` (577138be) and this repo's `follow-felis-105b0899` (edfb8dfa). Both pin felis `105b089979369fd310757375af3df7900ae7e523`. ## The mismatch felis's wire reset moved `GridMsg::ClipboardSet`'s two fields into a `ClipboardWrite` struct behind a `write` field. `crates/felis-protocol/src/messages/grid.rs`: ```rust /// `OSC 52` clipboard write, carried to the initiating client only … ClipboardSet { write: ClipboardWrite }, … pub struct ClipboardWrite { pub selection: ClipboardSelection, /// Decoded bytes (post-base64); the spec does not constrain charset. pub data: Vec<u8>, } ``` `GridMsg` is externally tagged with no serde attributes and `ClipboardSelection` keeps its hand-written `Serialize` that emits the raw `u8` bits, so the JSON is: ```json {"ClipboardSet": {"write": {"selection": 1, "data": [104, 105]}}} ``` The gateway relays that verbatim — `src/main.rs`'s `decode_frame` calls `felis_grid::transcode::body_to_json`, which is `serde_json::to_value` on the decoded `GridMsg` for every family but the `RowDelta` row recode. This component still reads the pre-reset flat body — `web/felis-terminal.ts:2034`, in `routeGrid`: ```ts case "ClipboardSet": { const b = bodyOf(msg, name); const selection = Number(b["selection"] ?? 0); const write: ClipboardWrite = { text: new TextDecoder().decode(new Uint8Array((b["data"] ?? []) as number[])), clipboard: (selection & SELECTION_CLIPBOARD) !== 0, primary: (selection & SELECTION_PRIMARY) !== 0, }; ``` `bodyOf(msg, "ClipboardSet")` now returns `{write: {...}}`, so `b["selection"]` and `b["data"]` are both `undefined`. ## Why it breaks a live session Every OSC 52 write on a live session reaches the page as an empty one: `selection` falls back to `0`, so `clipboard` and `primary` are both `false`, and `data` falls back to `[]`, so `text` is `""`. - `Terminal.onClipboard` subscribers fire with `{text: "", clipboard: false, primary: false}` — a lie rather than a no-op. `web/mobile.html:287` is a shipped consumer. - The `clipboardMode === "write"` path is gated on `write.clipboard`, which is now permanently false, so `navigator.clipboard.writeText` is never called. An opted-in embed silently stops receiving OSC 52 writes. Nothing surfaces: `routeGrid` has no failure path, and the shadow screen's own typed `apply` handles the frame correctly, so the grid keeps painting. ## Fix Unwrap `write` before reading the fields, e.g. ```ts const b = (bodyOf(msg, name)["write"] ?? {}) as Record<string, unknown>; ``` Nothing changes on the gateway: it forwards the felis-typed value and `docs/broker.md` names the current shape. ## Not covered by a test `routeGrid`'s reply/event arms are TypeScript with no fixture behind them; `tests/apply.rs` exercises `ShadowScreen::apply`, which decodes `ClipboardSet` through the typed enum and therefore cannot see this.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
natsukium/felis-web-component#5
No description provided.