feat(plugins-webmcp): a WebMCP tool surface for OpenLeaf editors - #252
Open
PeytonNowlin wants to merge 35 commits into
Open
feat(plugins-webmcp): a WebMCP tool surface for OpenLeaf editors#252PeytonNowlin wants to merge 35 commits into
PeytonNowlin wants to merge 35 commits into
Conversation
Placeholder so the tracking PR for #241 exists while the ticket work lands on it. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
An agent arriving on a page that has OpenLeaf editors can now ask which editors are there and get back a stable identifier for each one. When an editor is removed from the page its identifier stops being offered; an editor mounted after the bundle loaded starts being offered. This is the tracer bullet for the whole feature: the thinnest complete path through every layer, and the shape the remaining tools are built on. Registration is page-global and made once, which is forced rather than chosen. A probe against Chrome for Testing 151 with `--enable-blink-features=WebMCP` found that a second `registerTool` under a name already taken rejects with `InvalidStateError: Duplicate tool name`, so a tool set per editor would fail on the second editor -- and a page with several editors is the normal case here. Each editor instead adds itself to a page-global register through the editor plugin's own per-view lifecycle. That register is keyed on the host element rather than held in the plugin view's closure, because `state.reconfigure` destroys and recreates every plugin view: an identifier in the closure would be reassigned every time any other plugin registers, and the id an agent was handed one call ago would name nothing. The same probe corrected two things #241 records. The object is `document.modelContext`, falling back to the deprecated `navigator.modelContext` -- not `agent`. And the abort signal works only as `{ signal }` in `registerTool`'s second argument; inside the descriptor it is silently ignored, which would have left a registration with no teardown at all, since the API exposes no `unregisterTool`, no bulk replace and no clear. The tool set is a plain value -- names, titles, descriptions, input schemas, annotations and executable handlers -- and installing is a thin wrapper that hands it to the browser. That is the seam: the flag-free suite calls the handlers directly in all three engines, so it does not break the next time a young API is renamed, and one Chromium-only spec in a new `chromium-webmcp` Playwright project proves the real registration path against the browser's own listing and execute call. The package contributes no nodes, no marks, no toolbar items, no icons and no CSS, so a deployment that does not install it is unchanged. In a browser without the API, installing is silent: no error, no console output, and no half-wired editor. Documentation: new package README, the optional-plugin tables in README.md and docs/integrating-openleaf.md, the plugin and budget tables in docs/authoring-plugins.md, a plugin-trust section in SECURITY.md covering the new caller and the untrusted-content annotation, the published-package counts in AGENTS.md, docs/releasing.md and docs/agents/domain.md, and a CHANGELOG entry. demo/index.html is deliberately untouched: #241 lists the demo page as out of scope. Refs #241 Closes #242 Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…ntent An agent that has found an editor can now ask what that editor is able to do, and read what is currently in it. `openleaf_get_capabilities` answers two questions separately, because in this project they have different answers. What a document can STORE is the schema, and the base schema is deliberately wide -- table and structural nodes are in it whether or not the editing chrome for them was ever installed, so that a stored document round-trips in every deployment. What a deployment can DO is the toolbar item registry, narrowed again by the `toolbar` and `toolbar2` layout the integrator gave that one editor, since `registerToolbarItem` is page-global and restricting a single editor is a layout decision rather than an uninstall. So an editor can hold a table in a deployment that has no command to build one, and can hold a heading on a bar with no way to apply one. Reporting only the schema would promise an agent edits that cannot happen; reporting only the commands would tell it a stored table is unreadable. `openleaf_get_document` returns the editor's current content as HTML -- `host.value`, so source view and unsaved edits are included -- and is the first tool in the package annotated `untrustedContentHint`, because a document is where text aimed at the agent reading it hides. Both take the editor id the listing hands out. `findEditor` resolves it, and a miss is never resolved to "the first editor": an agent holding a stale identifier gets a failure telling it to list again, rather than somebody else's document. Arguments that do not match the published schema fail the same way instead of throwing out to the browser, which would reach the agent as a rejected call with no shape to it. `@openleaf-editor/ui` becomes a peer dependency for `allToolbarItems()`. It is already on the shared runtime, so the script-tag bundle grows 0.9 KB gzipped rather than carrying a second copy. The harness page's `post-body` editor gains `blockType` in its toolbar: the capabilities test needs an editor that CAN apply a heading beside the restricted one that cannot, or a tool reporting no commands at all would pass it. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…dits An agent can now search a named editor for a literal string and get back a handle for each match, then use that handle later in the same task and land on the same text -- even though the document has moved under it in between. Addressing has to be its own mechanism here. A selection does not survive the round trip out to an agent and back: the author clicks somewhere, the editor re-renders, the agent takes a second to think. So each editor keeps a table of handles in its own plugin state and carries every one of them through each transaction's position mapping, which is what makes an edit in one part of the document leave a handle in another part alone. The mapping detail is the whole ticket. `tr.mapping.map(pos)` always answers with a position, so deleting the text a handle names slides it quietly onto the neighbouring text and a later write lands there -- a stale handle becoming a write into something nobody chose is the dangerous failure this mechanism exists to prevent. `mapResult(pos, assoc)` answers with a position AND whether the token on that side was deleted, so a handle whose content is gone can refuse instead. It refuses permanently, and it refuses even if the same characters are typed back: matching characters are a coincidence, not the thing the agent read. The ends are biased outward, so text typed against either edge lands outside the handle rather than being adopted by it. The table is plugin state rather than a plugin-view closure, for the reason the register already is: `state.reconfigure` destroys and recreates every plugin view when any other plugin registers, and every outstanding handle would go with it. Handles are opaque random tokens -- anything an agent can read out of a handle is something it will eventually compute with -- and an editor removed from the page stops resolving them without the table having to be told. Each editor keeps its most recent 256, because nothing releases a handle and the table is walked on every transaction. The search itself matches a literal string one block at a time, so a query spanning a mark boundary is one match and a query spanning a paragraph break is none; it caps at 50 matches and says `truncated` when there were more, because an agent that believes it has seen every occurrence will replace them all. Text that does not occur is an empty result, not an error. The tool is annotated read-only and as returning untrusted content: it hands back the text around each match, and a document is exactly where text aimed at the agent reading it hides. `resolveHandle` is the seam #245, #246 and #248 consume: one function, and its failure is already shaped like `fail()`'s arguments so a caller cannot paper over a stale handle with a message of its own. Handle mapping is unit-tested against a real ProseMirror view in jsdom, because it is document-model arithmetic and because nothing in the tool set consumes a handle yet -- the browser suite has no way to resolve one until the write path lands. Everything a browser can answer about the search is in `webmcp.spec.ts`, in all three engines, including finding text the author has just typed. `prosemirror-model` joins the peer and dev dependencies at the range every other package in the repo already uses. It is a type-only import, so nothing reaches the bundle: the WebMCP bundle is 2.8 KB gzipped against its 8 KB budget. Documentation: the tool table, result codes and a new Handles section in the package README, the untrusted-content paragraph in SECURITY.md, the package row in README.md, the measured budget figure in docs/authoring-plugins.md, and a CHANGELOG entry. Refs #241 Closes #244 Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`openleaf_list_editors` tells an agent to pass an "id" back to any other openleaf_* tool. #244's search tool called the same thing "editor", which made that instruction wrong on a quarter of the surface and duplicated the two failure branches `withEditor` already owns. `editorArgumentWith` lets a tool declare its own arguments on top of the shared one, so the next tools cannot drift the same way. Refs #241 Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`openleaf_get_structure` names each block of one editor in document order -- its node type, a heading's level, and the start of its text -- and nothing else. Reading a fifty-section article through `openleaf_get_document` to retitle one of its sections spends an agent's context before it can act, so this answers with a map of the document instead of the document. Every entry carries a handle, minted through the same `createHandles` a search uses and in the same single step-free transaction, so an outline is something an agent can act on rather than only read: a handle taken from one still names its block after an edit elsewhere, and refuses once the block is deleted. The range is the block's whole node range, boundary tokens included, so the handle names the block itself rather than what is inside it. Nested blocks are not listed separately -- a list or a table is one entry, and searching is how an agent addresses something inside one -- because a recursive walk would be the document again with different punctuation. An empty paragraph is not structure, which is also what makes an empty document answer with an empty outline rather than with the paragraph the schema requires it to have. A rule or a preserved region carries no text and is still listed: an agent inserting after it has to know it is there. Capped at 200 blocks with `truncated`, and the cap is the handle table's rather than a matter of taste: an editor keeps its most recent 256 handles, so a longer outline would go stale at the top while it was still being read. Annotated read-only and `untrustedContentHint` -- an outline is shorter than the document but it is built from the document's own headings. Docs: the package README gains an "Outlines" section and a tool-table row, SECURITY.md names the third tool that returns document content, CHANGELOG.md gains an entry, and the one-line descriptions in README.md, docs/integrating-openleaf.md and docs/authoring-plugins.md s.1 now mention the outline. openleaf-webmcp.min.js measures 4.1 KB gzipped against its unchanged budget of 8, and s.4.5's table carries the new number. Refs #245 Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…ange `openleaf_apply_command` runs one of the commands the deployment registered against the text a handle names, rather than writing markup. A command already knows what it is allowed to do -- it declines on a figure, it stops at an isolating boundary, it knows which marks its schema permits -- so an agent inherits every guard a keyboard shortcut has, including ones a plugin added. There is no list of command names anywhere in the package. Only what that editor offers can be applied. `offeredCommands` is lifted out of the capabilities handler so both tools read the same intersection of the toolbar-item registry and this editor's `toolbar` layout; a command reported as available and then refused as unknown would be a contradiction an agent has no way to resolve. An id nothing registered, or one this editor's bar does not carry, is `unknown-command`. A control that only works through the editor's own interface -- `blockType`, `link`, `image`, `source`, which are `render`- or `run`-driven with no plain command underneath -- is `unsupported-command`, and saying so is the point: an agent told the heading applied moves on believing it exists. A command that declines at that position reports `refused` and changes nothing. So does an editor that is readonly or has its HTML source view open, matching what the editor's own toolbar does in both cases -- a change made behind the source view is applied to the hidden document and discarded when it closes. A range holding preserved markup is `preserved-region`. One call is one transaction. The range is staged as a selection on a local state that is never dispatched, and the command's transaction is captured rather than forwarded, so "exactly one" is a property of the code and not a hope about every command an integrator installed. It carries the agent marker, which #249 will group undo on, and it restores the author's selection: the staged range is there for the command to read, and leaving it behind would jump a caret that may be in another paragraph. After dispatch the state identity is checked, because a `filterTransaction` drops a transaction silently and reporting that as a write is the failure this tool is shaped to avoid. A handle may name an inline range or a whole block node, and nothing in a handle says which. `stage` discriminates on `nodeAfter` and the node's size: a block range is brought inside the block's boundary tokens, an atom becomes a `NodeSelection`, and an inline range is unchanged. Without that, both ends of a block range sit on a boundary token and `TextSelection.between` searches outward -- a handle naming a horizontal rule came back as a selection over the paragraph before it. `marker.ts` and `write-guard.ts` are deliberately small and plainly named: the HTML write path needs both, and the two copies should collapse into one. Docs: the package README gains a tool row, the new error tokens and an "Applying a command" section; SECURITY.md gains a paragraph on the first tool that writes; the CHANGELOG, the integration guide, the root README and authoring-plugins §1 all name the new capability. The §4.5 budget row is the measured 5.1 KB gzipped against an unchanged 8 KB budget. Refs #248 Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
An agent can rewrite the passage a handle names, and every guard the editor makes to a person still holds. The HTML is sanitized before it is parsed, by the same `normalizePastedHtml` a human paste goes through, and that ordering is the whole of the guarantee. The preservation layer is a catch-all -- markup the schema does not recognise is wrapped and kept rather than rejected -- so parsing agent HTML first would turn hostile or malformed input into an opaque atom the document then carries faithfully forever, preserved precisely because nothing could parse it. Content the policy leaves nothing of is refused rather than written. A range covering preserved markup is refused outright: the promise to hand that markup back byte-identical only holds if nothing edits inside it, and the route an agent has to such a range is real, not theoretical -- the search stands an inline atom in for one object-replacement character, so searching for that character hands back a handle onto preserved content. Every check runs before anything touches the editor, so a refused write is not a partial write; it is not a write. Each call dispatches exactly one transaction, carrying a `PluginKey` marker #249 will key undo grouping off. The checks, the sanitize-then-parse step and the single dispatch live in `src/write.ts` rather than inside the tool, because #247, #248 and #250 all build on them and a second copy that drifted would drift in the direction of writing to the wrong place. `writeAt` takes a callback that returns a transaction; the one `view.dispatch` in the package is inside it. `agentSlice` fits content to both shapes a handle comes in: a search's inline range inside one textblock, and an outline's whole-block node range. A lone paragraph going into inline text contributes its contents, so a model wrapping its answer in `<p>` does not split the sentence it was editing; anything else, and anything over a block range, goes in as blocks. Tests: `packages/plugins-webmcp/test/write.test.ts` for the paste policy, the preserved refusals, the block-range shape and the transaction count and marker; `webmcp.spec.ts` gains a writing block asserting through `stored()` in three engines, including the browser-side proof #244 owed -- a handle taken before the author edits elsewhere still names its own text afterwards. The webmcp harness gains the two pieces of preserved markup those need. `@openleaf-editor/paste` becomes a peer dependency; it is already on the shared runtime, so the bundle borrows it. `openleaf-webmcp.min.js` measures 4.8 KB gzipped against its unchanged 8 KB budget. Docs: the package README gains a tool-table row, the two new error tokens and a "Writing" section; SECURITY.md gains a paragraph on the sanitize ordering and the preserved refusal; the root README, the integration guide, the plugin table and the §4.5 budget table in `docs/authoring-plugins.md`, and the changelog are all updated. Refs #246 Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Installing the agent tool set is a coarse decision: it offers an agent
every tool or none of them. A host that wants an agent to read its
documents and not rewrite them, or to touch the draft editor and not the
published one, had nowhere to say so short of forking the package.
`installAgentTools({ allowTool })` takes a synchronous predicate asked
before every tool call. It receives the tool's name, the editor
identifier the call names, and the tool's own `readOnlyHint` -- so
"allow reads, refuse writes" is `({ readOnly }) => readOnly` rather than
a list of tool names that goes stale the moment one is added. Answering
anything but `true` returns `refused` to the agent and changes nothing;
the question is asked before any argument is validated and before
anything touches an editor, so a refusal is not a partial call. A
predicate that throws is treated as a refusal rather than reaching the
agent as a rejected call with no shape to it, and nothing of the thrown
error travels back with it. With no predicate, every tool behaves
exactly as it did.
The gate wraps each descriptor where the set is composed rather than
living inside the handlers. That is what makes it cover
`openleaf_list_editors`, which takes no editor and so passes through
neither of the package's two argument chokepoints, and it is what makes
a tool added later gated by having been added -- there is no line for
its author to forget.
From a script tag the predicate is `OpenLeaf.registerAgentPermission`,
next to `agentTools` on the `registerSaveHandler` precedent: that bundle
installs on load, so `installAgentTools`'s options argument is spent by
the time an integrator's own script runs.
`openleaf-webmcp.min.js` 6.4 -> 6.6 KB gzipped against the unchanged 8
KB budget.
Refs #250
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
An agent could rewrite the passage a handle names; it could not add one beside it. `openleaf_insert_html` takes the same handle and a `position` of "before" or "after", leaves the named text alone -- so the handle is not spent -- and goes through the same `writeAt` chokepoint every other write does: one transaction marked as the agent's, the paste policy over the HTML before the parser sees it, preserved markup refused, and nothing touched at all when any of that says no. The one thing insertion does that replacement does not is ask the schema whether the content may sit at that position. Replacement is fitted to the range it lands in, which is right for a call that means "this text becomes that". Fitting an insertion means a heading aimed into the middle of a sentence splits the paragraph in two, and emphasis aimed into a code block is dropped on the way in -- both reported to the agent as successes it can build on. So `parent.canReplace` is asked before anything is built, and an insertion it refuses answers `invalid-position` with the content expression that position does hold, which is what tells an agent to reshape the HTML or ask an outline for a handle naming a whole block. `invalid-position` is a new `ToolErrorCode`, separate from `rejected-content` because here re-reading the schema is exactly what helps. Docs: the package README gains an "Inserting" section and the new token; SECURITY.md's paste-policy and annotation paragraphs name the new tool; CHANGELOG, the root README, the integration guide and the plugin table in docs/authoring-plugins.md follow. The §4.5 size row moves 6.4 -> 6.9 KB gzipped against the unchanged 8 KB budget. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…e command
`markAgent`'s payload meant two things at its two call sites: `openleaf_replace_at`
marked `{ tool: 'openleaf_replace_at' }` while `openleaf_apply_command` marked
`{ tool: 'bold' }` -- the command id. A field called `tool` that means a tool on
one write path and a command on another is a field nothing can read, and it is
about to have a reader.
Both call sites now mark the tool name. It is the value every write path has,
it is the string the agent actually called, and it is page-global where a
command id is not -- `registerToolbarItem` is last-wins, so `bold` may be an
integrator's command rather than the built-in one. Which command ran is already
reported in `openleaf_apply_command`'s result, which is where an agent reads it.
`markAgent` becomes private behind `dispatchAgent`, the one dispatch in the
package: it marks, dispatches, and answers whether the transaction survived the
editor's `filterTransaction`. `openleaf_apply_command` still cannot hand
`writeAt` a finished transaction, but it no longer has to remember the marker or
repeat the did-it-land check to get one.
Refs #249
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
An author who watched an agent restructure a document presses Ctrl+Z once and has the document back as it was before the agent started. Redo brings the whole run back the same way. The editor's default cannot answer this. `history()` groups by elapsed time and adjacency, a plugin cannot change its options, and agent calls arrive in a burst -- so the same six-paragraph rewrite collapses into one step or fragments into six depending on how fast the model answered and how far apart the paragraphs were, and the author has no way to know how many times to press. Grouping keys off the marker every agent write already carries, so a slow agent and a fast one produce the same one step. Three mechanisms, one per edge of the run, and none of them touches a transaction the author produced: - `appendedTransaction` on the second and later writes of a run holds it together at any elapsed time -- the trick `core/src/autolink.ts` uses. - `closeHistory` on the first write stops the run reaching backwards into the author's preceding typing, and is what makes a human edit between two agent writes break the run. - `setTime` stops it reaching forwards. History merges the next transaction if it is inside `newGroupDelay` and adjacent, and an agent write is both: it happened just now and the author's caret is often exactly where it landed, so without this the first thing they typed afterwards was undone with the agent's work. "Consecutive" is doc identity rather than a flag or a clock: a `doc` node is replaced only by a transaction that changed the document, so the caret moves, focus changes and step-free handle transactions that pass through a live editor cannot break a run, and a real edit cannot fail to. `prosemirror-history` joins the peer dependencies at the range core and element already use, for `closeHistory` alone. It is already on the shared runtime, so the bundle borrows it: 6.4 -> 6.6 KB gzipped against the unchanged 8 KB budget. Closes #249 Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`agentSlice` handed the agent's own string to `normalizePastedHtml`, which dispatches on `detectSource`. One of its branches is laxer than the rest: `looksLikeOpenLeaf` is the bare presence of `data-pm-slice=`, and it selects `normalizeOpenLeaf`, which keeps inline styles because a copy out of this editor is in the same trust domain as where it is going. An agent's argument never is, and the marker is one the agent writes itself -- so an agent could pick its own sanitizer and land `style="position:fixed"` in a document, which is exactly the markup this package exists to make unreachable. The choice is now taken away from it: markup that detects as an internal copy goes through `normalizeGeneric` instead. The other three branches (word, excel, gdocs) all end in `stripAllStyles` and strip their own vendor debris besides, so steering into one of them can only make the policy stricter and is left alone. Regression coverage at both levels, with the attribute-bearing string the existing fixtures omit: `write.test.ts` and `webmcp.spec.ts`. Both fail against the previous dispatch. Docs: the `Writing` bullet in the package README and the paste-policy paragraph in SECURITY.md now say the policy is applied as it is to foreign input, rather than implying `normalizePastedHtml` is one policy. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`run` called `command(staged, capture, view)`. A `Command` is `(state, dispatch, view)`, and the third argument is a way out of the second: a command handed the live view can call `view.dispatch` itself and write unmarked, ungrouped, past the did-it-land check, and outside the "exactly one transaction" guarantee the function's own comment claims. Commands here are third-party code -- `registerToolbarItem` is last-wins, so even a built-in id may be an integrator's -- so this was not hypothetical. It now gets the real view with one property shadowed: `Object.create(view)` with its own `dispatch`, so every other read falls through to the real object (`state`, `dom`, `someProp`, `coordsAtPos`) and only the dispatch is ours. Passing nothing instead would have broken every command that legitimately measures or focuses through the view, for the sake of the one that dispatches. Test: a registered command that dispatches through the view produces exactly one transaction, marked as the agent's, with the content it asked for. Against the previous call it declined and wrote anyway. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…uard Two duplications the concurrent tickets left behind. `apply-command.ts` re-declared the `handle` argument inline instead of spreading `handleArgument`, which `write.ts` exports precisely "so all of them describe it alike". Its copy had also gone stale: it said "A handle from openleaf_find_text", omitting `openleaf_get_structure` -- even though the same file's `stage()` exists to handle an outline handle's whole-node range. An agent reading the schema was told outline handles are not accepted. It now spreads the shared one, and its description says both sources too. Six near-identical string-argument guards in two spellings (`typeof x !== 'string' || x === ''` and `typeof x === 'string' ? x : ''`) collapse into `stringArg(args, name)` in `editor-arg.ts`: one reading of "whatever the agent actually sent", used by `withEditor`, `targetFor`, and the `text`, `html`, `html` and `command` guards. The refusal message stays each tool's own -- it is the only place an agent is told what to send instead. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Seven of the eight tools name the editor they acted on in their success
payload; the search was the one that did not. An agent driving several editors
had to pair a result with the call it came from by position, which is the kind
of bookkeeping the id exists to remove -- and the odd one out in a set whose
whole value is that the tools answer alike.
`{"ok":true,"id":string,"matches":[…],"truncated":boolean}` now, with the
tool's own description, the package README, the jsdom test and the browser
test that pinned the old shape updated with it.
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
`registerAgentPermission` was last-writer-wins and accepted `null`. It is hung
on the page's own `OpenLeaf` global -- it has to be, because the script-tag
bundle installs on load and `installAgentTools({ allowTool })` is therefore
unreachable from a script tag -- so any script that ran after the integrator's
could hand the tools a policy of its own or clear it back to "allow
everything". That is a hole in the one thing #250 exists to give an
integrator, and #242 already says a second `installAgentTools` is ignored,
options and all.
The first predicate registered now wins, whichever door it came through, and a
later call is ignored: another predicate, `null`, or an `installAgentTools`
that supplies its own. The `typeof` guard is for the untyped script-tag caller
that would still reach for `null` to clear one. This is not a defence against a
script that already has the page -- it can call `agentTools` directly -- it is
what stops the policy being replaced, by a third party or by accident.
A policy that changes with the host's state belongs inside the predicate, which
is asked on every call; that is now said in the package README, the integration
guide, SECURITY.md and the changelog entry.
`permission.test.ts` re-imports the package per test, because the state under
test is exactly the state a test used to reset -- and the register and handle
table have to come from the same fresh instance the tools read. Three new
cases: a second registration, a clear, and a later install with its own
predicate. `webmcp.spec.ts`'s "goes back to allowing everything when the policy
is cleared" becomes its opposite, driven through the same script-tag hatch.
Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…stallers Three stale claims, all of them about a bundle that has since been built. `scripts/bundle-budgets.mjs` argued 8 KB "against a measured 1.3", as room for a tool set arriving one tool per issue. That has played out: all nine tools of #241 are in and it measures 7.2, so the comment now says what is true -- roughly 0.8 KB left, one more tool at recent prices, and past that a decision about the surface rather than a number to raise. `demo/build.mjs` called it "the smallest bundle here, and it should stay that way". It is not: import (3.3), colour (5.3) and highlight (6.7) are all smaller. It has the smallest budget, which is a different claim, and what should stay that way is the structural reason -- no icons, no stylesheet, no dialogs. `packages/element/README.md`'s optional-plugin list never gained `plugins-webmcp`, though the root README, the integration guide and `docs/authoring-plugins.md` all did. No behaviour change; `openleaf-webmcp.min.js` still measures 7.2 KB gzipped against the unchanged 8 KB budget, which is the figure §4.5 already carries. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
#241 asks that the guarantees be asserted through the stored form value, and `openleaf_replace_at`'s "exactly one transaction per call" had no such proof: `write.test.ts` counts transactions in jsdom, and the browser suite showed only that a *run* of three writes is one press -- which the undo grouping would give for a call that dispatched twice as well. One write, one `Ctrl+Z`, back to the value the form would have posted before it. The sibling claim for `openleaf_apply_command` was already covered by "lands as one undoable step"; the marker itself is proved in the browser by the two grouping tests, which an unmarked write would fail. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
… too The seventh copy of the same reading, and the last one in the other spelling: `typeof id === 'string' && id !== '' ? id : null`. It answers a different question from the six guards -- which editor the agent named, if any, rather than a refusal -- so the `null` stays, but the string comes out of `stringArg` like everywhere else. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
The outline test clicked the first paragraph and pressed End before typing. `toBeFocused()` resolves before the click has moved the caret, so the keypress raced it: the text landed at position 0 and Enter split above it, giving "a second paragraphalpha beta" and an empty block. It failed in isolation, not only under load. The caret is now set through the view by arithmetic, the same idiom keyboard.spec.ts uses. 5 repeats on chromium and the full suite on all three engines are green. Refs #241 Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
PeytonNowlin
marked this pull request as ready for review
August 28, 2026 02:20
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fc30bbb349
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`undo` and `redo` are registered, are first on the default bar, and have a plain `command` -- so every guard in `openleaf_apply_command` passed them through. They are also the one pair that ignores the selection: they revert the last history event wherever in the document it happened. An agent could hand one a valid handle naming its own passage, revert an author's unrelated work somewhere else, and be told the handle-scoped call succeeded. A toolbar item now declares what it acts on. `scope: 'document'` says the command does not read the selection; `selection` is the default, so every other item is unchanged and the toolbar does not read the field at all -- for a person clicking a button the distinction is invisible and correct. The webmcp tool refuses a `document` command with `unsupported-command`, beside the refusal for a control that has no command underneath it, so there is still no list of command names in that file. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
The contract is that `true` allows a call and anything else refuses it, and `if (!allowed)` did not say that. A predicate the integrator wrote as `async` answers with a Promise; one whose body ends in the session it looked the decision up in answers with an object. Both are truthy, and neither is a policy that said yes -- but both were read as authorization, for reads and for writes alike, on a page whose host thought it had installed a veto. The answer is now typed `unknown` and compared with `===`. The type said boolean and nothing made that so: this is host code reached through a function on the page's own global, often from a script tag with no types in front of it at all. A predicate whose answer needs an `await` cannot be answered here, so the documentation says to cache the decision and have the predicate read the cache. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Nothing validates a call against the schema a tool published -- the browser parses the agent's JSON and hands over whatever it parsed -- so a top-level `null`, `undefined`, a number or a string reaches `execute` as the arguments. Every handler reads a property off that as its first act, `openleaf_list_ editors` included through the permission gate's own `id` lookup, so the call threw before any of them could answer `invalid-argument`. A throw out of a handler reaches the agent as a rejected call with no shape to it and nothing to retry against, which is the one outcome every result in this package is written to avoid. Normalised once in `gated`, which is the seam every tool is composed through for exactly this kind of invariant: a non-object becomes an empty one, and each handler goes on to say which argument was missing in its own words. The test asserts it over the whole set rather than a list of names, so a tool added later is covered by having been added. The `demo/build.mjs` figure moves 7.2 -> 7.4 KB, which is the refusal text these three fixes added; the budget is 8 KB and `check-docs` pins the number. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
…ries The three fixes are corrections to features that have not shipped, so they belong in the entries that describe them rather than under Fixed: what `openleaf_apply_command` refuses, and what the integrator's predicate has to answer. `ToolbarItemSpec.scope` is new public API on `@openleaf-editor/ui` and gets an entry of its own. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
Owner
Author
|
Cursor have you reviewed this? |
There was a problem hiding this comment.
Human TLDR:
- Two should-fix correctness bugs: editors can share the same agent id (writes then hit the first one), and while HTML source view is open
get_documentreads the textarea but search/outline still read the hidden document. - The earlier Codex findings (permission
=== true, undo/redo refused, non-object args) look fixed on this head.
Review
Looked at the new WebMCP write, handle, permission, and registry paths only. No security injection hole beyond the paste policy this package already documents.
Sent by Cursor Automation: PR Review
…se reads in source view Both from a review round on #252. The identifier check only covered attribute-against-attribute. The generated name shares one namespace with the integrator's own `id`, and `editor-2` is a name an integrator really writes -- the README documents that exact spelling -- so a page whose first editor was `id="editor-2"` handed the same name to the second editor, which has no id and is second on the page. Both answered to it, `openleaf_list_editors` returned it twice, and `findEditor` resolved every later call to whichever was registered first: replacing text in one rewrote the other, and the agent was told `{"ok":true}`. The ordinal now walks past a name that is already live, and the count is shared so a skipped name is never handed out. `openleaf_get_document` reads `host.value`, which is the source textarea once source view is open; `openleaf_find_text` and `openleaf_get_structure` still walked `view.state.doc`, the document that is not reparsed until the view closes. An agent could read the markup, search it for a string it had just been given, and be told the string is not there -- or be handed a match in text the author had already deleted. Both now refuse with the same `refused` token the write path uses. Not by parsing `host.value` into a throwaway document: the handles that would mint point into a document that is not the live one, so no later call could write through them. The source-view question now has one home, `source-mode.ts`, asked by the two reads and by `refuseWrite`, so the three cannot drift on what they say about it. Also bumps the measured `openleaf-webmcp.min.js` claim in authoring-plugins.md from 7.4 KB to 7.6 KB; the new module put it past the docs gate's tolerance. Budget headroom is unchanged at 7.6/8 KB. Signed-off-by: Peyton Nowlin <peytonn98@googlemail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Implements the WebMCP tool surface spec.
An opt-in plugin package that registers a WebMCP tool set for the OpenLeaf
editors on a page. An agent lists the editors, asks each one what it can hold
and what it can do, locates text by searching rather than by guessing at
positions, and makes changes through the same commands and the same sanitize
policy a human's typing goes through. The editor gains no new nodes, no new
marks, no new buttons, and no new CSS; a deployment that does not install the
plugin is byte-for-byte the deployment it is today.
Tickets
listEditorsgetCapabilitiesandgetDocumentfindTextgetDocumentStructurereplaceRangeinsertHTMLapplyCommandThe eight tools
openleaf_list_editors,openleaf_get_capabilities,openleaf_get_document,openleaf_find_text,openleaf_get_structure,openleaf_replace_at,openleaf_insert_html,openleaf_apply_command.Every tool is gated by an optional integrator predicate, carries
readOnlyHint,and marks
untrustedContentHintwherever it returns document content.Departures from the spec, and why
The browser API is
document.modelContext/navigator.modelContext, notdocument.agent/navigator.agent. #241's spike table names the latter. Are-probe of the Chrome for Testing 151 that this repo's Playwright bundles, with
--enable-blink-features=WebMCP, found it wrong — and found that the abortsignal works only as
registerTool(descriptor, { signal }), the secondargument. A
signalinside the descriptor is silently ignored, so building tothe spec as written would have shipped a registration with no teardown at all.
Everything that names the API lives in
src/agent.ts; the next rename is onefile.
Agent HTML is sanitized with
normalizePastedHtmlfrom@openleaf-editor/paste, notsanitizeHtmlfrom@openleaf-editor/sanitize.normalizePastedHtmlis what the editor's own paste path actually runs, so itis literally "the same policy paste uses".
@openleaf-editor/sanitizeis aserver-side package whose sub-path imports the shared-runtime bundler cannot
resolve; using it would have meant changing the core bundle, which the spec
forbids.
The demo page is untouched — #241 lists it as out of scope. The bundle is
built and budgeted but not shown there.
Review findings fixed on the branch
A two-axis review (repo standards, and the spec's 37 user stories plus the nine
acceptance checklists) ran before this left draft. The one that mattered:
Agent HTML could steer the sanitizer into its lax path.
normalizePastedHtmldispatches on
detectSource(), andlooksLikeOpenLeafis/\bdata-pm-slice\s*=/— an attribute the agent controls in its own argument. Setting it selected the
internal-clipboard normalizer, which keeps inline styles, so
<div style="position:fixed">survived a write that was supposed to strip it.That defeats user story 21 outright, and the e2e test guarding it passed only
because its fixture omitted eleven characters. Agent HTML is foreign input and
now always takes the foreign-input path. The other three source signals were
checked and left alone: they can only select a stricter normalizer.
Also fixed: a third-party command could dispatch through the live
EditorViewbehind the "exactly one transaction" capture;
applyCommandre-declared theshared handle description and its copy wrongly told agents outline handles were
not accepted;
registerAgentPermissionwas last-writer-wins, so any page scriptcould clear the integrator's policy (now set-once and non-clearing); six
duplicated argument guards;
findTextalone omittedidfrom its payload; andtwo comments describing a 1.3 KB bundle that is now 7.2 KB.
Second review round
Three more, all confirmed and fixed:
An agent could undo the author's work through a handle.
undoandredoare registered, are first on the default bar, and have a plain
command, soevery guard in
openleaf_apply_commandpassed them through — and they are theone pair that ignores the selection, acting on the last history event wherever
in the document it happened. Reproduced before fixing: the tool answered
{"ok":true,"command":"undo"}with an edit made in another paragraph gone.ToolbarItemSpecnow carriesscope?: 'selection' | 'document'(defaultselection);undoandredodeclaredocument, and the tool refuses onewith
unsupported-command. The toolbar does not read the field, so nothingabout the bar changes, and there is still no list of command names in the tool.
The permission predicate was read for truthiness, not for
true. Thedocumented contract is that only
trueallows a call. Anasyncpredicateanswers with a Promise and one ending in a session lookup answers with an
object; both are truthy, so both silently authorized reads and writes on a page
whose host thought it had installed a veto. The answer is now typed
unknownand compared with
===.A call whose arguments were not an object threw. Nothing validates a call
against the schema a tool published, so a top-level
nullor a number reachedexecuteand threw at the first property read — reaching the agent as arejected call with no shape to it. Normalized once in
gated, the seam everydescriptor is composed through, so every tool answers
invalid-argumentinstead. Asserted over the whole set rather than a list of names.
A fourth finding, a commit missing a sign-off trailer, named an object that is
not on this PR; all 33 commits carry the trailer and the Sign-off job is green.
Documentation
packages/plugins-webmcp/README.md(new), rootREADME.md,packages/element/README.md,SECURITY.md(agent tools are a new caller, andthe permission predicate),
docs/integrating-openleaf.md,docs/authoring-plugins.md§1, §3.4 (the newscopefield) and the §4.5 sizetable,
docs/releasing.mdandAGENTS.md(fifteen → sixteen packages), andCHANGELOG.mdunder## Unreleased.The second review round also touched
SECURITY.md,docs/integrating-openleaf.mdand the package README (the predicate is compared with
===, and what to do whenthe decision needs an
await), the README's error-token list (a malformed call isan
invalid-argumentresult, never a throw) and itsopenleaf_apply_commandsection (a command acting on document history cannot be handle-scoped), plus
src/result.tson whatunsupported-commandnow covers.Dependencies
No new third-party dependencies. Three existing workspace/peer packages were
added to the new manifest:
@openleaf-editor/ui(to read which commands adeployment installed),
prosemirror-modelandprosemirror-history(forcloseHistory). All three are already on the shared runtime, so the script-tagbundle ships no extra copy.
No translation obligation
The plugin contributes no user-visible strings. Tool names and descriptions are
agent-facing and stay in English, per #241.
Verification
pnpm typecheck,pnpm test(1884),node scripts/check-docs.mjs,node scripts/bundle-budgets.mjs(openleaf-webmcp.min.js7.4 / 8 KB gzipped —the refusal text the second round added; the documented figure moved with it),
pnpm test:e2e(1269 passed on all three engines) andnode scripts/verify.mjs --quickall pass. The two remaining e2e failures,[firefox] placeholder.spec.tsand[webkit] demo.spec.ts › promo video, failidentically on
fc30bbband onmainand are untouched by this branch;[firefox] resilience.spec.tsflakes under full parallel load and passes on itsown.
Closes #241
Closes #242
Closes #243
Closes #244
Closes #245
Closes #246
Closes #247
Closes #248
Closes #249
Closes #250
🤖 Generated with Claude Code