Skip to content

feat: runtime plugins for real — load from outside the binary, proven by a genuine plugin (goal 0249) - #515

Merged
alicoding merged 7 commits into
mainfrom
feat/0249-runtime-plugins
Aug 29, 2026
Merged

feat: runtime plugins for real — load from outside the binary, proven by a genuine plugin (goal 0249)#515
alicoding merged 7 commits into
mainfrom
feat/0249-runtime-plugins

Conversation

@alicoding

Copy link
Copy Markdown
Owner

Un-gates ADR-0047 §4's out-of-tree loader (owner-directed). Orchestrator-authored end to end per the #514 law. The audit found 8 oversold claims first ("do not trust the pattern"); every one is now closed:

The kernel (S1):

  • internal/services/pluginsvc: scans <data dir>/plugins/<id>/ for the converged manifest (id-equals-folder, enumerated capabilities, fail-closed on unknown ones), serves plugin js/css/json over the asset middleware (traversal-guarded; invalid plugins never serve).
  • The capability seam is real: RequestGuardedAction refuses an undeclared kind before any rule runs, evaluates declared ones per action through feat: the guardrail gains its public request-an-action entry (ADR-0047 §5) #508's rule core, and on approval Mill performs the primitive itself (open-url, http/https only) — the plugin never holds it.
  • Boot order beats reactivity machinery: plugins load before the frontend module graph evaluates (App + aux windows are dynamic imports), so every existing snapshot includes runtime registrations; a tripwire makes regressions loud.
  • One frozen activate(api): registerCanvasObject (the ADR-0046 object concept — renderFace(el, ctx) is a framework-agnostic DOM callback, no build step for authors), registerCommand, payload writes + guarded actions host-mediated. ObjectSource grows its url arm; SetBoardObjectPayload is the new content-plane door (undo round-trip tested, completeness-classified).

The proof (S3): examples/plugins/mill-bookmark — plain ESM, copy the folder, reload, a Bookmark object exists. Also closed on the way: parked guarded actions now render in Review with approve/deny (ADR-0047 §5's "render alongside" half had never shipped — a plugin's ask was invisible and unapprovable). runtime-plugins.spec.ts (3/3) boots against a copy of examples/plugins and drives: copied folder → tray → placement → plugin render → payload edit → reload persistence → guarded ask → Review row naming plugin:mill-bookmark → approve wakes the caller ("Opened.") → deny reaches it ("Not allowed").

The install story (S2): Settings → Extensions gains Installed plugins — manifest metadata, declared capabilities, the same switch, error rows naming the exact problem, Open plugins folder + Reload. Live-verified + e2e-pinned including a broken-plugin fixture.

Records (S4): ADR-0047 amendment, SPEC §9.5, userdocs/reference/install-a-plugin.md, the extending doc's now-false "no out-of-tree mechanism" corrected, and the cruiser boundary finally guards a real path (src/plugins/sdk.ts imports nothing).

Proof of green: pluginsvc + atlassvc Go tests, vitest 121/121, runtime-plugins 3/3, full suite 560 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq

alicoding and others added 3 commits August 29, 2026 02:22
… in the plugin's hand

internal/services/pluginsvc scans <data dir>/plugins/<id>/ for the
converged manifest (id-equals-folder, enumerated capabilities,
fail-closed on unknown ones), serves each valid plugin's js/css/json
over the asset middleware (traversal-guarded, invalid plugins never
serve), and carries the capability seam: RequestGuardedAction refuses
an undeclared kind before any rule runs, evaluates a declared one
through the guardrail core per action, and on approval performs the
primitive itself (open-url, http/https only) -- the plugin never holds
it.

The frontend loads plugins BEFORE the app module graph evaluates
(main.tsx dynamic-imports App and the aux windows), so the tool-list/
command-table snapshots include runtime registrations with zero
late-registration machinery; a loadGate tripwire in atlasTools.ts
makes a boot-order regression loud. Plugins hold ONE frozen api
(src/plugins/hostApi.ts): registerCanvasObject (the ADR-0046 object
concept as the contribution -- renderFace is a framework-agnostic DOM
callback the host wraps in the one React mount), registerCommand
(palette, never default-bound), and requestGuardedAction. The noun
registry gains a third-party path (open string kinds; the built-in
literal unions and their identity agreement check keep guarding
built-ins), ObjectSource grows its url arm, placement is ONE generic
branch (atlasThirdPartyPlacement.ts), and SetBoardObjectPayload is the
new content-plane write door (undo round-trip tested, classified in
the completeness table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
…n end to end

examples/plugins/mill-bookmark is the reference plugin: plain ESM, no
build step, copied-folder install. It contributes a Bookmark canvas
object on the object contract (source: url -- the ObjectSource arm's
first declarer), edits its address inline through the host's
content-plane door, and its Open button never touches a browser --
it requests the guarded open-url action.

Closing the gap that made the capability model unusable: a parked
guarded action now RENDERS in the Review queue
(ReviewGuardedActions.tsx; PendingGuardedActions/ResolveGuardedAction
are Wails-bound and the park/resolve emit the existing
pending-changed event) -- the render-alongside half docs/adr/0047 §5's
park always promised. Tray buttons fall back to a tool's own declared
label when no locale key exists (a plugin has no bundle to key into).

runtime-plugins.spec.ts proves the shipping artifact, not a stand-in:
the server boots with MILL_PLUGINS_DIR at examples/plugins, and the
suite drives dropped-folder -> tray entry -> placement -> plugin
renderFace -> payload edit -> reload persistence, then the guarded
loop: ask parks, Review lists it naming plugin:mill-bookmark, approve
wakes the blocked caller ("Opened."), deny reaches it as not-allowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
…, and the real SDK boundary

Settings > Extensions gains an Installed plugins section: every folder
in the plugins directory with its manifest metadata (name, version,
author, description, declared capabilities), the same enable/disable
switch built-ins carry, a load-error state naming the exact problem
(no switch pretending a broken plugin could run), Open plugins folder,
and Reload (plugins load at app start). Plugin tools are excluded from
the compiled-in rows -- one richer row, never two.

Docs: userdocs/reference/install-a-plugin.md is the user-facing story
(install, capabilities, the activate contract, the shipping Bookmark
example); extending-the-canvas.md's "no out-of-tree mechanism yet"
paragraph -- now false -- points at it and reframes the compiled-in
door honestly. The dependency-cruiser boundary now guards a REAL path:
src/plugins/sdk.ts (what a plugin sees) may import nothing at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
@alicoding
alicoding enabled auto-merge (squash) August 29, 2026 07:03
alicoding and others added 4 commits August 29, 2026 03:08
Resolves the two overlaps by taking main's structure wholesale and
re-applying this branch's deltas in their new homes: the third-party
noun path re-lands on main's split registry (its content half now
lives in atlasBoardObjectContent.ts, widened to open string kinds
there); the installed-plugins mount and third-party row exclusion
re-land on the treatment's per-section ExtensionsSection; plugin
nouns declare nounName (the treatment's noun-title convention). This
branch's own gesture-types extraction is dropped -- main's split
already brought the registry under the file limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
…all-story case

The merge auto-resolved the bindings hub by taking main's regen,
dropping PendingGuardedActions/ResolveGuardedAction -- restored the
documented way (regenerate on rebase, never hand-merge). The
Extensions install-story e2e case (broken-plugin fixture, per-test
copy of examples/plugins) also lands here; it had missed the earlier
commit's staged paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
…ontract without the side effects

The dynamic-import boot (two commits back) had two measured costs: the
App chunk's CSS re-entered the cascade after mill-tokens.css (breaking
the load-order tie its header documents -- the teal accent test caught
it), and first-paint moved behind the plugin await, so a keypress
racing the shell landed on nothing. This restores the fully static
module graph (CSS order and chunking exactly as before) and instead
makes the three module-scope registry snapshots LAZY
(shared/lazySnapshot.ts): ATLAS_TOOLS, COMMANDS, and the Extensions
rows materialize on first ACCESS -- always a render- or event-time
read, after plugin activation -- with the boot tripwire moved into the
builder so a premature materialization stays loud.

The specs that pressed a hotkey the instant goto resolved worked only
by accident of synchronous eval; the shell now paints after a short
async boot, so their first press waits for the painted nav -- the
honest user-primitive precondition, applied to all five sites as one
class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
…g Open

The Enter commit's payload write re-renders the plugin face, replacing
its DOM; on a slower runner the Open click could land on the doomed
pre-commit button, whose handler then wrote status into a detached
element. Waiting for the commit's own observable (the derived title)
means the click always hits the current elements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
@alicoding
alicoding merged commit 41c7c8c into main Aug 29, 2026
52 of 54 checks passed
@alicoding
alicoding deleted the feat/0249-runtime-plugins branch August 29, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant