Files
wordcraft/docs/control-protocol.md
T
bded38dd23 Control port: require a per-window key (split from #54) (#124)
* MCP bridge: send the control key, to loopback addresses only

`wordcraft-cli mcp --connect` now sends the app's control key with every
request. It takes the key from WORDCRAFT_CONTROL_KEY, or else from the
key file the app writes for that port, and reads it again on every new
connection, also after an `unauthorized` reply (the app restarted with
a new key).

The key goes only to loopback addresses: 127.0.0.0/8, ::1 and
`localhost`, and only when the connection really leads to a loopback
address. For any other host the bridge sends no key, prints why at
start, and turns the app's `unauthorized` into an error that says the
key was not sent and how to reach the app (through 127.0.0.1, for
example an SSH tunnel). Other host names are never resolved for this.

The new `wordcraft_mcp::control_key` module holds the rule the app and
the bridge share: the settings folder (the one with ui.json), the
Flatpak instance id and `<settings>/control-key.<instance>`
(`local-<port>` outside Flatpak). No new dependency; wasm-safe.

* Control port: every request needs the window's key

Any local program, and possibly a web page, could drive the app through
its loopback control port. Each window started with --control now has
a key:

- once the port is bound, the app writes a random key (32 bytes from
  the OS, as hex) to <settings>/control-key.<instance>, mode 0600 on
  Unix, replacing a stale file; it removes the file on a clean exit. A
  failed bind writes nothing. <instance> is the Flatpak instance id, or
  local-<port> outside Flatpak (wordcraft_mcp::control_key, the rule
  the MCP bridge reads it with). The settings folder is the one that
  holds ui.json; prefs_path now uses the same helper.
- every request needs the key: top-level "key", or
  {"method":"auth","key":...} once per connection. Only auth reads
  params.key; elsewhere it stays an ordinary parameter (ui.key). The key
  is checked on every request, in time that does not depend on where a
  wrong key differs. A missing or wrong key gets
  {"ok":false,"error":"unauthorized"} and the connection is closed.
- a line over 1 MiB gets "line too long" and the connection is closed
  (the rest of the line is drained first, so the client reads the
  error).
- a connection that sends no authorised request within 30 s of being
  accepted is closed; blank lines and bad JSON do not extend that
  limit. After that it may stay idle.

* Docs: control-port keys

docs/control-protocol.md gets a Keys section: where the key file is
(per platform and per instance), top-level "key" or auth once per
connection, `unauthorized` and the closed connection, the 1 MiB line
limit, the 30 s limit for the first authorised request, and how the MCP
bridge finds the key and sends it only to loopback addresses (an SSH
tunnel for an app on another machine). docs/mcp.md points there, and
the agent guides (AGENTS.md, CLAUDE.md) show the key in their control
channel example.

* Control key: the key-file helpers in their own small crate

wordcraft-control-key (standard library only, Standalone in the layering
table) holds where the key file lives and how to read it. The app and the
MCP bridge both use it, so the app no longer depends on wordcraft-mcp.

* Cargo.lock: wordcraft-control-key follows the workspace version (0.4.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: lpgn <2556951+lpgn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:56:41 -04:00

4.2 KiB

Control protocol

Start the app with a control port: wordcraft --control 7981 (or WORDCRAFT_CONTROL_PORT=7981). It listens on 127.0.0.1 only. Send one JSON object per line, with the window's key (see Keys); each gets one reply line:

{"id": 1, "key": "…", "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}}}
{"id": 1, "ok": true, "result": {"anchor": {...}, "focus": {...}}}

Errors come back as {"id": …, "ok": false, "error": "message"}. A failed command leaves the document unchanged. A line that isn't valid JSON gets one error reply, then the server closes the connection (so an HTTP request sent to the port can't run a command); reconnect to continue.

Keys

Any local program (and possibly a web page) can reach a loopback port, so every request needs the window's key. Once the port is bound, the app writes a random key (256 bits, as hex) to <settings>/control-key.<instance> and removes the file when the window closes; a new start writes a new key. <settings> is the folder that holds the preferences: on Linux $XDG_CONFIG_HOME/wordcraft (by default ~/.config/wordcraft), on macOS ~/Library/Application Support/WordCraft, on Windows %APPDATA%\WordCraft. <instance> is the Flatpak instance id inside Flatpak, and local-<port> otherwise. On Unix the file has mode 0600.

cat ~/.config/wordcraft/control-key.local-7981   # Linux, outside Flatpak, port 7981
  • Send the key as top-level "key" with every request, or once per connection with {"method": "auth", "key": "…"} (reply {"ok": true, "result": {}}); later requests on that connection may leave key out. Only auth reads params.key; for other methods params.key is an ordinary parameter (ui.key {"key": "Enter"} presses Enter).
  • The key is checked on every request. A missing or wrong key gets {"ok": false, "error": "unauthorized"}, and the app closes the connection.
  • A line over 1 MiB gets {"ok": false, "error": "line too long"}, and the app closes the connection.
  • A connection that sends no authorised request within 30 s of being accepted is closed. That is one limit: blank lines and bad JSON do not extend it. After an authorised request the connection may stay idle.

wordcraft-cli mcp --connect takes the key from WORDCRAFT_CONTROL_KEY, or else from the key file for the port it connects to, and reads it again on every new connection (the app may have restarted). It sends the key only to loopback addresses (127.0.0.0/8, ::1, localhost). For any other host it sends no key, says so, and the app refuses its requests: to drive an app on another machine, forward its port to 127.0.0.1 (for example ssh -L 7981:127.0.0.1:7981 host) and set WORDCRAFT_CONTROL_KEY to that app's key.

Methods

Method Params Result
engine.execute command, params the command's result (any id from engine.commands, including UI commands ui.tab, ui.dialog, ui.backstage, ui.language (`{"value": "auto"
engine.commands — every command: id, label, location, shortcut, params, enabled
document.inspect text? blocks (text, style, runs, lists, tables), parts, sections, selection, pages
ui.inspect — UI state, view, dialog, window size, page rects on screen, caret, perf
ui.click x, y, button?, count?, shift?, cmd?, alt? real pointer input at window coordinates
ui.clickText page (0-based), x, y (points from the page's top-left), count? click inside a page
ui.move / ui.drag x,y / x,y,toX,toY,steps? pointer move / drag
ui.key key (B, Enter, Left…), shift?, alt?, cmd? key press through egui (shortcuts apply)
ui.text text typed text through egui
ui.screenshot path? PNG of the window
ui.render path, page? (1-based), scale? PNG of a page, rendered by the engine
ui.parity — feature-catalog parity
ui.resize / ui.focus / app.quit window control

Any other method name is treated as a command id: {"method": "format.bold"} works.

Positions are {"story": "body" | {"part": n}, "path": [block, row, cell, block, …], "off": byteOffset}.