From 22c98d75b69db4d563ccc92819e02aca88d37318 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Sat, 29 Aug 2026 02:22:51 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat(0249=20S1):=20the=20plugin=20kernel=20?= =?UTF-8?q?=E2=80=94=20scan,=20serve,=20load=20at=20boot,=20one=20API=20in?= =?UTF-8?q?=20the=20plugin's=20hand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/services/pluginsvc scans /plugins// 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 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- .../mill/internal/domain/guardrail/index.ts | 1 + .../mill/internal/domain/guardrail/models.ts | 22 ++ .../services/atlassvc/atlasservice.ts | 13 + .../internal/services/dataevent/models.ts | 2 + .../services/guardrailsvc/guardrailservice.ts | 24 ++ .../mill/internal/services/mcpsvc/models.ts | 12 +- .../mill/internal/services/pluginsvc/index.ts | 13 + .../internal/services/pluginsvc/models.ts | 46 ++++ .../services/pluginsvc/pluginservice.ts | 69 +++++ frontend/src/app/main.tsx | 64 +++-- frontend/src/atlas/atlasGestureTypes.ts | 64 +++++ frontend/src/atlas/atlasNounRegistry.ts | 118 ++++----- .../src/atlas/atlasThirdPartyPlacement.ts | 17 ++ frontend/src/atlas/atlasTools.ts | 3 + frontend/src/atlas/objectSeams.ts | 12 +- frontend/src/atlas/useAtlasCreation.ts | 8 +- frontend/src/plugins/PluginFaceContent.tsx | 49 ++++ frontend/src/plugins/hostApi.ts | 86 +++++++ frontend/src/plugins/loadGate.ts | 20 ++ frontend/src/plugins/loader.ts | 93 +++++++ frontend/src/plugins/pluginCommands.ts | 25 ++ frontend/src/plugins/sdk.ts | 87 +++++++ frontend/src/shared/commands.ts | 8 + frontend/src/views/extensionMeta.ts | 1 + .../services/atlassvc/atlasboardobject.go | 62 +++++ .../atlassvc/atlasboardobject_test.go | 37 +++ internal/services/atlassvc/atlasundo_doors.go | 1 + internal/services/pluginsvc/pluginservice.go | 239 ++++++++++++++++++ .../pluginsvc/pluginservice_assets.go | 74 ++++++ .../services/pluginsvc/pluginservice_test.go | 134 ++++++++++ internal/services/wiring/plugins.go | 34 +++ main.go | 7 +- 32 files changed, 1351 insertions(+), 94 deletions(-) create mode 100644 frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts create mode 100644 frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts create mode 100644 frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/pluginservice.ts create mode 100644 frontend/src/atlas/atlasGestureTypes.ts create mode 100644 frontend/src/atlas/atlasThirdPartyPlacement.ts create mode 100644 frontend/src/plugins/PluginFaceContent.tsx create mode 100644 frontend/src/plugins/hostApi.ts create mode 100644 frontend/src/plugins/loadGate.ts create mode 100644 frontend/src/plugins/loader.ts create mode 100644 frontend/src/plugins/pluginCommands.ts create mode 100644 frontend/src/plugins/sdk.ts create mode 100644 internal/services/pluginsvc/pluginservice.go create mode 100644 internal/services/pluginsvc/pluginservice_assets.go create mode 100644 internal/services/pluginsvc/pluginservice_test.go create mode 100644 internal/services/wiring/plugins.go diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/index.ts index e6cfb8ebb..d3e40541d 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/index.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/index.ts @@ -8,5 +8,6 @@ export { export type { Rule, + Step, Verdict } from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts index e49ccdac0..85389a45b 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts @@ -118,6 +118,28 @@ export interface Rule { "Seed": seedorigin$0.Origin; } +/** + * Step is what one about-to-execute step looks like to the evaluator. + */ +export interface Step { + "NodeTypeID": string; + + /** + * RequestID is the step's configured HTTPRequest reference, if any + * (an integration-http node's requestId config). + */ + "RequestID": string; + "WorkflowID": string; + "NodeID": string; + + /** + * Env is the condition-evaluation environment: Payload, Attributes, + * Config -- same shape Decision-edge conditions already evaluate + * against, so rule authors learn one expression surface. + */ + "Env": { [_ in string]?: any } | null; +} + /** * Verdict is one evaluation's outcome: the effect plus which rule * produced it (nil RuleID/RuleLabel means the effect-class default diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts index 25a2b78e4..c4776010c 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts @@ -881,6 +881,19 @@ export function SetAtlasSession(state: $models.AtlasSessionState): $CancellableP return $Call.ByID(1784937459, state); } +/** + * SetBoardObjectPayload merges patch into a board object's Payload -- + * the content-plane write door for a payload-carrying object whose + * data changes after placement (docs/goals/0249: a plugin object's own + * fields, written host-mediated so plugin code never touches a + * binding). A key with an empty value deletes that key; every other + * key overwrites. mirrorPath changes re-arm the file watch the same + * way creation does. + */ +export function SetBoardObjectPayload(id: string, patch: { [_ in string]?: string } | null): $CancellablePromise { + return $Call.ByID(1717906545, id, patch); +} + /** * SetBoardObjectPosition updates a board object's placement within its * parent's canvas -- the same drag-persistence call cards/notes go diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/models.ts index a9b5d5575..dadf9dd2f 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/models.ts @@ -13,6 +13,8 @@ * one shared entity string rather than one per family, since they all * persist as a single blob (atlassvc's own atlasStateKey) and a * change to any of them means the whole surface should refresh. + * "extension" carries the canvas-extension id whose enabled/disabled + * state just changed (Settings > Extensions). */ export interface Changed { "entity": string; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts index 8445e8687..c8339cc95 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts @@ -47,6 +47,30 @@ export function DeleteRule(id: string): $CancellablePromise { return $Call.ByID(1475597571, id); } +/** + * EvaluateAction adapts a generic action's kind/attributes into a Step + * and evaluates it through EvaluateStep. kind fills Step.NodeTypeID -- + * the same scope axis a workflow node's own NodeTypeID already targets + * -- so a rule authored against a NodeTypeID scope also targets a + * guarded action of that kind, by construction, with no separate rule + * vocabulary to maintain. + */ +export function EvaluateAction(kind: string, attributes: { [_ in string]?: string } | null, $class: guardrail$0.EffectClass): $CancellablePromise { + return $Call.ByID(4024562939, kind, attributes, $class); +} + +/** + * EvaluateStep is the guardrail's rule-evaluation core: judges a + * fully-formed Step against the current rules with guardrail.Evaluate's + * deny > ask > allow > class-default precedence. A thin wrapper by + * design -- the extraction this pays for is a single call site every + * caller (a workflow step, a generic action) shares, so they can never + * silently diverge into two different evaluations of the same rules. + */ +export function EvaluateStep(step: guardrail$0.Step, $class: guardrail$0.EffectClass): $CancellablePromise { + return $Call.ByID(1285652131, step, $class); +} + /** * Rules returns every stored rule. */ diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/models.ts index 79da4ae3f..7602858d7 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/models.ts @@ -38,9 +38,9 @@ export interface MCPWriteActivity { /** * MCPWriteRequest is the frontend-facing shape for a still-PENDING * write (the "mcp-write-approval" event payload and PendingMCPWrites' - * own return type) -- narrower than MCPWriteRecord (no ToolName/ - * ArgsJSON/executor internals), same field names the banner/Review UI - * already bind against. + * own return type) -- narrower than the shared store's own + * GuardedActionRecord (no ToolName/ArgsJSON/executor internals), same + * field names the banner/Review UI already bind against. */ export interface MCPWriteRequest { "id": string; @@ -48,7 +48,7 @@ export interface MCPWriteRequest { "createdAt": string; /** - * LastPolledAt mirrors MCPWriteRecord's own field (docs/goals/0026 + * LastPolledAt mirrors the shared record's own field (docs/goals/0026 * item 3) -- nil when the requester has never called * check_write_status on this id yet. */ @@ -59,8 +59,8 @@ export interface MCPWriteRequest { * MCPWriteResolved is the frontend-facing shape for an already-resolved * write (docs/goals/0026 item 6) -- Review's Recently-resolved section * reads this alongside RunSummary's own resolved rows, merged - * newest-first. Retained for the same 24h window check_write_status - * already promises (sweepLocked's own retention) -- "durable across a + * newest-first. Retained for the same retention window check_write_status + * already promises (the shared store's own sweep) -- "durable across a * restart" and "still visible for the same window an MCP client can * still poll" are the same guarantee, not two. */ diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts new file mode 100644 index 000000000..e3f53cf55 --- /dev/null +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts @@ -0,0 +1,13 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as PluginService from "./pluginservice.js"; +export { + PluginService +}; + +export type { + GuardedActionDecision, + Manifest, + PluginInfo +} from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts new file mode 100644 index 000000000..076487c55 --- /dev/null +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts @@ -0,0 +1,46 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * GuardedActionDecision is RequestGuardedAction's wire shape. + */ +export interface GuardedActionDecision { + "Approved": boolean; + "Effect": string; + "RuleLabel": string; + + /** + * Performed is true when Mill executed the approved action itself + * (the plugin never receives the primitive). + */ + "Performed": boolean; +} + +/** + * Manifest is the converged plugin manifest shape (docs/adr/0047 §1: + * identity metadata + a declared capability set; contributions happen + * at activate() time through the host API, so they are not restated + * here). + */ +export interface Manifest { + "id": string; + "name": string; + "version": string; + "description": string; + "author": string; + "minMillVersion": string; + "capabilities": string[] | null; +} + +/** + * PluginInfo is one scanned plugin as the Extensions surface and the + * loader see it. Error is a load-blocking validation problem stated + * for the human (the row renders it; the loader skips the plugin) -- + * a plugin is either fully valid or visibly broken, never silently + * half-loaded. + */ +export interface PluginInfo { + "Manifest": Manifest; + "Dir": string; + "Error": string; +} diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/pluginservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/pluginservice.ts new file mode 100644 index 000000000..e1780252e --- /dev/null +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/pluginservice.ts @@ -0,0 +1,69 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * PluginService is Wails-bound. openURL is injected so tests never + * shell out to the real OS handler. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * AssetMiddleware serves GET /plugins// from the scanned + * plugins directory and passes every other request through. Only ids + * that scan to a VALID manifest serve at all (a broken plugin is + * visible in Extensions, never half-loaded via a dangling script + * URL), only allowlisted extensions serve, and the resolved path must + * stay inside the plugin's own folder (filepath.Rel guards traversal + * after cleaning). + */ +export function AssetMiddleware(): $CancellablePromise { + return $Call.ByID(3587145368); +} + +/** + * ListPlugins scans the plugins directory fresh on every call (the + * Extensions page's Rescan is just another call) and returns every + * plugin folder with its manifest -- valid ones ready to load, + * invalid ones carrying their human-readable Error. + */ +export function ListPlugins(): $CancellablePromise<$models.PluginInfo[] | null> { + return $Call.ByID(1757683504); +} + +/** + * PluginsDir returns the directory plugins are installed into -- + * the Extensions page's install story shows and reveals it. The + * directory is created on first ask so "open the folder" never lands + * on a missing path. + */ +export function PluginsDir(): $CancellablePromise { + return $Call.ByID(1345088799); +} + +/** + * RequestGuardedAction is the plugin plane's one door to a primitive + * the plugin does not hold (docs/adr/0047 §2). The manifest must + * DECLARE the capability (an undeclared kind is refused here, before + * any rule runs); a declared one is evaluated per-action by the + * guardrail rule core -- allow/deny resolve immediately, ask parks for + * a human and blocks this call until resolved (the same park the MCP + * write plane uses). On approval Mill performs the action itself. + */ +export function RequestGuardedAction(pluginID: string, kind: string, attributes: { [_ in string]?: string } | null, description: string): $CancellablePromise<$models.GuardedActionDecision> { + return $Call.ByID(2237721377, pluginID, kind, attributes, description); +} + +/** + * RevealPluginsDir opens the plugins directory in the OS file manager. + */ +export function RevealPluginsDir(): $CancellablePromise { + return $Call.ByID(4165393810); +} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 2009e5c14..998dedf1d 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -12,10 +12,15 @@ import '@primer/primitives/dist/css/functional/themes/dark.css' // the wrong element). import './mill-tokens.css' import { ThemeProvider, BaseStyles } from '@primer/react' -import App from './App' +// App is imported DYNAMICALLY inside bootstrap() below -- a static +// import would evaluate the whole app module graph (including the +// tool-registry snapshot) before the plugin loader has run. See +// src/plugins/loader.ts's boot-order contract. import { AppErrorBoundary, CrashProbe } from './AppErrorBoundary' -import { QuickPanelApp } from './QuickPanelApp' -import { ApprovalPromptApp } from './ApprovalPromptApp' +// QuickPanelApp/ApprovalPromptApp are dynamic for the same boot-order +// reason as App: their own import graphs are deep enough to reach the +// tool-registry snapshot, and hand-auditing them forever is exactly +// the maintenance trap the dynamic import avoids structurally. import { COLOR_MODE_STORAGE_KEY } from './theme' // Read once, synchronously, before the first render, to seed @@ -65,22 +70,47 @@ if (import.meta.env.PROD && 'serviceWorker' in navigator) { }) } -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - {isCrashProbe ? ( - - ) : isQuickPanel ? ( - - ) : isApprovalPrompt ? ( - - ) : ( +// The main window loads runtime plugins BEFORE the app module graph +// evaluates (docs/goals/0249): activation must precede the tool-list/ +// command-table snapshots those modules take at eval. Raced against a +// deadline so a hung plugin import can never brick the boot -- the app +// then simply starts without the slow plugin, whose row shows the +// state. The auxiliary windows (Quick Panel, approval prompt, crash +// probe) render immediately -- none of them mounts a canvas. +async function bootstrap() { + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) + if (isCrashProbe || isQuickPanel || isApprovalPrompt) { + const aux = isCrashProbe + ? + : isQuickPanel + ? await import('./QuickPanelApp').then((m) => ) + : await import('./ApprovalPromptApp').then((m) => ) + root.render( + + {aux} + , + ) + return + } + const { loadPlugins } = await import('../plugins/loader') + await Promise.race([ + loadPlugins().catch((err) => console.error('plugin loading failed', err)), + new Promise((resolve) => window.setTimeout(resolve, 4000)), + ]) + const { markPluginsSettled } = await import('../plugins/loadGate') + markPluginsSettled() + const { default: App } = await import('./App') + root.render( + + - )} - - , -) + + , + ) +} + +void bootstrap() diff --git a/frontend/src/atlas/atlasGestureTypes.ts b/frontend/src/atlas/atlasGestureTypes.ts new file mode 100644 index 000000000..89405f993 --- /dev/null +++ b/frontend/src/atlas/atlasGestureTypes.ts @@ -0,0 +1,64 @@ +import type { ComponentType } from 'react' +import type { FrameBox } from './useAtlasDragFiling' + +// The gesture-engine type vocabulary (goal 0215 S2), split from +// atlasNounRegistry.ts along the pure-types seam (500-line +// convention): every field's contract is documented where it was +// authored; this module only carries the shapes. +// AtlasGesturePoint -- one accumulated point of an in-flight gesture, +// always carrying its own capture timestamp so an ephemeral tool +// (laser's fadeMs) can age individual points out independently; every +// other tool simply ignores `t`. +export interface AtlasGesturePoint { x: number; y: number; t: number } + +// AtlasGestureCtx -- what a tool's own gesture.onPoint/onEnd may reach, +// assembled fresh each render by AtlasBoard.tsx and threaded through by +// the engine. Deliberately NOT the wrapper box or React Flow's own +// screenToFlowPosition internals beyond the function itself -- kept to +// exactly what the five hooks this contract replaces actually consumed +// (goal 0215 S2 design lock item 1). +export interface AtlasGestureCtx { + screenToFlowPosition: (p: { x: number; y: number }) => { x: number; y: number } + parentID: string + cardBoxes: FrameBox[] + noteBoxes: { id: string; x: number; y: number; width: number; height: number }[] + // Every board-local object's (ink/shape/image/table/diagram) own + // rendered flow-space box -- read off React Flow's own measured node + // state (goal 0230), since a BoardObject's persisted Size stays null + // until first resize and its rendered footprint is otherwise CSS- + // intrinsic (atlasBuildBoardObjectNodes.ts's own header comment). + objectBoxes: { id: string; x: number; y: number; width: number; height: number }[] + onDeleteSelection: (cardIDs: string[], noteIDs: string[], objectIDs: string[]) => void + openAreaPopover: (screenPos: { x: number; y: number }, flowPos: { x: number; y: number }, enclosedCardIDs: string[], enclosedNoteIDs: string[]) => void + onShapeCreated: (objectID: string) => void + // Real functions for a one-shot tool; no-ops for a sticky one (the + // engine's own gestureDisarmFns enforces this, not each tool). + disarm: () => void + disarmUnlessLocked: () => void + // Fresh per-gesture scratch space the engine allocates at pointerdown + // and discards after onEnd -- eraser's own onPoint is the sole + // consumer today; no other tool touches it. + hitAccumulator: { cardIDs: Set; noteIDs: Set; objectIDs: Set } +} + +// AtlasToolGesture -- a drag-shaped tool's own pure behavior +// contribution. onEnd receives the FULL client-space point list +// unconditionally (even a below-threshold stray click) -- deciding +// whether that constitutes a real gesture (a distance threshold, a +// hit count, or nothing at all) is each tool's own call, matching how +// the five hooks this contract replaces each guarded their own commit +// differently (eraser's own guard is "did we hit anything", never a +// distance). +export interface AtlasToolGesture { + onPoint?: (pt: AtlasGesturePoint, ctx: AtlasGestureCtx) => void + onEnd: (points: AtlasGesturePoint[], ctx: AtlasGestureCtx) => void + // Rendered generically by AtlasBoard.tsx in ONE overlay slot, wrapper- + // spanning, fed the engine's own wrapper-local point accumulation. + preview?: ComponentType<{ points: AtlasGesturePoint[]; now: number }> + // Ephemeral tools (laser) never commit -- their accumulated points + // fade out on their own timer instead of clearing at pointerup, the + // one generic mechanism useAtlasToolGesture.ts owns for an + // 'ephemeral-drag' tool so no tool needs its own rAF loop. + fadeMs?: number +} + diff --git a/frontend/src/atlas/atlasNounRegistry.ts b/frontend/src/atlas/atlasNounRegistry.ts index f3fd23e9c..89073a048 100644 --- a/frontend/src/atlas/atlasNounRegistry.ts +++ b/frontend/src/atlas/atlasNounRegistry.ts @@ -1,10 +1,11 @@ +import type { AtlasToolGesture } from './atlasGestureTypes' +export type { AtlasGestureCtx, AtlasGesturePoint, AtlasToolGesture } from './atlasGestureTypes' import type { ComponentType } from 'react' import type { Icon } from '@primer/octicons-react' import type { BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import type { ListProjection } from '../../bindings/github.com/alicoding/mill/internal/services/atlassvc/models' import { ATLAS_TOOL_IDENTITIES, type AtlasToolIdentity, type AtlasToolInteraction } from '../shared/atlasToolIdentity' import type { AtlasStyleField } from './atlasStyleVocabulary' -import type { FrameBox } from './useAtlasDragFiling' import type { EditRouteDecl, ObjectSource } from './objectSeams' import type { MirrorReadState } from './useAtlasObjectMirrorRead' @@ -141,7 +142,7 @@ export interface AtlasBoardObjectContent extends AtlasNounContent { fileBacked: boolean } -const boardObjectContentRegistry = new Map() +const boardObjectContentRegistry = new Map() // registerBoardObjectContent -- the honest home for a noun with no // tray tool at all (diagram: file-drop only, goal 0179 S2). Called @@ -149,7 +150,7 @@ const boardObjectContentRegistry = new Map unknown } -// AtlasGesturePoint -- one accumulated point of an in-flight gesture, -// always carrying its own capture timestamp so an ephemeral tool -// (laser's fadeMs) can age individual points out independently; every -// other tool simply ignores `t`. -export interface AtlasGesturePoint { x: number; y: number; t: number } - -// AtlasGestureCtx -- what a tool's own gesture.onPoint/onEnd may reach, -// assembled fresh each render by AtlasBoard.tsx and threaded through by -// the engine. Deliberately NOT the wrapper box or React Flow's own -// screenToFlowPosition internals beyond the function itself -- kept to -// exactly what the five hooks this contract replaces actually consumed -// (goal 0215 S2 design lock item 1). -export interface AtlasGestureCtx { - screenToFlowPosition: (p: { x: number; y: number }) => { x: number; y: number } - parentID: string - cardBoxes: FrameBox[] - noteBoxes: { id: string; x: number; y: number; width: number; height: number }[] - // Every board-local object's (ink/shape/image/table/diagram) own - // rendered flow-space box -- read off React Flow's own measured node - // state (goal 0230), since a BoardObject's persisted Size stays null - // until first resize and its rendered footprint is otherwise CSS- - // intrinsic (atlasBuildBoardObjectNodes.ts's own header comment). - objectBoxes: { id: string; x: number; y: number; width: number; height: number }[] - onDeleteSelection: (cardIDs: string[], noteIDs: string[], objectIDs: string[]) => void - openAreaPopover: (screenPos: { x: number; y: number }, flowPos: { x: number; y: number }, enclosedCardIDs: string[], enclosedNoteIDs: string[]) => void - onShapeCreated: (objectID: string) => void - // Real functions for a one-shot tool; no-ops for a sticky one (the - // engine's own gestureDisarmFns enforces this, not each tool). - disarm: () => void - disarmUnlessLocked: () => void - // Fresh per-gesture scratch space the engine allocates at pointerdown - // and discards after onEnd -- eraser's own onPoint is the sole - // consumer today; no other tool touches it. - hitAccumulator: { cardIDs: Set; noteIDs: Set; objectIDs: Set } -} - -// AtlasToolGesture -- a drag-shaped tool's own pure behavior -// contribution. onEnd receives the FULL client-space point list -// unconditionally (even a below-threshold stray click) -- deciding -// whether that constitutes a real gesture (a distance threshold, a -// hit count, or nothing at all) is each tool's own call, matching how -// the five hooks this contract replaces each guarded their own commit -// differently (eraser's own guard is "did we hit anything", never a -// distance). -export interface AtlasToolGesture { - onPoint?: (pt: AtlasGesturePoint, ctx: AtlasGestureCtx) => void - onEnd: (points: AtlasGesturePoint[], ctx: AtlasGestureCtx) => void - // Rendered generically by AtlasBoard.tsx in ONE overlay slot, wrapper- - // spanning, fed the engine's own wrapper-local point accumulation. - preview?: ComponentType<{ points: AtlasGesturePoint[]; now: number }> - // Ephemeral tools (laser) never commit -- their accumulated points - // fade out on their own timer instead of clearing at pointerup, the - // one generic mechanism useAtlasToolGesture.ts owns for an - // 'ephemeral-drag' tool so no tool needs its own rAF loop. - fadeMs?: number -} - // AtlasToolShape: a discriminated union, one member per // shared/atlasToolIdentity.ts entry, correlating id<->interaction the // same way the pre-registry hand-written ATLAS_TOOLS tuple did -- @@ -476,9 +420,57 @@ export function assertRegistryAgreesWithIdentity(): void { // import.meta.glob's own alphabetical file-path sort and silently // reorder the tray the next time a noun's filename changes). export function orderedRegisteredTools(): AtlasToolShape[] { - return ATLAS_TOOL_IDENTITIES.map((i) => { + const builtIns = ATLAS_TOOL_IDENTITIES.map((i) => { const found = registry.get(i.id) if (!found) throw new Error(`atlas noun "${i.id}" missing its registered descriptor`) return found }) + return [...builtIns, ...thirdPartyRegistry.values()] as AtlasToolShape[] +} + +// --- Third-party nouns (docs/goals/0249, ADR-0047's out-of-tree tier) --- +// +// A runtime-loaded plugin's canvas object registers here, through the +// SAME conceptual door built-ins use, with two honest differences: +// its id/kind are open strings (the built-in literal unions stay +// closed and keep guarding built-ins), and it is exempt from +// assertRegistryAgreesWithIdentity (which checks exactly the +// shared/atlasToolIdentity.ts list, where a runtime id cannot appear). +// orderedRegisteredTools appends third-party tools AFTER every +// built-in, cast into the AtlasToolShape array at this ONE site: +// consumers discriminate on `interaction` and read string fields +// generically; a consumer comparing `id` against a built-in literal +// simply never matches a plugin id, which is the correct behavior. +export type ThirdPartyNounShape = Omit & { + id: string + interaction: 'arm-then-click' + boardObjectKind: string + thirdParty: true + // The owning plugin (manifest id) -- the Extensions page's join key. + pluginId: string + defaultPayload: Record +} + +const thirdPartyRegistry = new Map() + +export function registerThirdPartyNoun(shape: ThirdPartyNounShape): void { + if (registry.has(shape.id) || thirdPartyRegistry.has(shape.id)) { + throw new Error(`canvas object kind "${shape.id}" is already registered`) + } + thirdPartyRegistry.set(shape.id, shape) + if (shape.content) { + registerBoardObjectContent(shape.boardObjectKind, { ...shape.content, dragBand: shape.dragBand, fileBacked: shape.fileBacked }) + } +} + +export function thirdPartyNouns(): ThirdPartyNounShape[] { + return [...thirdPartyRegistry.values()] +} + +export function isThirdPartyToolId(id: string): boolean { + return thirdPartyRegistry.has(id) +} + +export function thirdPartyNounFor(id: string): ThirdPartyNounShape | undefined { + return thirdPartyRegistry.get(id) } diff --git a/frontend/src/atlas/atlasThirdPartyPlacement.ts b/frontend/src/atlas/atlasThirdPartyPlacement.ts new file mode 100644 index 000000000..6d69b9534 --- /dev/null +++ b/frontend/src/atlas/atlasThirdPartyPlacement.ts @@ -0,0 +1,17 @@ +import { AtlasService } from '../shared/bindings' +import { refreshAtlas } from './atlasStore' +import { thirdPartyNounFor } from './atlasNounRegistry' + +// placeThirdPartyObject -- the ONE generic placement for every +// runtime-registered noun (docs/goals/0249): the armed click creates a +// BoardObject of the declared kind with its declared default payload, +// through the same content-plane door built-ins use. Returns false for +// a non-third-party tool so the caller's built-in branches proceed. +export function placeThirdPartyObject(toolId: string, flowPos: { x: number; y: number }, parentID: string): boolean { + const noun = thirdPartyNounFor(toolId) + if (!noun) return false + void AtlasService.CreateBoardObject(noun.boardObjectKind, { ...noun.defaultPayload }, { X: flowPos.x, Y: flowPos.y }, parentID) + .then(() => refreshAtlas()) + .catch(console.error) + return true +} diff --git a/frontend/src/atlas/atlasTools.ts b/frontend/src/atlas/atlasTools.ts index 36e735011..d8fb0bbee 100644 --- a/frontend/src/atlas/atlasTools.ts +++ b/frontend/src/atlas/atlasTools.ts @@ -1,4 +1,5 @@ import { ATLAS_TOOL_IDENTITIES, type AtlasToolIdentity } from '../shared/atlasToolIdentity' +import { warnIfSnapshotBeforePlugins } from '../plugins/loadGate' import { assertRegistryAgreesWithIdentity, orderedRegisteredTools } from './atlasNounRegistry' // The canvas tool registry (goal 0169 slice 1, re-platformed onto @@ -30,6 +31,8 @@ import { assertRegistryAgreesWithIdentity, orderedRegisteredTools } from './atla // somewhere, and nowhere else needs to enumerate the tools/ directory. import.meta.glob(['./tools/*.ts', '!./tools/*.test.ts'], { eager: true }) +warnIfSnapshotBeforePlugins() + // Fails fast (at the module-eval time every test/dev/build reaches by // importing this file) if a noun's identity and registered descriptor // ever disagree -- a noun that half-exists on either side never ships diff --git a/frontend/src/atlas/objectSeams.ts b/frontend/src/atlas/objectSeams.ts index 610c5869b..7c95793bd 100644 --- a/frontend/src/atlas/objectSeams.ts +++ b/frontend/src/atlas/objectSeams.ts @@ -16,15 +16,17 @@ import { openAtlasEditDiagram } from './atlasEditDiagramStore' // string-sniffing (the map that found this contract implicit: mirrorPath // for a file, listID for a Configure List projection). pathKey/refKey // ARE the literal Payload key -- resolveObjectSourceKey below is the one -// place that turns the declaration into the actual value. `url` and -// `bundled` are the future self-hosted/vendored engine-source arms -// (ADR-0045 S2) -- named in the ADR's glossary but not yet a member any -// registered noun declares. `board-local` (ADR-0046, goal 0244 S1) names +// place that turns the declaration into the actual value. `url` names a Kind whose +// artifact is a web reference the Payload carries (docs/goals/0249's +// bookmark object is its first declarer); `bundled` stays a future +// engine-source arm (ADR-0045 S2), named in the ADR's glossary but not +// yet a member any registered noun declares. `board-local` (ADR-0046, goal 0244 S1) names // a Kind whose Payload IS the artifact -- no external file/provider/url // to resolve at all (shape's own geometry). export type ObjectSource = | { kind: 'file'; pathKey: 'mirrorPath' } | { kind: 'provider'; refKey: 'listID' } + | { kind: 'url'; urlKey: 'url' } | { kind: 'board-local' } // EditRoute -- which door a Kind's edit affordance opens. The noun @@ -64,7 +66,7 @@ export function resolveEditRoute(object: BoardObject, decl: EditRouteDecl): Edit // directly by the Kind's own Component, never fetched by key here. export function resolveObjectSourceKey(object: BoardObject, source: ObjectSource): string | undefined { if (source.kind === 'board-local') return undefined - const key = source.kind === 'file' ? source.pathKey : source.refKey + const key = source.kind === 'file' ? source.pathKey : source.kind === 'url' ? source.urlKey : source.refKey return object.Payload?.[key] } diff --git a/frontend/src/atlas/useAtlasCreation.ts b/frontend/src/atlas/useAtlasCreation.ts index 26afb2103..3a6ff72be 100644 --- a/frontend/src/atlas/useAtlasCreation.ts +++ b/frontend/src/atlas/useAtlasCreation.ts @@ -8,6 +8,8 @@ import { resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './a import { freeChildPosition } from './atlasContainmentPlacement' import { computeEnclosedBoundingBoxOrigin } from './atlasBoardBoxes' import { ATLAS_TOOLS, isAtlasArmableTool, isLockableArmTool, type AtlasArmableTool, type AtlasCreationTool, type AtlasToolID } from './atlasTools' +import { isThirdPartyToolId } from './atlasNounRegistry' +import { placeThirdPartyObject } from './atlasThirdPartyPlacement' import { cardTool } from './tools/cardTool' import { noteTool } from './tools/noteTool' import { areaTool } from './tools/areaTool' @@ -119,7 +121,7 @@ export function useAtlasCreation({ parentID, allCards, kinds, notes, objects, re // behaviour, unchanged in toggleArm below. Narrowed off the SHARED // armedToolId (goal 0238): Table/Image can hold that same field // without this hook ever treating either as its own armed tool. - const armedTool = armedToolId !== null && isAtlasArmableTool(armedToolId) ? armedToolId : null + const armedTool = armedToolId !== null && (isAtlasArmableTool(armedToolId) || isThirdPartyToolId(armedToolId)) ? armedToolId : null const locked = armedTool !== null ? armedToolLocked : false const [popover, setPopover] = useState(null) const [draftNoteFlowPos, setDraftNoteFlowPos] = useState<{ x: number; y: number } | null>(null) @@ -214,6 +216,10 @@ export function useAtlasCreation({ parentID, allCards, kinds, notes, objects, re return } const flowPos = screenToFlowPosition(screenPos) + // Third-party canvas objects route through the one generic + // placement (atlasThirdPartyPlacement.ts); built-in branches below + // stay built-in-only by construction. + if (placeThirdPartyObject(tool, flowPos, parentIDOverride ?? parentID)) return if (tool === 'card') { // Instant placement (goal 0144): the click IS the creation -- // last-used kind, "Untitled", inline title editor on the node. diff --git a/frontend/src/plugins/PluginFaceContent.tsx b/frontend/src/plugins/PluginFaceContent.tsx new file mode 100644 index 000000000..917705e0a --- /dev/null +++ b/frontend/src/plugins/PluginFaceContent.tsx @@ -0,0 +1,49 @@ +import { memo, useEffect, useRef, type ComponentType } from 'react' +import type { BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' +import { AtlasService } from '../shared/bindings' +import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc' +import type { CanvasObjectDecl, GuardedActionResult } from './sdk' + +// pluginFaceComponent adapts a plugin's framework-agnostic +// renderFace(el, ctx) callback into the one React component shape the +// noun registry's content contract expects (AtlasNounContent.Component) +// -- the host owns the React mount; the plugin owns el's contents (the +// CodeMirror-widget/Obsidian-contentEl convergence, docs/goals/0249). +// renderFace re-runs whenever the object's own data changes; the +// plugin reads ctx.object to decide what to redraw. +export function pluginFaceComponent(pluginId: string, decl: CanvasObjectDecl): ComponentType<{ object: BoardObject; mirrorVersion: number }> { + const Face = memo(function PluginFace({ object, mirrorVersion }: { object: BoardObject; mirrorVersion: number }) { + const elRef = useRef(null) + // Payload identity changes on every fetch; re-render on VALUE + // change only, or a plugin's own updatePayload would re-invoke + // renderFace mid-typing with a stale echo of what it just wrote. + const payloadJSON = JSON.stringify(object.Payload ?? {}) + useEffect(() => { + const el = elRef.current + if (!el) return + try { + decl.renderFace(el, { + object: { + ID: object.ID, + Kind: object.Kind, + Payload: Object.fromEntries(Object.entries(object.Payload ?? {}).flatMap(([k, v]) => (v === undefined ? [] : [[k, v]]))), + }, + updatePayload: async (patch) => { + await AtlasService.SetBoardObjectPayload(object.ID, patch) + }, + requestGuardedAction: async (kind, attributes, description): Promise => { + const d = await PluginService.RequestGuardedAction(pluginId, kind, attributes, description) + return { approved: d.Approved, effect: d.Effect, ruleLabel: d.RuleLabel, performed: d.Performed } + }, + }) + } catch (err) { + // A plugin's render crash stays inside its own face -- + // never up into the board tree. + console.error(`plugin ${pluginId} renderFace failed`, err) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- payloadJSON stands in for object.Payload's value identity (see above) + }, [object.ID, payloadJSON, mirrorVersion]) + return
+ }) + return Face +} diff --git a/frontend/src/plugins/hostApi.ts b/frontend/src/plugins/hostApi.ts new file mode 100644 index 000000000..64310b51f --- /dev/null +++ b/frontend/src/plugins/hostApi.ts @@ -0,0 +1,86 @@ +import { createElement } from 'react' +import type { Icon } from '@primer/octicons-react' +import { registerThirdPartyNoun } from '../atlas/atlasNounRegistry' +import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc' +import type { Manifest } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' +import { collectPluginCommand } from './pluginCommands' +import { pluginFaceComponent } from './PluginFaceContent' +import type { CanvasObjectDecl, MillPluginAPI } from './sdk' + +// buildPluginAPI constructs the ONE object a plugin ever holds +// (docs/adr/0047 §2: capabilities arrive as api calls the host +// mediates, never as importable primitives). Frozen so a plugin +// cannot re-point a sibling's callbacks. Validation here is the +// host-side twin of pluginsvc's manifest validation: registration +// inputs are checked at the door, with the plugin's own id in every +// error so a broken plugin names itself. +const KIND_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/ +const SOURCES = new Set(['board-local', 'url', 'file']) +const EDIT_ROUTES = new Set(['inline', 'external-app', 'none']) + +// One emoji as the tray/palette icon -- wrapped into the octicon +// component shape the registry's `icon` field expects. The cast is the +// one place the two icon worlds meet; the rendered output honors the +// same size prop octicons do. +function emojiIcon(emoji: string): Icon { + const Component = ({ size = 16 }: { size?: number | string }) => + createElement('span', { style: { fontSize: typeof size === 'number' ? `${size}px` : size, lineHeight: 1 }, 'aria-hidden': true }, emoji) + return Component as unknown as Icon +} + +export function buildPluginAPI(manifest: Manifest, millVersion: string): MillPluginAPI { + const pluginId = manifest.id + const requestGuardedAction = async (kind: string, attributes: Record, description: string) => { + const d = await PluginService.RequestGuardedAction(pluginId, kind, attributes, description) + return { approved: d.Approved, effect: d.Effect, ruleLabel: d.RuleLabel, performed: d.Performed } + } + return Object.freeze({ + millVersion, + pluginId, + registerCanvasObject: (decl: CanvasObjectDecl) => { + if (!KIND_PATTERN.test(decl.kind)) throw new Error(`plugin ${pluginId}: canvas object kind "${decl.kind}" must be a lowercase slug`) + if (!SOURCES.has(decl.source)) throw new Error(`plugin ${pluginId}: unknown source "${decl.source}"`) + if (!EDIT_ROUTES.has(decl.editRoute)) throw new Error(`plugin ${pluginId}: unknown editRoute "${decl.editRoute}"`) + if (typeof decl.renderFace !== 'function') throw new Error(`plugin ${pluginId}: renderFace must be a function`) + registerThirdPartyNoun({ + id: decl.kind, + interaction: 'arm-then-click', + thirdParty: true, + pluginId, + defaultPayload: { ...(decl.defaultPayload ?? {}) }, + icon: emojiIcon(decl.icon), + label: decl.label, + description: decl.description, + shortcutKey: null, + tray: 'quick', + group: decl.source === 'file' ? 'file' : 'knowledge', + styleFields: [], + lockable: false, + resizable: true, + boardNodeType: 'atlas-object', + dragBand: true, + fileBacked: decl.source === 'file', + boardObjectKind: decl.kind, + content: { + Component: pluginFaceComponent(pluginId, decl), + // i18next returns an unknown key verbatim, so the + // label doubles as the wrapper's accessible name -- + // a plugin has no locale bundle to key into. + ariaLabelKey: decl.label, + role: undefined, + source: decl.source === 'file' ? { kind: 'file', pathKey: 'mirrorPath' } : decl.source === 'url' ? { kind: 'url', urlKey: 'url' } : { kind: 'board-local' }, + editRoute: { kind: decl.editRoute }, + }, + sticky: false, + gesture: null, + commit: () => { + throw new Error('third-party placement goes through useAtlasCreation’s generic branch, never commit()') + }, + }) + }, + registerCommand: (decl) => { + collectPluginCommand({ id: `plugin.${pluginId}.${decl.id}`, label: decl.label, run: decl.run }) + }, + requestGuardedAction, + }) +} diff --git a/frontend/src/plugins/loadGate.ts b/frontend/src/plugins/loadGate.ts new file mode 100644 index 000000000..8f557ee00 --- /dev/null +++ b/frontend/src/plugins/loadGate.ts @@ -0,0 +1,20 @@ +// The boot-order tripwire (docs/goals/0249): src/atlas/atlasTools.ts +// SNAPSHOTS the tool registry at module eval, which must happen after +// plugin activation in the main window. This module is import-free so +// both sides can reach it without joining each other's graphs. +let settled = false + +export function markPluginsSettled(): void { + settled = true +} + +// warnIfSnapshotBeforePlugins -- called by atlasTools.ts at its own +// eval. Only the MAIN window carries the invariant (aux windows and +// unit tests import the snapshot without ever loading plugins). +export function warnIfSnapshotBeforePlugins(): void { + if (typeof window === 'undefined') return + if (window.location.hash !== '') return + if (!settled) { + console.warn('atlasTools evaluated before plugin loading settled: runtime plugins will be missing from the tool list until the app reloads') + } +} diff --git a/frontend/src/plugins/loader.ts b/frontend/src/plugins/loader.ts new file mode 100644 index 000000000..b771665da --- /dev/null +++ b/frontend/src/plugins/loader.ts @@ -0,0 +1,93 @@ +import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc' +import type { PluginInfo } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' +import { SettingsService } from '../shared/bindings' +import { buildPluginAPI } from './hostApi' +import type { MillPluginAPI, PluginModule } from './sdk' + +// The runtime plugin loader (docs/goals/0249). Runs BEFORE the app +// module graph evaluates (main.tsx awaits it and only then +// dynamic-imports App), so every module-eval snapshot downstream -- +// the tool list, the palette command table, the Extensions rows -- +// already contains plugin registrations, with no late-registration +// machinery anywhere. The cost of that simplicity is honest: a plugin +// installed while Mill is running needs an app reload (the Extensions +// section offers one), the same load-at-start contract the surveyed +// desktop plugin platforms converge on. +// +// IMPORT DISCIPLINE (load-bearing): nothing imported here, directly or +// transitively, may evaluate src/atlas/atlasTools.ts -- that module +// SNAPSHOTS the tool registry at eval, which must happen after +// activation. hostApi -> atlasNounRegistry stays below that line; +// plugin commands go through plugins/pluginCommands.ts's collector for +// the same reason. + +export type PluginLoadStatus = 'loaded' | 'disabled' | 'error' + +export interface PluginLoadState { + status: PluginLoadStatus + error?: string + info: PluginInfo +} + +const loadStates = new Map() + +// pluginLoadStates -- the Extensions section's join source: every +// scanned plugin folder with what actually happened to it this boot. +export function pluginLoadStates(): Map { + return loadStates +} + +function resolveActivate(mod: PluginModule): ((api: MillPluginAPI) => void | Promise) | null { + if (typeof mod.activate === 'function') return mod.activate + if (typeof mod.default === 'function') return mod.default + if (mod.default && typeof mod.default.activate === 'function') return mod.default.activate.bind(mod.default) + return null +} + +// loadPlugins scans, filters to enabled+valid, and activates each +// plugin's main.js. Every failure is PER-PLUGIN -- recorded on its own +// row, never thrown upward -- and the whole pass is raced against a +// deadline in main.tsx so a hung import can never brick the boot. +export async function loadPlugins(): Promise { + let millVersion = '' + try { + millVersion = await SettingsService.AppVersion() + } catch { + // Version is informational to a plugin; loading proceeds. + } + let plugins: PluginInfo[] + try { + plugins = (await PluginService.ListPlugins()) ?? [] + } catch (err) { + console.error('plugin scan failed', err) + return + } + let disabled: string[] = [] + try { + disabled = (await SettingsService.GetDisabledExtensions()) ?? [] + } catch { + // An unreadable disabled set loads everything -- matching how + // built-in extensions already behave when the same read fails. + } + for (const info of plugins) { + const id = info.Manifest.id + if (info.Error) { + loadStates.set(id, { status: 'error', error: info.Error, info }) + continue + } + if (disabled.includes(id)) { + loadStates.set(id, { status: 'disabled', info }) + continue + } + try { + const url = `/plugins/${id}/main.js?v=${encodeURIComponent(info.Manifest.version)}` + const mod = (await import(/* @vite-ignore */ url)) as PluginModule + const activate = resolveActivate(mod) + if (!activate) throw new Error('main.js exports no activate() function') + await Promise.resolve(activate(buildPluginAPI(info.Manifest, millVersion))) + loadStates.set(id, { status: 'loaded', info }) + } catch (err) { + loadStates.set(id, { status: 'error', error: err instanceof Error ? err.message : String(err), info }) + } + } +} diff --git a/frontend/src/plugins/pluginCommands.ts b/frontend/src/plugins/pluginCommands.ts new file mode 100644 index 000000000..8ff1a3494 --- /dev/null +++ b/frontend/src/plugins/pluginCommands.ts @@ -0,0 +1,25 @@ +// Plugin-contributed palette commands, collected here during plugin +// activation (which runs BEFORE the app module graph evaluates -- the +// loader's own boot-order contract, main.tsx) and drained by +// shared/commands.ts at ITS module eval. This indirection exists so +// the loader never transitively imports the command module (whose own +// imports evaluate the ATLAS_TOOLS snapshot -- pulling that forward +// would freeze the tool list before any plugin had registered). +export interface RuntimeCommandDecl { + id: string + label: string + run: () => void +} + +const collected: RuntimeCommandDecl[] = [] + +export function collectPluginCommand(decl: RuntimeCommandDecl): void { + if (collected.some((c) => c.id === decl.id)) { + throw new Error(`plugin command "${decl.id}" is already registered`) + } + collected.push(decl) +} + +export function drainedPluginCommands(): RuntimeCommandDecl[] { + return [...collected] +} diff --git a/frontend/src/plugins/sdk.ts b/frontend/src/plugins/sdk.ts new file mode 100644 index 000000000..32c57d8a7 --- /dev/null +++ b/frontend/src/plugins/sdk.ts @@ -0,0 +1,87 @@ +// The plugin-facing surface (docs/goals/0249, docs/adr/0047): every +// type an out-of-tree plugin's own code sees. This module is the +// boundary the dependency-cruiser kernel-import rule guards -- it may +// import NOTHING from the kernel (no bindings, no services, no atlas +// internals), because its contents describe what a plugin receives, +// and a plugin receives capabilities only through the api object +// handed to activate(), never through an import. + +// ObjectSource/EditRoute restated here as plain strings rather than +// imported from atlas/objectSeams.ts: the SDK's compile-time +// independence from the kernel is the point of this file, and the +// host's registration path (hostApi.ts) narrows/validates them against +// the kernel's own unions at registration time. +export interface CanvasObjectDecl { + // kind is the persisted BoardObject.Kind and the tray/palette id -- + // lowercase slug, must be unique against built-ins and other + // plugins. + kind: string + // label/description are user-facing (tray tooltip, the Extensions + // row). + label: string + description?: string + // icon is one emoji -- rendered in the tray and the palette. + icon: string + // Where the object's artifact lives (ADR-0046 vocabulary): + // 'board-local' | 'url' | 'file'. + source: 'board-local' | 'url' | 'file' + // Which door edits it: 'inline' (the face itself is the editor) | + // 'external-app' | 'none'. + editRoute: 'inline' | 'external-app' | 'none' + // Payload a fresh placement starts with. + defaultPayload?: Record + // renderFace draws the object's board face into el (a host-owned + // div, already sized to the object's box). Called on mount and again + // whenever the object's data changes -- el's contents are the + // plugin's own to manage between calls (checking ctx.object for + // what changed). Framework-agnostic on purpose: plain DOM, no + // renderer library coupling, no build step required of a plugin. + renderFace: (el: HTMLElement, ctx: CanvasObjectFaceCtx) => void +} + +export interface CanvasObjectFaceCtx { + object: { + ID: string + Kind: string + Payload: Record + } + // updatePayload merges patch into this object's payload through the + // host (an empty string deletes a key). The write persists, syncs, + // and participates in undo like any built-in edit. + updatePayload: (patch: Record) => Promise + // requestGuardedAction asks Mill to perform an action the plugin + // cannot perform itself. The action kind must be declared in the + // plugin's manifest capabilities; each use is evaluated by the + // owner's guardrail rules and may require live approval. + requestGuardedAction: (kind: string, attributes: Record, description: string) => Promise +} + +export interface GuardedActionResult { + approved: boolean + effect: string + ruleLabel: string + performed: boolean +} + +export interface PluginCommandDecl { + id: string + label: string + run: () => void +} + +// MillPluginAPI is the one object a plugin ever holds -- handed to its +// exported activate(api), frozen by the host. +export interface MillPluginAPI { + millVersion: string + pluginId: string + registerCanvasObject: (decl: CanvasObjectDecl) => void + registerCommand: (decl: PluginCommandDecl) => void + requestGuardedAction: (kind: string, attributes: Record, description: string) => Promise +} + +// A plugin's main.js default-exports (or named-exports) activate: +// export function activate(api) { api.registerCanvasObject({...}) } +export interface PluginModule { + activate?: (api: MillPluginAPI) => void | Promise + default?: { activate?: (api: MillPluginAPI) => void | Promise } | ((api: MillPluginAPI) => void | Promise) +} diff --git a/frontend/src/shared/commands.ts b/frontend/src/shared/commands.ts index e4eec9752..8585551ec 100644 --- a/frontend/src/shared/commands.ts +++ b/frontend/src/shared/commands.ts @@ -3,6 +3,7 @@ import { comboFromEvent, comboKey } from './keybinding' import { useAppStore } from './store' import type { View } from './store' import { useUISignalStore } from './uiSignalStore' +import { drainedPluginCommands } from '../plugins/pluginCommands' import { CONFIGURE_CREATE_COMMANDS } from './configureCreateCommands' import { ATLAS_BOARD_COMMANDS } from './atlasBoardCommands' import { SETTINGS_COMMANDS } from './settingsCommands' @@ -400,6 +401,13 @@ export const COMMANDS: Command[] = [ ...CODING_LOOP_COMMANDS, // docs.search -- split out to shared/docsSearchCommands.ts. ...DOCS_SEARCH_COMMANDS, + // Runtime plugin commands (docs/goals/0249): drained from the + // plugins/pluginCommands.ts collector, which activation filled + // BEFORE this module evaluated (main.tsx's boot order). Never + // default-bound -- a plugin command is palette-reachable; a + // keybinding for third-party code is assigned in Settings, never + // shipped by the plugin. + ...drainedPluginCommands().map((c) => ({ id: c.id, label: c.label, defaultBinding: null, run: c.run })), ] export function findCommand(id: string): Command | undefined { diff --git a/frontend/src/views/extensionMeta.ts b/frontend/src/views/extensionMeta.ts index d30b65601..1f2b16bf7 100644 --- a/frontend/src/views/extensionMeta.ts +++ b/frontend/src/views/extensionMeta.ts @@ -87,6 +87,7 @@ export function sourceLabel(source: ObjectSource | undefined): string | null { switch (source.kind) { case 'board-local': return 'Stored on the board' case 'file': return 'Backed by a file' + case 'url': return 'Points at a web address' case 'provider': return 'Live view of a List' } } diff --git a/internal/services/atlassvc/atlasboardobject.go b/internal/services/atlassvc/atlasboardobject.go index 7a29c969a..d6f6290d5 100644 --- a/internal/services/atlassvc/atlasboardobject.go +++ b/internal/services/atlassvc/atlasboardobject.go @@ -92,6 +92,68 @@ func (a *AtlasService) SetBoardObjectPosition(id string, pos atlas.Position) (at return o, nil } +// SetBoardObjectPayload merges patch into a board object's Payload -- +// the content-plane write door for a payload-carrying object whose +// data changes after placement (docs/goals/0249: a plugin object's own +// fields, written host-mediated so plugin code never touches a +// binding). A key with an empty value deletes that key; every other +// key overwrites. mirrorPath changes re-arm the file watch the same +// way creation does. +// +//nolint:dupl // same lock/mutate/persist/emit/recordScalar shape as SetBoardObjectPosition -- see its own dupl note +func (a *AtlasService) SetBoardObjectPayload(id string, patch map[string]string) (atlas.BoardObject, error) { + a.mu.Lock() + idx := a.findObjectLocked(id) + if idx == -1 { + a.mu.Unlock() + return atlas.BoardObject{}, fmt.Errorf("no board object with id %q", id) + } + previous := a.objects[idx] + o := previous + o.Payload = copyPayload(previous.Payload) + for k, v := range patch { + if v == "" { + delete(o.Payload, k) + continue + } + o.Payload[k] = v + } + o.UpdatedAt = time.Now() + a.objects[idx] = o + perr := a.persistLocked() + if perr != nil { + a.objects[idx] = previous + } + a.mu.Unlock() + if perr != nil { + return atlas.BoardObject{}, fmt.Errorf("save board object payload: %w", perr) + } + dataevent.Emit("atlas", o.ID) + a.armMirrorWatch(o.ID, o.Payload["mirrorPath"]) + recordScalar(a, actorUI, "object", id, o.Kind, + func(a *AtlasService, prev map[string]string) error { + a.mu.Lock() + if i := a.findObjectLocked(id); i != -1 { + restored := a.objects[i] + restored.Payload = copyPayload(prev) + restored.UpdatedAt = time.Now() + a.objects[i] = restored + if err := a.persistLocked(); err != nil { + a.mu.Unlock() + return err + } + a.mu.Unlock() + dataevent.Emit("atlas", id) + return nil + } + a.mu.Unlock() + return fmt.Errorf("no board object with id %q", id) + }, + previous.Payload, o.Payload, + ) + return o, nil +} + // SetBoardObjectSize persists a user-driven resize -- nil until the // object's own natural/intrinsic render size is first overridden (S2+; // S1 never calls this, but the door exists so a future resize handle diff --git a/internal/services/atlassvc/atlasboardobject_test.go b/internal/services/atlassvc/atlasboardobject_test.go index da878ac4e..d29de0cd8 100644 --- a/internal/services/atlassvc/atlasboardobject_test.go +++ b/internal/services/atlassvc/atlasboardobject_test.go @@ -280,3 +280,40 @@ func TestPromoteBoardObject_TableCarriesProjectionListID(t *testing.T) { t.Errorf("PromoteBoardObject's card MirrorPath = %q, want empty for a table object", card.MirrorPath) } } + +func TestSetBoardObjectPayload_MergesDeletesAndUndoes(t *testing.T) { + a := newTestAtlasService(t) + o, err := a.CreateBoardObject("bookmark", map[string]string{"url": "https://old.example", "title": "Old"}, atlas.Position{}, "") + if err != nil { + t.Fatalf("CreateBoardObject: %v", err) + } + updated, err := a.SetBoardObjectPayload(o.ID, map[string]string{"url": "https://new.example", "title": ""}) + if err != nil { + t.Fatalf("SetBoardObjectPayload: %v", err) + } + if updated.Payload["url"] != "https://new.example" { + t.Errorf("url = %q, want the patched value", updated.Payload["url"]) + } + if _, still := updated.Payload["title"]; still { + t.Error("an empty patch value must delete the key") + } + // Undo restores the FULL previous payload -- the deleted key + // returns and the patched key reverts (replacement, not re-patch). + if res := a.Undo(); !res.Applied || res.Skipped { + t.Fatalf("Undo did not apply cleanly: %+v", res) + } + for _, got := range a.Objects() { + if got.ID == o.ID { + if got.Payload["url"] != "https://old.example" || got.Payload["title"] != "Old" { + t.Errorf("undone payload = %#v, want the original", got.Payload) + } + } + } +} + +func TestSetBoardObjectPayload_UnknownIDErrors(t *testing.T) { + a := newTestAtlasService(t) + if _, err := a.SetBoardObjectPayload("nope", map[string]string{"k": "v"}); err == nil { + t.Fatal("want an error for an unknown id") + } +} diff --git a/internal/services/atlassvc/atlasundo_doors.go b/internal/services/atlassvc/atlasundo_doors.go index 2c1f629c6..53339ba8f 100644 --- a/internal/services/atlassvc/atlasundo_doors.go +++ b/internal/services/atlassvc/atlasundo_doors.go @@ -36,6 +36,7 @@ var journaledDoors = map[string]string{ "PromoteBoardObject": "promote family (demote/repromote, atlasundo_promote.go)", "PromoteNote": "promote family (demote/repromote, atlasundo_promote.go)", "SetBoardObjectPosition": "scalar family", + "SetBoardObjectPayload": "scalar family", "SetBoardObjectRotation": "scalar family", "SetBoardObjectSize": "scalar family", "SetCardSize": "scalar family", diff --git a/internal/services/pluginsvc/pluginservice.go b/internal/services/pluginsvc/pluginservice.go new file mode 100644 index 000000000..2777d9e5b --- /dev/null +++ b/internal/services/pluginsvc/pluginservice.go @@ -0,0 +1,239 @@ +// Package pluginsvc is the out-of-tree plugin platform's backend +// (docs/goals/0249, un-gating docs/adr/0047 §4's loader): it scans the +// plugins directory for manifests, serves each plugin's own files to +// the webview, and carries the capability model's enforcement seam -- +// a plugin never holds a dangerous primitive; it requests a guarded +// action here, the manifest's declared capability set is checked +// first (declare-in-manifest), and the guardrail rule core evaluates +// the actual use (evaluate-per-action, docs/adr/0047 §2/§3). +package pluginsvc + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/alicoding/mill/internal/adapters/windowing" + "github.com/alicoding/mill/internal/services/guardrailsvc" +) + +// Manifest is the converged plugin manifest shape (docs/adr/0047 §1: +// identity metadata + a declared capability set; contributions happen +// at activate() time through the host API, so they are not restated +// here). +type Manifest struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + MinMillVersion string `json:"minMillVersion"` + Capabilities []string `json:"capabilities"` +} + +// PluginInfo is one scanned plugin as the Extensions surface and the +// loader see it. Error is a load-blocking validation problem stated +// for the human (the row renders it; the loader skips the plugin) -- +// a plugin is either fully valid or visibly broken, never silently +// half-loaded. +type PluginInfo struct { + Manifest Manifest + Dir string + Error string +} + +// knownCapabilities is the enumerated capability vocabulary +// (docs/adr/0047 §2: enumerated, never free-text). It grows per real +// plugin request, never speculatively -- docs/goals/0249 carries the +// revisit trigger. +var knownCapabilities = map[string]bool{ + // open-url: ask Mill to open an http(s) URL in the default + // browser. The plugin never receives the primitive; on approval + // Mill itself performs the open. + "open-url": true, +} + +// pluginIDPattern pins ids to a filesystem- and URL-safe slug: the id +// doubles as the plugin's folder name and its asset-route segment, so +// anything outside this set would be a traversal or encoding hazard, +// not a style choice. +var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) + +// PluginService is Wails-bound. openURL is injected so tests never +// shell out to the real OS handler. +type PluginService struct { + dir string + guardrail *guardrailsvc.GuardrailService + openURL func(url string) error +} + +func New(dir string, guardrail *guardrailsvc.GuardrailService) *PluginService { + return &PluginService{dir: dir, guardrail: guardrail, openURL: windowing.OpenURL} +} + +// PluginsDir returns the directory plugins are installed into -- +// the Extensions page's install story shows and reveals it. The +// directory is created on first ask so "open the folder" never lands +// on a missing path. +func (p *PluginService) PluginsDir() (string, error) { + if err := os.MkdirAll(p.dir, 0o750); err != nil { + return "", fmt.Errorf("create plugins directory: %w", err) + } + return p.dir, nil +} + +// RevealPluginsDir opens the plugins directory in the OS file manager. +func (p *PluginService) RevealPluginsDir() error { + dir, err := p.PluginsDir() + if err != nil { + return err + } + return p.openInOS("file://" + dir) +} + +func (p *PluginService) openInOS(url string) error { + if p.openURL == nil { + return fmt.Errorf("no URL opener available in this mode") + } + return p.openURL(url) +} + +// ListPlugins scans the plugins directory fresh on every call (the +// Extensions page's Rescan is just another call) and returns every +// plugin folder with its manifest -- valid ones ready to load, +// invalid ones carrying their human-readable Error. +func (p *PluginService) ListPlugins() ([]PluginInfo, error) { + entries, err := os.ReadDir(p.dir) + if os.IsNotExist(err) { + return []PluginInfo{}, nil + } + if err != nil { + return nil, fmt.Errorf("read plugins directory: %w", err) + } + infos := make([]PluginInfo, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + infos = append(infos, p.scanOne(e.Name())) + } + sort.Slice(infos, func(i, j int) bool { return infos[i].Manifest.ID < infos[j].Manifest.ID }) + return infos, nil +} + +func (p *PluginService) scanOne(folder string) PluginInfo { + dir := filepath.Join(p.dir, folder) + info := PluginInfo{Dir: dir, Manifest: Manifest{ID: folder}} + raw, err := os.ReadFile(filepath.Join(dir, "manifest.json")) // #nosec G304 G703 -- dir is this service's own plugins root joined with a ReadDir entry name + if err != nil { + info.Error = "manifest.json is missing or unreadable" + return info + } + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + info.Error = "manifest.json is not valid JSON" + return info + } + info.Manifest = m + switch { + case !pluginIDPattern.MatchString(m.ID): + info.Error = "the manifest id must be lowercase letters, digits, and hyphens" + case m.ID != folder: + // The Obsidian convention, adopted deliberately: the folder IS + // the identity, so a copied folder can never impersonate a + // different plugin's id. + info.Error = fmt.Sprintf("the manifest id %q must match the folder name %q", m.ID, folder) + case strings.TrimSpace(m.Name) == "" || strings.TrimSpace(m.Version) == "": + info.Error = "the manifest needs a name and a version" + default: + if _, err := os.Stat(filepath.Join(dir, "main.js")); err != nil { // #nosec G703 -- folder passed pluginIDPattern (no separators, no dots) + info.Error = "main.js is missing" + } + } + if info.Error == "" { + for _, c := range m.Capabilities { + if !knownCapabilities[c] { + // Fail-closed: an unknown capability blocks the LOAD, + // never silently narrows to the known set -- the user + // sees exactly why the plugin won't run. + info.Error = fmt.Sprintf("unknown capability %q", c) + break + } + } + } + return info +} + +// GuardedActionDecision is RequestGuardedAction's wire shape. +type GuardedActionDecision struct { + Approved bool + Effect string + RuleLabel string + // Performed is true when Mill executed the approved action itself + // (the plugin never receives the primitive). + Performed bool +} + +// RequestGuardedAction is the plugin plane's one door to a primitive +// the plugin does not hold (docs/adr/0047 §2). The manifest must +// DECLARE the capability (an undeclared kind is refused here, before +// any rule runs); a declared one is evaluated per-action by the +// guardrail rule core -- allow/deny resolve immediately, ask parks for +// a human and blocks this call until resolved (the same park the MCP +// write plane uses). On approval Mill performs the action itself. +func (p *PluginService) RequestGuardedAction(pluginID string, kind string, attributes map[string]string, description string) (GuardedActionDecision, error) { + plugin := p.scanOne(pluginID) + if plugin.Error != "" { + return GuardedActionDecision{}, fmt.Errorf("plugin %q: %s", pluginID, plugin.Error) + } + declared := false + for _, c := range plugin.Manifest.Capabilities { + if c == kind { + declared = true + break + } + } + if !declared { + return GuardedActionDecision{}, fmt.Errorf("plugin %q does not declare the %q capability in its manifest", pluginID, kind) + } + decision, err := p.guardrail.RequestGuardedAction(context.Background(), guardrailsvc.GuardedAction{ + Kind: kind, + Attributes: attributes, + Description: description, + Source: "plugin:" + pluginID, + }) + if err != nil { + return GuardedActionDecision{}, err + } + out := GuardedActionDecision{Approved: decision.Approved, Effect: string(decision.Effect), RuleLabel: decision.RuleLabel} + if decision.Approved { + performed, perr := p.perform(kind, attributes) + if perr != nil { + return out, perr + } + out.Performed = performed + } + return out, nil +} + +// perform executes an approved action on the plugin's behalf. Each +// capability's execution lives here, next to its vocabulary entry -- +// the plugin's request never contained the primitive, only the ask. +func (p *PluginService) perform(kind string, attributes map[string]string) (bool, error) { + if kind == "open-url" { + u := attributes["url"] + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return false, fmt.Errorf("open-url only opens http(s) URLs") + } + if err := p.openInOS(u); err != nil { + return false, fmt.Errorf("open URL: %w", err) + } + return true, nil + } + return false, nil +} diff --git a/internal/services/pluginsvc/pluginservice_assets.go b/internal/services/pluginsvc/pluginservice_assets.go new file mode 100644 index 000000000..6f6b7ac5e --- /dev/null +++ b/internal/services/pluginsvc/pluginservice_assets.go @@ -0,0 +1,74 @@ +package pluginsvc + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// assetExtensions is the allowlist of file types a plugin folder may +// serve to the webview. Everything else 404s -- the plugins directory +// sits in user data, and this route must never become a generic file +// server over it. +var assetExtensions = map[string]string{ + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", +} + +// AssetMiddleware serves GET /plugins// from the scanned +// plugins directory and passes every other request through. +func (p *PluginService) AssetMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rest, isPluginPath := strings.CutPrefix(r.URL.Path, "/plugins/") + if !isPluginPath || r.Method != http.MethodGet { + next.ServeHTTP(w, r) + return + } + full, contentType, ok := p.resolveAsset(rest) + if !ok { + http.NotFound(w, r) + return + } + data, err := os.ReadFile(full) // #nosec G304 G703 -- full is Rel-verified inside the id's own validated plugin folder (resolveAsset) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", contentType) + // The loader appends the plugin version as a query param, so + // a reinstall busts any intermediary cache naturally. + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(data) // #nosec G705 -- served under the allowlisted Content-Type set above, from the user's own plugins directory + }) + } +} + +// resolveAsset validates / and returns the on-disk path and +// content type. Only ids that scan to a VALID manifest serve at all (a +// broken plugin is visible in Extensions, never half-loaded via a +// dangling script URL), only allowlisted extensions serve, and the +// resolved path must stay inside the plugin's own folder +// (filepath.Rel guards traversal after cleaning). +func (p *PluginService) resolveAsset(rest string) (full, contentType string, ok bool) { + id, file, hasFile := strings.Cut(rest, "/") + if !hasFile || file == "" || !pluginIDPattern.MatchString(id) { + return "", "", false + } + if info := p.scanOne(id); info.Error != "" { + return "", "", false + } + contentType, allowed := assetExtensions[strings.ToLower(filepath.Ext(file))] + if !allowed { + return "", "", false + } + pluginDir := filepath.Join(p.dir, id) + full = filepath.Join(pluginDir, filepath.FromSlash(file)) + rel, err := filepath.Rel(pluginDir, full) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", "", false + } + return full, contentType, true +} diff --git a/internal/services/pluginsvc/pluginservice_test.go b/internal/services/pluginsvc/pluginservice_test.go new file mode 100644 index 000000000..20e1b880d --- /dev/null +++ b/internal/services/pluginsvc/pluginservice_test.go @@ -0,0 +1,134 @@ +package pluginsvc + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func writePlugin(t *testing.T, root, id, manifest string, extra map[string]string) { + t.Helper() + dir := filepath.Join(root, id) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + if manifest != "" { + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + } + files := map[string]string{"main.js": "export function activate() {}"} + for k, v := range extra { + files[k] = v + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } +} + +func TestListPlugins_ValidAndInvalidRows(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "good-one", `{"id":"good-one","name":"Good","version":"1.0.0","capabilities":["open-url"]}`, nil) + writePlugin(t, root, "bad-json", `{not json`, nil) + writePlugin(t, root, "wrong-id", `{"id":"other","name":"X","version":"1"}`, nil) + writePlugin(t, root, "bad-cap", `{"id":"bad-cap","name":"X","version":"1","capabilities":["format-disk"]}`, nil) + + svc := New(root, nil) + infos, err := svc.ListPlugins() + if err != nil { + t.Fatal(err) + } + byID := map[string]PluginInfo{} + for _, i := range infos { + byID[filepath.Base(i.Dir)] = i + } + if got := byID["good-one"]; got.Error != "" { + t.Fatalf("good-one should be valid, got error %q", got.Error) + } + if got := byID["bad-json"]; got.Error == "" { + t.Fatal("bad-json should carry a validation error") + } + if got := byID["wrong-id"]; !strings.Contains(got.Error, "must match the folder") { + t.Fatalf("wrong-id error = %q", got.Error) + } + if got := byID["bad-cap"]; !strings.Contains(got.Error, "unknown capability") { + t.Fatalf("bad-cap error = %q", got.Error) + } +} + +func TestListPlugins_MissingDirIsEmptyNotError(t *testing.T) { + svc := New(filepath.Join(t.TempDir(), "never-created"), nil) + infos, err := svc.ListPlugins() + if err != nil || len(infos) != 0 { + t.Fatalf("want empty, no error; got %d infos, err %v", len(infos), err) + } +} + +func serveThrough(t *testing.T, svc *PluginService, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + handler := svc.AssetMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) // marks "fell through to the app" + })) + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)) + return rec +} + +func TestAssetMiddleware_ServesOnlyValidPluginAllowlistedFiles(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "good-one", `{"id":"good-one","name":"G","version":"1"}`, map[string]string{ + "styles.css": "body{}", + "secret.txt": "nope", + }) + writePlugin(t, root, "broken", `{not json`, nil) + svc := New(root, nil) + + if rec := serveThrough(t, svc, "/plugins/good-one/main.js"); rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") { + t.Fatalf("main.js: code %d type %q", rec.Code, rec.Header().Get("Content-Type")) + } + if rec := serveThrough(t, svc, "/plugins/good-one/styles.css"); rec.Code != http.StatusOK { + t.Fatalf("styles.css: code %d", rec.Code) + } + if rec := serveThrough(t, svc, "/plugins/good-one/secret.txt"); rec.Code != http.StatusNotFound { + t.Fatalf("non-allowlisted extension must 404, got %d", rec.Code) + } + if rec := serveThrough(t, svc, "/plugins/broken/main.js"); rec.Code != http.StatusNotFound { + t.Fatalf("an invalid plugin must never serve, got %d", rec.Code) + } + if rec := serveThrough(t, svc, "/plugins/good-one/../../settings.json"); rec.Code != http.StatusNotFound { + t.Fatalf("traversal must 404, got %d", rec.Code) + } + if rec := serveThrough(t, svc, "/anything-else"); rec.Code != http.StatusTeapot { + t.Fatalf("non-plugin paths must fall through, got %d", rec.Code) + } +} + +func TestRequestGuardedAction_UndeclaredCapabilityRefusedBeforeRules(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "quiet-one", `{"id":"quiet-one","name":"Q","version":"1","capabilities":[]}`, nil) + // guardrail nil: proves the refusal happens BEFORE any rule + // evaluation could run (a nil-deref here would fail the test). + svc := New(root, nil) + _, err := svc.RequestGuardedAction("quiet-one", "open-url", map[string]string{"url": "https://example.com"}, "test") + if err == nil || !strings.Contains(err.Error(), "does not declare") { + t.Fatalf("want undeclared-capability refusal, got %v", err) + } +} + +func TestPerform_OpenURLRejectsNonHTTP(t *testing.T) { + svc := New(t.TempDir(), nil) + var opened string + svc.openURL = func(u string) error { opened = u; return nil } + if _, err := svc.perform("open-url", map[string]string{"url": "file:///etc/passwd"}); err == nil { + t.Fatal("file: scheme must be rejected") + } + ok, err := svc.perform("open-url", map[string]string{"url": "https://example.com"}) + if err != nil || !ok || opened != "https://example.com" { + t.Fatalf("https open failed: ok=%v err=%v opened=%q", ok, err, opened) + } +} diff --git a/internal/services/wiring/plugins.go b/internal/services/wiring/plugins.go new file mode 100644 index 000000000..ddc1e7648 --- /dev/null +++ b/internal/services/wiring/plugins.go @@ -0,0 +1,34 @@ +package wiring + +import ( + "net/http" + "os" + "path/filepath" + + "github.com/alicoding/mill/internal/services/guardrailsvc" + "github.com/alicoding/mill/internal/services/pluginsvc" + "github.com/alicoding/mill/internal/services/remoteauthsvc" +) + +// NewPluginService resolves the plugins directory and constructs the +// service (docs/goals/0249): plugins live beside the settings file +// (/plugins//), so MILL_SETTINGS_PATH isolation covers +// plugins for free; MILL_PLUGINS_DIR overrides independently for +// fixture-driven tests. +func NewPluginService(settingsPath string, guardrail *guardrailsvc.GuardrailService) *pluginsvc.PluginService { + dir := os.Getenv("MILL_PLUGINS_DIR") + if dir == "" { + dir = filepath.Join(filepath.Dir(settingsPath), "plugins") + } + return pluginsvc.New(dir, guardrail) +} + +// ComposedAssetMiddleware chains the remote-auth gate (server builds +// only -- AssetMiddleware's own doc) around the plugin asset route +// (both build modes: the desktop webview loads /plugins//main.js +// too), which falls through to the embedded bundle. +func ComposedAssetMiddleware(remoteAuth *remoteauthsvc.RemoteAuthService, plugins *pluginsvc.PluginService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return AssetMiddleware(remoteAuth)(plugins.AssetMiddleware()(next)) + } +} diff --git a/main.go b/main.go index 3008ac31d..9b8ac4860 100644 --- a/main.go +++ b/main.go @@ -189,6 +189,7 @@ func main() { logger.Error("migrate legacy MCP pending writes", "error", err) } guardrailService := guardrailsvc.NewGuardrailService(settingsStore, compositionService) + pluginService := wiring.NewPluginService(settingsPath, guardrailService) // docs/goals/0240 S1: the coding loop's Confirm-screen preview -- // read-only over guardrailService.Rules(). Its ExecutionService // dependency (goal 0240 S2, RunCommandBlock's own doc comment) is @@ -320,6 +321,7 @@ func main() { application.NewService(companionService), application.NewService(agentLoopService), application.NewService(guardrailService), + application.NewService(pluginService), application.NewService(clipboardHistoryService), application.NewService(codeLoopService), application.NewService(executionService), @@ -332,8 +334,9 @@ func main() { }, Assets: application.AssetOptions{ Handler: application.AssetFileServerFS(assets), - // Armed only for a server build (wiring.AssetMiddleware). - Middleware: wiring.AssetMiddleware(remoteAuthService), + // Auth gate around the plugin asset route around the embedded + // bundle -- wiring.ComposedAssetMiddleware's own doc. + Middleware: wiring.ComposedAssetMiddleware(remoteAuthService, pluginService), }, // Mill's macOS archetype and its termination contract live in the // windowing adapter, where they are documented and pinned by a test From ddcbc87fe4c87bf64c6c2a269a26ff87dafb7af3 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Sat, 29 Aug 2026 02:39:20 -0400 Subject: [PATCH 2/6] =?UTF-8?q?feat(0249=20S3):=20mill-bookmark=20?= =?UTF-8?q?=E2=80=94=20the=20real=20out-of-tree=20plugin,=20e2e-proven=20e?= =?UTF-8?q?nd=20to=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- .ls-lint.yml | 6 + examples/plugins/mill-bookmark/main.js | 88 ++++++++++++ examples/plugins/mill-bookmark/manifest.json | 9 ++ frontend/e2e/fixtures/serverPorts.ts | 8 ++ frontend/e2e/runtime-plugins.spec.ts | 134 ++++++++++++++++++ frontend/src/atlas/AtlasCreationTray.tsx | 16 +-- frontend/src/locales/en/views.json | 7 +- frontend/src/views/ReviewGuardedActions.tsx | 70 +++++++++ frontend/src/views/ReviewView.tsx | 8 +- .../guardrailsvc/guardrailservice_request.go | 20 +++ 10 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 examples/plugins/mill-bookmark/main.js create mode 100644 examples/plugins/mill-bookmark/manifest.json create mode 100644 frontend/e2e/runtime-plugins.spec.ts create mode 100644 frontend/src/views/ReviewGuardedActions.tsx diff --git a/.ls-lint.yml b/.ls-lint.yml index 4ff4fcd3a..bccea45e4 100644 --- a/.ls-lint.yml +++ b/.ls-lint.yml @@ -90,6 +90,12 @@ ignore: # its own right (a stray leftover here is a review miss, not a # shipped state). - docs-drafts + # examples/ (goal 0249, ADR-0001 extension): shippable example + # content a user copies OUT of the repo -- the first resident is + # examples/plugins/mill-bookmark, the runtime-plugin platform's own + # reference plugin (copy the folder into the app's plugins directory + # and reload). Plain web-file naming inside, not the root rule. + - examples - build - frontend - bin diff --git a/examples/plugins/mill-bookmark/main.js b/examples/plugins/mill-bookmark/main.js new file mode 100644 index 000000000..1b95da53f --- /dev/null +++ b/examples/plugins/mill-bookmark/main.js @@ -0,0 +1,88 @@ +// Bookmark -- Mill's reference runtime plugin (docs/goals/0249). +// Plain ESM, no build step: copy this folder into the app's plugins +// directory (Settings > Extensions > Open plugins folder) and reload. +// +// It contributes one canvas object on the object contract: a web +// address pinned to the board (source: url). The URL is edited right +// on the face (editRoute: inline); Open never touches the browser +// itself -- it asks Mill for the guarded open-url action, which the +// owner's guardrail rules evaluate per use. + +export function activate(api) { + api.registerCanvasObject({ + kind: 'bookmark', + label: 'Bookmark', + description: 'A web address pinned to the board.', + icon: '🔖', + source: 'url', + editRoute: 'inline', + defaultPayload: { url: '', title: '' }, + renderFace(el, ctx) { + // Rebuild the face from the object's current data. All text + // lands via textContent/value -- never markup -- so a URL can + // never inject anything. + el.replaceChildren() + el.style.cssText = 'display:flex;flex-direction:column;gap:6px;padding:10px 12px;font:12px system-ui;height:100%;box-sizing:border-box' + + const title = document.createElement('div') + title.style.cssText = 'display:flex;align-items:center;gap:6px;font-weight:600' + const glyph = document.createElement('span') + glyph.textContent = '🔖' + const titleText = document.createElement('span') + titleText.textContent = ctx.object.Payload.title || 'Bookmark' + title.append(glyph, titleText) + + const input = document.createElement('input') + input.type = 'text' + input.placeholder = 'https://…' + input.value = ctx.object.Payload.url || '' + input.setAttribute('data-testid', 'bookmark-url-input') + input.style.cssText = 'font:11px ui-monospace,monospace;padding:4px 6px;border:1px solid #d0d7de;border-radius:6px;width:100%;box-sizing:border-box' + // Commit on Enter/blur, not per keystroke -- each payload + // write re-renders this face, which would rebuild the input + // under the caret mid-word. + const commit = () => { + const next = input.value.trim() + if (next === (ctx.object.Payload.url || '')) return + void ctx.updatePayload({ url: next, title: next ? new URL(withScheme(next)).hostname : '' }).catch(() => { + status.textContent = 'Could not save the address.' + }) + } + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit() } + e.stopPropagation() // board shortcuts stay out of typing + }) + input.addEventListener('blur', commit) + + const row = document.createElement('div') + row.style.cssText = 'display:flex;align-items:center;gap:8px' + const open = document.createElement('button') + open.type = 'button' + open.textContent = 'Open' + open.setAttribute('data-testid', 'bookmark-open') + open.style.cssText = 'font:11px system-ui;padding:3px 10px;border:1px solid #d0d7de;border-radius:6px;background:#f6f8fa;cursor:pointer' + const status = document.createElement('span') + status.setAttribute('data-testid', 'bookmark-status') + status.style.cssText = 'font:11px system-ui;color:#57606a' + open.addEventListener('click', async () => { + const url = withScheme((ctx.object.Payload.url || '').trim()) + if (!url) { status.textContent = 'Enter an address first.'; return } + status.textContent = 'Asking…' + try { + const result = await ctx.requestGuardedAction('open-url', { url }, `Open ${url} in the browser`) + status.textContent = result.approved ? 'Opened.' : 'Not allowed' + (result.ruleLabel ? ` (${result.ruleLabel}).` : '.') + } catch (err) { + status.textContent = String(err && err.message ? err.message : err) + } + }) + row.append(open, status) + + el.append(title, input, row) + }, + }) +} + +function withScheme(url) { + if (!url) return url + return /^https?:\/\//.test(url) ? url : 'https://' + url +} diff --git a/examples/plugins/mill-bookmark/manifest.json b/examples/plugins/mill-bookmark/manifest.json new file mode 100644 index 000000000..c6662ed1d --- /dev/null +++ b/examples/plugins/mill-bookmark/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "mill-bookmark", + "name": "Bookmark", + "version": "1.0.0", + "description": "Keeps a web address on the board and opens it in your browser.", + "author": "Mill examples", + "minMillVersion": "0.9.0", + "capabilities": ["open-url"] +} diff --git a/frontend/e2e/fixtures/serverPorts.ts b/frontend/e2e/fixtures/serverPorts.ts index 68ba5b334..7a4536e7e 100644 --- a/frontend/e2e/fixtures/serverPorts.ts +++ b/frontend/e2e/fixtures/serverPorts.ts @@ -258,3 +258,11 @@ export const CLIPBOARD_HISTORY_MCP_BASE_PORT = 11060 // worker pool coding-loop.spec.ts itself runs on must never see. export const CODING_LOOP_SECRETS_SERVER_BASE_PORT = 11080 export const CODING_LOOP_SECRETS_MCP_BASE_PORT = 11100 + +// runtime-plugins.spec.ts's own dedicated pair (goal 0249): its server +// boots with MILL_PLUGINS_DIR pointed at the repo's own +// examples/plugins fixture, a whole-process env the shared pool must +// never inherit, and its Review-queue assertions read the global +// pending-guarded-action list. +export const RUNTIME_PLUGINS_SERVER_BASE_PORT = 11120 +export const RUNTIME_PLUGINS_MCP_BASE_PORT = 11140 diff --git a/frontend/e2e/runtime-plugins.spec.ts b/frontend/e2e/runtime-plugins.spec.ts new file mode 100644 index 000000000..fb83e064c --- /dev/null +++ b/frontend/e2e/runtime-plugins.spec.ts @@ -0,0 +1,134 @@ +import { chromium, expect, test } from '@playwright/test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnMillServer, type SpawnedServer } from './fixtures/server' +import { RUNTIME_PLUGINS_SERVER_BASE_PORT, RUNTIME_PLUGINS_MCP_BASE_PORT } from './fixtures/serverPorts' +import { findEmptyBoardRect } from './fixtures/atlasEmptyRegion' + +// The runtime plugin platform, proven against a REAL out-of-tree +// plugin (docs/goals/0249): the server boots with MILL_PLUGINS_DIR +// pointed at the repo's own examples/plugins -- the exact folder a +// user copies from -- so what this file proves is the shipping +// artifact, not a compiled-in stand-in. Dedicated server per test +// (testing.md's dedicated-spec exception): the plugins env is process- +// wide, and the Review-queue assertions read the global pending list. +const EXAMPLES_PLUGINS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'examples', 'plugins') + +async function launchWithPlugins(offset: number) { + const dir = mkdtempSync(path.join(tmpdir(), 'mill-plugins-e2e-')) + const server: SpawnedServer = await spawnMillServer({ + port: RUNTIME_PLUGINS_SERVER_BASE_PORT + offset, + mcpPort: RUNTIME_PLUGINS_MCP_BASE_PORT + offset, + settingsPath: path.join(dir, 'settings.json'), + executionDbPath: path.join(dir, 'exec.db'), + backupDir: path.join(dir, 'backups'), + extraEnv: { MILL_PLUGINS_DIR: EXAMPLES_PLUGINS_DIR }, + }) + const browser = await chromium.launch() + const context = await browser.newContext({ baseURL: `http://127.0.0.1:${RUNTIME_PLUGINS_SERVER_BASE_PORT + offset}` }) + const page = await context.newPage() + return { + page, + async close() { + await browser.close() + await server.stop() + rmSync(dir, { recursive: true, force: true }) + }, + } +} + +test('a dropped plugin folder yields a working canvas object: tray entry, placement, face render, payload edit, reload persistence', async () => { + const { page, close } = await launchWithPlugins(0) + try { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + // The plugin's tool is IN the tray, by its own declared label. + const bookmarkBtn = page.locator('[data-testid="atlas-creation-tray"] button[aria-label="Bookmark"]') + await expect(bookmarkBtn).toBeVisible() + + // Armed click places a bookmark object; the plugin's own + // renderFace draws the face. + await bookmarkBtn.click() + const spot = await findEmptyBoardRect(page, board, 300, 200) + const bb = await board.boundingBox() + if (!bb) throw new Error('board has no bounding box') + await board.click({ position: { x: spot.x - bb.x + 10, y: spot.y - bb.y + 10 } }) + const face = page.locator('[data-testid="plugin-face-bookmark"]') + await expect(face).toBeVisible() + + // The face's URL field writes through the host's content-plane + // door; the plugin derives the title from the committed value. + await face.locator('[data-testid="bookmark-url-input"]').click() + await page.keyboard.type('example.com/docs') + await page.keyboard.press('Enter') + await expect(face.locator('[data-testid="bookmark-url-input"]')).toHaveValue('example.com/docs') + await expect(face.locator('span').nth(1)).toHaveText('example.com') + + // The object is REAL content-plane data: it survives a reload + // and renders through the plugin again. + await page.reload() + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.locator('[data-testid="plugin-face-bookmark"]')).toBeVisible() + await expect(page.locator('[data-testid="bookmark-url-input"]')).toHaveValue('example.com/docs') + } finally { + await close() + } +}) + +test('a guarded action parks for the human, renders in Review, and the approve/deny answer reaches the plugin', async () => { + const { page, close } = await launchWithPlugins(2) + try { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + await page.locator('[data-testid="atlas-creation-tray"] button[aria-label="Bookmark"]').click() + const spot = await findEmptyBoardRect(page, board, 300, 200) + const bb = await board.boundingBox() + if (!bb) throw new Error('board has no bounding box') + await board.click({ position: { x: spot.x - bb.x + 10, y: spot.y - bb.y + 10 } }) + const face = page.locator('[data-testid="plugin-face-bookmark"]') + await expect(face).toBeVisible() + await face.locator('[data-testid="bookmark-url-input"]').click() + await page.keyboard.type('example.com') + await page.keyboard.press('Enter') + + // Open asks the guardrail; ClassExternal's ask-by-default parks. + await face.locator('[data-testid="bookmark-open"]').click() + await expect(face.locator('[data-testid="bookmark-status"]')).toHaveText('Asking…') + + // The park is visible and actionable in Review -- approved from a + // SECOND tab, so the asking face stays mounted and receives the + // answer (the waiting caller's result lands in the live face; a + // same-tab navigation would unmount it, which is why the + // approval surfaces are separate windows in the real app). + const reviewPage = await page.context().newPage() + await reviewPage.goto('/') + await reviewPage.getByRole('link', { name: 'Review' }).click() + const row = reviewPage.locator('[data-testid="review-guarded-action-item"]') + await expect(row).toBeVisible() + await expect(row.locator('[data-testid="review-guarded-action-source"]')).toContainText('plugin:mill-bookmark') + + // Approve: the blocked plugin call wakes; Mill performs the open + // itself (a documented no-op in server mode -- the decision + // round-trip is what this asserts). + await row.locator('[data-testid="review-guarded-action-approve"]').click() + await expect(row).toHaveCount(0) + await expect(face.locator('[data-testid="bookmark-status"]')).toHaveText('Opened.') + + // Deny round: the answer reaches the plugin as not-allowed. + await face.locator('[data-testid="bookmark-open"]').click() + await expect(row).toBeVisible() + await row.locator('[data-testid="review-guarded-action-deny"]').click() + await expect(row).toHaveCount(0) + await expect(face.locator('[data-testid="bookmark-status"]')).toContainText('Not allowed') + await reviewPage.close() + } finally { + await close() + } +}) diff --git a/frontend/src/atlas/AtlasCreationTray.tsx b/frontend/src/atlas/AtlasCreationTray.tsx index fe72e58c2..445446d52 100644 --- a/frontend/src/atlas/AtlasCreationTray.tsx +++ b/frontend/src/atlas/AtlasCreationTray.tsx @@ -173,8 +173,8 @@ export function AtlasCreationTray({ armedTool, locked, onToggle, tablePickerOpen data-testid={`atlas-tray-${tool.id}`} data-armed={tableArmed} aria-pressed={tableArmed} - title={t(`creationTray.${tool.id}Tooltip`)} - aria-label={t(`creationTray.${tool.id}Label`)} + title={t(`creationTray.${tool.id}Tooltip`, { defaultValue: tool.description ?? tool.label })} + aria-label={t(`creationTray.${tool.id}Label`, { defaultValue: tool.label })} onClick={() => onTableToggle(!tableArmed)} > @@ -222,8 +222,8 @@ export function AtlasCreationTray({ armedTool, locked, onToggle, tablePickerOpen data-armed={dragArmed} data-locked={isLocked} aria-pressed={dragArmed} - title={isLocked ? t('creationTray.lockedTooltip', { tool: t(`creationTray.${tool.id}Label`) }) : t(`creationTray.${tool.id}Tooltip`)} - aria-label={t(`creationTray.${tool.id}Label`)} + title={isLocked ? t('creationTray.lockedTooltip', { tool: t(`creationTray.${tool.id}Label`, { defaultValue: tool.label }) }) : t(`creationTray.${tool.id}Tooltip`, { defaultValue: tool.description ?? tool.label })} + aria-label={t(`creationTray.${tool.id}Label`, { defaultValue: tool.label })} onClick={() => onToggle(tool.id)} > {isLocked ? : } @@ -262,8 +262,8 @@ export function AtlasCreationTray({ armedTool, locked, onToggle, tablePickerOpen data-testid={`atlas-tray-${tool.id}`} data-armed={imagePopoverOpen} aria-pressed={imagePopoverOpen} - title={t(`creationTray.${tool.id}Tooltip`)} - aria-label={t(`creationTray.${tool.id}Label`)} + title={t(`creationTray.${tool.id}Tooltip`, { defaultValue: tool.description ?? tool.label })} + aria-label={t(`creationTray.${tool.id}Label`, { defaultValue: tool.label })} onClick={() => onImageToggle(!imagePopoverOpen)} > @@ -297,8 +297,8 @@ export function AtlasCreationTray({ armedTool, locked, onToggle, tablePickerOpen data-testid={`atlas-tray-${tool.id}`} data-armed={armed} aria-pressed={armed} - title={t(`creationTray.${tool.id}Tooltip`)} - aria-label={t(`creationTray.${tool.id}Label`)} + title={t(`creationTray.${tool.id}Tooltip`, { defaultValue: tool.description ?? tool.label })} + aria-label={t(`creationTray.${tool.id}Label`, { defaultValue: tool.label })} draggable={draggable} onDragStart={ draggable diff --git a/frontend/src/locales/en/views.json b/frontend/src/locales/en/views.json index 65ca3009f..39987da28 100644 --- a/frontend/src/locales/en/views.json +++ b/frontend/src/locales/en/views.json @@ -341,7 +341,8 @@ "ask": "Awaiting approval", "human-review": "Ask for review", "debug": "Paused at breakpoint", - "mcp-write": "MCP write request" + "mcp-write": "MCP write request", + "guarded-action": "Guarded actions" }, "heading": "Review", "subtitle": "Everything waiting for a human — guardrail approvals, review checkpoints, and MCP write requests — approve to proceed, deny to stop it.", @@ -382,7 +383,9 @@ "alwaysScopeStep": "Only this step — {{nodeType}} in {{workflow}}", "alwaysScopeWorkflow": "Any step in {{workflow}}", "alwaysScopeNodeType": "Every {{nodeType}} step, in any workflow", - "alwaysScopeRequest": "Any step calling {{request}}" + "alwaysScopeRequest": "Any step calling {{request}}", + "guardedActionRequest": "Guarded action", + "guardedActionSource": "Requested by {{source}} ({{kind}})" }, "guardrailRulesPanel": { "countLabel": "{{count}} rule{{plural}}", diff --git a/frontend/src/views/ReviewGuardedActions.tsx b/frontend/src/views/ReviewGuardedActions.tsx new file mode 100644 index 000000000..fd158b390 --- /dev/null +++ b/frontend/src/views/ReviewGuardedActions.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Stack, Text } from '@primer/react' +import { ShieldIcon } from '@primer/octicons-react' +import { Events } from '@wailsio/runtime' +import { GuardrailService } from '../../bindings/github.com/alicoding/mill/internal/services/guardrailsvc' +import type { PendingGuardedAction } from '../../bindings/github.com/alicoding/mill/internal/services/guardrailsvc/models' +import { StatusStamp } from '../shared/StatusStamp' +import { StalenessBadge } from '../shared/StalenessBadge' +import styles from './ReviewView.module.css' + +// Pending guarded actions in the Review queue (docs/goals/0249, +// closing docs/adr/0047 §5's render-alongside half): a non-workflow +// caller -- a plugin, an agent -- asked the guardrail for an action +// and a rule (or ClassExternal's ask-by-default) parked it. Same +// approve/deny decision pair as every other queue row; the blocked +// caller wakes with the answer. Self-contained data-wise (its own +// fetch + the same pending-changed event the run queue refreshes on) +// so ReviewView stays a thin composition of queue sections. +export function ReviewGuardedActions({ visible }: { visible: boolean }) { + const { t } = useTranslation('views') + const [actions, setActions] = useState([]) + + const refresh = () => { + GuardrailService.PendingGuardedActions().then((a) => setActions(a ?? [])).catch(() => {}) + } + useEffect(() => { + refresh() + const off = Events.On('guardrail-pending-changed', refresh) + const timer = window.setInterval(refresh, 2000) + return () => { + off() + window.clearInterval(timer) + } + }, []) + + const resolve = (id: string, approve: boolean) => { + GuardrailService.ResolveGuardedAction(id, approve).then(refresh).catch(() => {}) + } + + if (!visible || actions.length === 0) return null + return ( + + {actions.map((a) => ( +
+ + + + {t('reviewView.guardedActionRequest')} + {t('reviewView.awaitingApprovalLower')} + + + {a.Description || a.Kind} + + {t('reviewView.guardedActionSource', { source: a.Source, kind: a.Kind })} + + + + + + +
+ ))} +
+ ) +} diff --git a/frontend/src/views/ReviewView.tsx b/frontend/src/views/ReviewView.tsx index 467c0397a..21ae746c8 100644 --- a/frontend/src/views/ReviewView.tsx +++ b/frontend/src/views/ReviewView.tsx @@ -12,6 +12,7 @@ import { useAppStore } from '../shared/store' import { useUISignalStore } from '../shared/uiSignalStore' import { formatRunStartedAt } from '../shared/runTime' import { StalenessBadge } from '../shared/StalenessBadge' +import { ReviewGuardedActions } from './ReviewGuardedActions' import { formatLastChecked } from '../shared/staleness' import { ReviewAlwaysRuleDialog } from './ReviewAlwaysRuleDialog' import { GuardrailRulesPanel } from './GuardrailRulesPanel' @@ -31,12 +32,12 @@ interface AlwaysRuleRequest { run: RunSummary; effect: 'allow' | 'deny' } // 'mcp-write' isn't a RunSummary at all (docs/adr/0032's own pending // store) -- it's counted separately and only ever shown when present. type PendingKind = 'ask' | 'human-review' | 'debug' -type KindFilterValue = '' | PendingKind | 'mcp-write' +type KindFilterValue = '' | PendingKind | 'mcp-write' | 'guarded-action' // Order the kind Select's options render in when 2+ are present -- // fixed, not Set-insertion-order, so the list doesn't reshuffle as // different kinds come and go. -const KIND_ORDER: Array> = ['ask', 'human-review', 'debug', 'mcp-write'] +const KIND_ORDER: Array> = ['ask', 'human-review', 'debug', 'mcp-write', 'guarded-action'] // Wording reused verbatim from what the row itself already renders // (isDebugPark's Label text, the pendingWrites card's "MCP write @@ -51,6 +52,7 @@ function kindLabelsFor(t: (key: string) => string): Record + + {pending.filter((r) => (!workflowFilter || r.workflowID === workflowFilter) && kindMatches(r)).map((run) => (
Date: Sat, 29 Aug 2026 02:47:52 -0400 Subject: [PATCH 3/6] =?UTF-8?q?feat(0249=20S2/S4):=20the=20install=20story?= =?UTF-8?q?=20=E2=80=94=20installed-plugins=20section,=20docs,=20and=20the?= =?UTF-8?q?=20real=20SDK=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- frontend/.dependency-cruiser.cjs | 7 ++ frontend/src/locales/en/views.json | 10 +- .../src/views/ExtensionsInstalledPlugins.tsx | 109 ++++++++++++++++++ frontend/src/views/ExtensionsSection.tsx | 11 +- userdocs/llms-full.txt | 17 +-- userdocs/reference/extending-the-canvas.md | 17 +-- userdocs/reference/install-a-plugin.md | 76 ++++++++++++ 7 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 frontend/src/views/ExtensionsInstalledPlugins.tsx create mode 100644 userdocs/reference/install-a-plugin.md diff --git a/frontend/.dependency-cruiser.cjs b/frontend/.dependency-cruiser.cjs index 178c7a60a..f7f7ed9c9 100644 --- a/frontend/.dependency-cruiser.cjs +++ b/frontend/.dependency-cruiser.cjs @@ -66,6 +66,13 @@ module.exports = { from: { path: '^src/atlas/extensions' }, to: { path: '^(src/shared/bindings\\.ts|bindings/.*/internal/services/)' }, }, + { + name: 'plugin-sdk-imports-nothing', + severity: 'error', + comment: 'ADR-0047 / goal 0249: src/plugins/sdk.ts describes exactly what an out-of-tree plugin sees, and a plugin receives capabilities only through the api object handed to activate() -- never through an import. The SDK module therefore imports NOTHING (kernel, bindings, or otherwise); host-side plumbing lives in src/plugins/{hostApi,loader,PluginFaceContent} which legitimately reach the kernel.', + from: { path: '^src/plugins/sdk\\.ts$' }, + to: {}, + }, ], options: { doNotFollow: { path: 'node_modules' }, diff --git a/frontend/src/locales/en/views.json b/frontend/src/locales/en/views.json index 39987da28..9c4a7833c 100644 --- a/frontend/src/locales/en/views.json +++ b/frontend/src/locales/en/views.json @@ -40,7 +40,15 @@ "builtIn": "Built-in", "turnAllOff": "Turn all off", "turnAllOn": "Turn all on", - "docsLink": "See how canvas objects work" + "docsLink": "See how canvas objects work", + "pluginsTitle": "Installed plugins", + "openPluginsFolder": "Open plugins folder", + "reload": "Reload", + "installHint": "Copy a plugin's folder here, then reload.", + "noPlugins": "No plugins installed yet.", + "pluginCapabilities": "Can request: {{list}}", + "pluginDisabledNote": "Turned off. Turn on and reload to load it.", + "pluginToggleAria": "Enable {{name}}" }, "keyboardShortcuts": { "description": "Every in-window command Mill dispatches. Rebind one by clicking its combo and pressing a new one — the same recorder used for a workflow's own trigger hotkey below." diff --git a/frontend/src/views/ExtensionsInstalledPlugins.tsx b/frontend/src/views/ExtensionsInstalledPlugins.tsx new file mode 100644 index 000000000..7a3719ad7 --- /dev/null +++ b/frontend/src/views/ExtensionsInstalledPlugins.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ActionList, Button, Stack, Text, ToggleSwitch } from '@primer/react' +import { AlertIcon } from '@primer/octicons-react' +import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc' +import type { PluginInfo } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' +import { SettingsService } from '../shared/bindings' +import { pluginLoadStates } from '../plugins/loader' +import { refreshDisabledExtensions, useExtensionEnablementStore } from '../shared/extensionEnablementStore' +import styles from '../shared/ListCard.module.css' + +// The installed-plugins section of Settings > Extensions (docs/goals/ +// 0249): every folder in the plugins directory, with its manifest +// metadata and what actually happened to it this boot -- loaded, +// disabled, or visibly broken with the exact reason. The install +// story lives here too: the folder is one click away, and a fresh +// install takes effect on reload (plugins load at app start). +export function ExtensionsInstalledPlugins() { + const { t } = useTranslation('views') + const disabledIds = useExtensionEnablementStore((s) => s.disabledExtensionIds) + const [plugins, setPlugins] = useState(null) + + useEffect(() => { + PluginService.ListPlugins().then((p) => setPlugins(p ?? [])).catch(() => setPlugins([])) + }, []) + + const toggle = (id: string, enabled: boolean) => { + SettingsService.SetExtensionEnabled(id, enabled).then(refreshDisabledExtensions).catch(console.error) + } + const openFolder = () => { + PluginService.RevealPluginsDir().catch(console.error) + } + const states = pluginLoadStates() + + return ( + + + + {t('settings.extensions.pluginsTitle')} + + + + + + + + {t('settings.extensions.installHint')} + + {plugins !== null && plugins.length === 0 && ( + + {t('settings.extensions.noPlugins')} + + )} + {plugins !== null && plugins.length > 0 && ( + + {plugins.map((p) => { + const id = p.Manifest.id + const runtime = states.get(id) + const error = p.Error || (runtime?.status === 'error' ? runtime.error : '') + const enabled = !disabledIds.includes(id) + return ( + + + + + {p.Manifest.name || id} + + {p.Manifest.version} + {p.Manifest.author ? ` · ${p.Manifest.author}` : ''} + + + {p.Manifest.description && {p.Manifest.description}} + {(p.Manifest.capabilities?.length ?? 0) > 0 && ( + + {t('settings.extensions.pluginCapabilities', { list: (p.Manifest.capabilities ?? []).join(', ') })} + + )} + {error && ( + + + {error} + + )} + {!error && runtime?.status === 'disabled' && ( + {t('settings.extensions.pluginDisabledNote')} + )} + + {!error && ( + toggle(id, on)} + aria-labelledby={`plugin-name-${id}`} + data-testid="extensions-plugin-toggle" + /> + )} + + + ) + })} + + )} + + ) +} diff --git a/frontend/src/views/ExtensionsSection.tsx b/frontend/src/views/ExtensionsSection.tsx index 00f86838f..db7e4b280 100644 --- a/frontend/src/views/ExtensionsSection.tsx +++ b/frontend/src/views/ExtensionsSection.tsx @@ -2,10 +2,11 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActionList, Button, Stack, Text } from '@primer/react' import { ATLAS_TOOLS, type AtlasToolID } from '../atlas/atlasTools' -import { toolLessNounExtensions } from '../atlas/atlasNounRegistry' +import { isThirdPartyToolId, toolLessNounExtensions } from '../atlas/atlasNounRegistry' import { SettingsService } from '../shared/bindings' import { refreshDisabledExtensions, useExtensionEnablementStore } from '../shared/extensionEnablementStore' import { ExtensionRow } from './ExtensionRow' +import { ExtensionsInstalledPlugins } from './ExtensionsInstalledPlugins' import { toolLessRowSource, toolRowSource, type ExtensionRowSource } from './extensionMeta' import styles from '../shared/ListCard.module.css' @@ -37,8 +38,11 @@ import styles from '../shared/ListCard.module.css' // noun's own `extension.disableScopeNote`, atlasNounRegistry.ts). const CARD_TOOL_ID: AtlasToolID = 'card' const EXTENSION_ROWS: ExtensionRowSource[] = [ - ...ATLAS_TOOLS.map(toolRowSource), - ...toolLessNounExtensions().map(toolLessRowSource), + // Runtime plugin tools are excluded here -- they get their own + // richer row (manifest metadata, load state) in the installed- + // plugins section below, never a second compiled-in-style one. + ...ATLAS_TOOLS.filter((tool) => !(tool as { thirdParty?: boolean }).thirdParty).map(toolRowSource), + ...toolLessNounExtensions().filter((n) => !isThirdPartyToolId(n.kind)).map(toolLessRowSource), ] const NON_BUILT_IN_IDS: string[] = EXTENSION_ROWS.filter((r) => r.id !== CARD_TOOL_ID).map((r) => r.id) @@ -101,6 +105,7 @@ export default function ExtensionsSection() { ))} + ) } diff --git a/userdocs/llms-full.txt b/userdocs/llms-full.txt index a2eacf745..697783bf9 100644 --- a/userdocs/llms-full.txt +++ b/userdocs/llms-full.txt @@ -1342,13 +1342,16 @@ self-registered files. This page is the contract that file has to satisfy: how it gets discovered, what its declaration requires, and which platform services its runtime code may call — and may not. -Read this as a Mill developer working in this repo, not as a plugin -author installing a package: there's no out-of-tree loading mechanism -yet, so extending the canvas means editing Mill's own tree, the same -way adding a workflow step type does. That's a deliberate, recorded -gap, not an oversight — nothing here promises stability for a tool -built outside this repo, and the "Stability" section below says -exactly what is and isn't safe to build against. +Two doors exist now. A **runtime plugin** — a folder with a manifest +and a `main.js`, copied into the app's plugins folder, no rebuild — +is the out-of-tree door, and [Install a plugin](install-a-plugin.md) +covers it end to end, including the `activate(api)` contract and the +guarded-capability model. This page is the OTHER door: a compiled-in +tool built by editing Mill's own tree, the same way adding a workflow +step type does — fuller reach (custom React rendering, gestures, +style fields) at the price of a rebuild. The "Stability" section +below says exactly what is and isn't safe to build against either +way. ## How it loads diff --git a/userdocs/reference/extending-the-canvas.md b/userdocs/reference/extending-the-canvas.md index 7066da645..7f1736d12 100644 --- a/userdocs/reference/extending-the-canvas.md +++ b/userdocs/reference/extending-the-canvas.md @@ -7,13 +7,16 @@ self-registered files. This page is the contract that file has to satisfy: how it gets discovered, what its declaration requires, and which platform services its runtime code may call — and may not. -Read this as a Mill developer working in this repo, not as a plugin -author installing a package: there's no out-of-tree loading mechanism -yet, so extending the canvas means editing Mill's own tree, the same -way adding a workflow step type does. That's a deliberate, recorded -gap, not an oversight — nothing here promises stability for a tool -built outside this repo, and the "Stability" section below says -exactly what is and isn't safe to build against. +Two doors exist now. A **runtime plugin** — a folder with a manifest +and a `main.js`, copied into the app's plugins folder, no rebuild — +is the out-of-tree door, and [Install a plugin](install-a-plugin.md) +covers it end to end, including the `activate(api)` contract and the +guarded-capability model. This page is the OTHER door: a compiled-in +tool built by editing Mill's own tree, the same way adding a workflow +step type does — fuller reach (custom React rendering, gestures, +style fields) at the price of a rebuild. The "Stability" section +below says exactly what is and isn't safe to build against either +way. ## How it loads diff --git a/userdocs/reference/install-a-plugin.md b/userdocs/reference/install-a-plugin.md new file mode 100644 index 000000000..bb34f034c --- /dev/null +++ b/userdocs/reference/install-a-plugin.md @@ -0,0 +1,76 @@ +# Install a plugin + +A plugin adds a new object type to the canvas without rebuilding Mill. +It is a folder holding two files — `manifest.json` (name, version, and +what the plugin is allowed to ask for) and `main.js` (its code) — and +installing one is copying that folder into Mill's plugins folder. + +## Installing + +1. Open **Settings → Extensions** and press **Open plugins folder**. +2. Copy the plugin's folder in — the folder name must match the + plugin's id. +3. Press **Reload**. The plugin's tool appears in the canvas tray, and + its row appears under **Installed plugins** with its name, version, + author, and what it can request. + +A plugin that can't load shows exactly why on its row — a missing +file, invalid manifest, or a capability Mill doesn't recognize — +instead of silently doing nothing. + +## Turning a plugin off + +Each installed plugin has the same switch every built-in extension +has. Turning it off removes its tool from the tray and palette; +objects it already placed stay on your boards untouched. + +## What a plugin can and cannot do + +A plugin draws its own objects and edits their data through Mill. +It is never handed the ability to open network connections, touch +files, or leave the app on its own. When it needs something like +that — opening a web address in your browser, say — it must: + +1. **Declare** the capability in its manifest, visible on its + Extensions row before you ever run it. +2. **Ask** at the moment of use. Every ask runs through your + guardrail rules: you can allow it, deny it, or leave the default, + which parks the request in **Review** for your explicit approval. + +Undeclared asks are refused outright. Approved actions are performed +by Mill itself, never by the plugin's own code. + +## The example plugin + +Mill's repository ships a working example, **Bookmark** — a web +address pinned to the board, edited in place, opened through a +guarded ask. Copy `examples/plugins/mill-bookmark` from the +repository into your plugins folder to try it, or use it as the +starting point for your own. + +## Writing one + +A plugin's `main.js` is a plain JavaScript module — no build step — +exporting one function: + +```js +export function activate(api) { + api.registerCanvasObject({ + kind: 'my-thing', + label: 'My thing', + icon: '⭐', + source: 'board-local', + editRoute: 'inline', + defaultPayload: {}, + renderFace(el, ctx) { + // Draw into el with plain DOM. ctx.object holds the data; + // ctx.updatePayload(patch) saves changes (undo included); + // ctx.requestGuardedAction(kind, attrs, description) asks Mill + // to act on the plugin's behalf. + }, + }) +} +``` + +The full contract — every field, every capability, and what stays +stable between versions — is in [Extending the canvas](extending-the-canvas.md). From e9b8a52f2a5a823fe2f798b8228e5f3ed2eeedaa Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Sat, 29 Aug 2026 03:14:15 -0400 Subject: [PATCH 4/6] fix: regenerate bindings after the main merge; commit the spec's install-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 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- .../services/guardrailsvc/guardrailservice.ts | 18 ++++++++ .../internal/services/guardrailsvc/index.ts | 1 + .../internal/services/guardrailsvc/models.ts | 15 +++++++ .../services/pluginsvc/pluginservice.ts | 7 +-- frontend/e2e/runtime-plugins.spec.ts | 44 +++++++++++++++++-- 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts index c8339cc95..2969fe8b6 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts @@ -71,6 +71,24 @@ export function EvaluateStep(step: guardrail$0.Step, $class: guardrail$0.EffectC return $Call.ByID(1285652131, step, $class); } +/** + * PendingGuardedActions is the Wails-bound listing the Review queue + * renders -- the same records RequestGuardedAction parks. + */ +export function PendingGuardedActions(): $CancellablePromise<$models.PendingGuardedAction[] | null> { + return $Call.ByID(4230330282); +} + +/** + * ResolveGuardedAction is the Review queue's approve/deny door for a + * parked guarded action (docs/goals/0249 closed the render-alongside + * half this park always promised): the blocked RequestGuardedAction + * caller wakes with the human's answer. + */ +export function ResolveGuardedAction(id: string, approve: boolean): $CancellablePromise { + return $Call.ByID(3175233248, id, approve); +} + /** * Rules returns every stored rule. */ diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/index.ts index f48ee9eed..4bbeb4295 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/index.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/index.ts @@ -7,5 +7,6 @@ export { }; export type { + PendingGuardedAction, RuleTestResult } from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/models.ts index 6f6cb8680..95f641b40 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/models.ts @@ -1,6 +1,21 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * PendingGuardedAction is a parked, not-yet-resolved GuardedAction -- + * the non-workflow analogue of executionsvc.PendingApproval (docs/adr/0047 + * §5 point 3): additive, never reshaping the workflow park it is meant + * to one day render alongside. + */ +export interface PendingGuardedAction { + "ID": string; + "Kind": string; + "Attributes": { [_ in string]?: string } | null; + "Description": string; + "Source": string; + "CreatedAt": string; +} + /** * RuleTestResult is one dry-run's outcome for the Configure tester. */ diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/pluginservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/pluginservice.ts index e1780252e..b899e4294 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/pluginservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/pluginservice.ts @@ -17,12 +17,7 @@ import * as $models from "./models.js"; /** * AssetMiddleware serves GET /plugins// from the scanned - * plugins directory and passes every other request through. Only ids - * that scan to a VALID manifest serve at all (a broken plugin is - * visible in Extensions, never half-loaded via a dangling script - * URL), only allowlisted extensions serve, and the resolved path must - * stay inside the plugin's own folder (filepath.Rel guards traversal - * after cleaning). + * plugins directory and passes every other request through. */ export function AssetMiddleware(): $CancellablePromise { return $Call.ByID(3587145368); diff --git a/frontend/e2e/runtime-plugins.spec.ts b/frontend/e2e/runtime-plugins.spec.ts index fb83e064c..aff2daab2 100644 --- a/frontend/e2e/runtime-plugins.spec.ts +++ b/frontend/e2e/runtime-plugins.spec.ts @@ -1,5 +1,5 @@ import { chromium, expect, test } from '@playwright/test' -import { mkdtempSync, rmSync } from 'node:fs' +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,15 +16,25 @@ import { findEmptyBoardRect } from './fixtures/atlasEmptyRegion' // wide, and the Review-queue assertions read the global pending list. const EXAMPLES_PLUGINS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'examples', 'plugins') -async function launchWithPlugins(offset: number) { +async function launchWithPlugins(offset: number, opts: { withBroken?: boolean } = {}) { const dir = mkdtempSync(path.join(tmpdir(), 'mill-plugins-e2e-')) + // The plugins dir is a per-test COPY of examples/plugins (the exact + // artifact a user copies from) -- never the repo folder itself, so + // a test can add a deliberately-broken sibling without touching it. + const pluginsDir = path.join(dir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + cpSync(path.join(EXAMPLES_PLUGINS_DIR, 'mill-bookmark'), path.join(pluginsDir, 'mill-bookmark'), { recursive: true }) + if (opts.withBroken) { + mkdirSync(path.join(pluginsDir, 'broken-one')) + writeFileSync(path.join(pluginsDir, 'broken-one', 'manifest.json'), '{not json') + } const server: SpawnedServer = await spawnMillServer({ port: RUNTIME_PLUGINS_SERVER_BASE_PORT + offset, mcpPort: RUNTIME_PLUGINS_MCP_BASE_PORT + offset, settingsPath: path.join(dir, 'settings.json'), executionDbPath: path.join(dir, 'exec.db'), backupDir: path.join(dir, 'backups'), - extraEnv: { MILL_PLUGINS_DIR: EXAMPLES_PLUGINS_DIR }, + extraEnv: { MILL_PLUGINS_DIR: pluginsDir }, }) const browser = await chromium.launch() const context = await browser.newContext({ baseURL: `http://127.0.0.1:${RUNTIME_PLUGINS_SERVER_BASE_PORT + offset}` }) @@ -132,3 +142,31 @@ test('a guarded action parks for the human, renders in Review, and the approve/d await close() } }) + +test('the Extensions page tells the install story: plugin row with manifest metadata and capabilities, a broken plugin names its error, no duplicate compiled-in row', async () => { + const { page, close } = await launchWithPlugins(4, { withBroken: true }) + try { + await page.goto('/') + await page.getByRole('link', { name: 'Settings' }).click() + const section = page.locator('[data-testid="extensions-installed-plugins"]') + await section.scrollIntoViewIfNeeded() + await expect(section).toBeVisible() + + const bookmarkRow = section.locator('[data-testid="extensions-plugin-row"][data-plugin-id="mill-bookmark"]') + await expect(bookmarkRow).toContainText('Bookmark') + await expect(bookmarkRow).toContainText('1.0.0') + await expect(bookmarkRow).toContainText('open-url') + await expect(bookmarkRow.locator('[data-testid="extensions-plugin-toggle"]')).toBeVisible() + + // A broken folder is a visible row naming its exact problem -- + // never silently skipped, never a switch pretending it could run. + const brokenRow = section.locator('[data-testid="extensions-plugin-row"][data-plugin-id="broken-one"]') + await expect(brokenRow.locator('[data-testid="extensions-plugin-error"]')).toContainText('not valid JSON') + await expect(brokenRow.locator('[data-testid="extensions-plugin-toggle"]')).toHaveCount(0) + + // The plugin never gets a second compiled-in-style row. + await expect(page.locator('[data-testid="extensions-row"][data-extension-id="bookmark"]')).toHaveCount(0) + } finally { + await close() + } +}) From 1738d898a737149ebd28ac8f666e8fc3be458f81 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Sat, 29 Aug 2026 03:38:48 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20static=20module=20graph=20+=20lazy?= =?UTF-8?q?=20registry=20snapshots=20=E2=80=94=20the=20boot-order=20contra?= =?UTF-8?q?ct=20without=20the=20side=20effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- frontend/e2e/command-palette.spec.ts | 8 ++++++ frontend/e2e/help-overlay.spec.ts | 4 +++ frontend/e2e/quick-panel.spec.ts | 4 +++ frontend/e2e/settings.spec.ts | 4 +++ frontend/src/app/main.tsx | 35 +++++++++--------------- frontend/src/atlas/atlasTools.ts | 13 +++++++-- frontend/src/shared/commands.ts | 8 ++++-- frontend/src/shared/lazySnapshot.ts | 25 +++++++++++++++++ frontend/src/views/ExtensionsSection.tsx | 7 +++-- 9 files changed, 78 insertions(+), 30 deletions(-) create mode 100644 frontend/src/shared/lazySnapshot.ts diff --git a/frontend/e2e/command-palette.spec.ts b/frontend/e2e/command-palette.spec.ts index ee88fc984..a7b82e4c0 100644 --- a/frontend/e2e/command-palette.spec.ts +++ b/frontend/e2e/command-palette.spec.ts @@ -85,6 +85,10 @@ async function createHotkeyTriggerWorkflow(page: import('@playwright/test').Page // fit however many results that was). test('Meta+K rest-state shows a bounded set (nav commands only) until a query narrows it', async ({ page }) => { await page.goto('/') + // The shell paints after a short async boot (plugins load first -- + // docs/goals/0249); a keypress before anything is visible is not a + // user primitive, so the first press waits for the painted nav. + await expect(page.getByTestId('sidebar-nav')).toBeVisible() await page.keyboard.press('Meta+k') await expect(paletteDialog(page)).toBeVisible() @@ -103,6 +107,10 @@ test('Meta+K rest-state shows a bounded set (nav commands only) until a query na test('the palette list is height-bounded with internal scroll, not the Dialog growing unbounded', async ({ page }) => { await page.goto('/') + // The shell paints after a short async boot (plugins load first -- + // docs/goals/0249); a keypress before anything is visible is not a + // user primitive, so the first press waits for the painted nav. + await expect(page.getByTestId('sidebar-nav')).toBeVisible() await page.keyboard.press('Meta+k') await expect(paletteDialog(page)).toBeVisible() diff --git a/frontend/e2e/help-overlay.spec.ts b/frontend/e2e/help-overlay.spec.ts index 9da49a3a4..01bca11f2 100644 --- a/frontend/e2e/help-overlay.spec.ts +++ b/frontend/e2e/help-overlay.spec.ts @@ -91,6 +91,10 @@ test('"Open coverage" from the palette on Atlas opens the coverage dialog', asyn test('"Rebind in Settings" in the overlay footer navigates to Settings and closes the overlay', async ({ page }) => { await page.goto('/') + // The shell paints after a short async boot (plugins load first -- + // docs/goals/0249); a keypress before anything is visible is not a + // user primitive, so the first press waits for the painted nav. + await expect(page.getByTestId('sidebar-nav')).toBeVisible() await page.keyboard.press('?') await expect(helpDialog(page)).toBeVisible() diff --git a/frontend/e2e/quick-panel.spec.ts b/frontend/e2e/quick-panel.spec.ts index 2efb11a6b..320bddd4f 100644 --- a/frontend/e2e/quick-panel.spec.ts +++ b/frontend/e2e/quick-panel.spec.ts @@ -210,6 +210,10 @@ test('Quick Panel jump rows exist for Decisions and AI Providers (goal 0071 pari // share the exact same wiring). test('Running "New list" from the palette opens Configure -> Lists with the create form already open', async ({ page }) => { await page.goto('/') + // The shell paints after a short async boot (plugins load first -- + // docs/goals/0249); a keypress before anything is visible is not a + // user primitive, so the first press waits for the painted nav. + await expect(page.getByTestId('sidebar-nav')).toBeVisible() await page.keyboard.press('Meta+k') const palette = page.getByRole('dialog', { name: 'Command palette' }) await expect(palette).toBeVisible() diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index ff8432f31..7cde06570 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -181,6 +181,10 @@ test('Back up now from the command palette takes a real snapshot, live-updating // deep-link command already follows. test('Export everything from the command palette lands on the Backups settings section', async ({ page }) => { await page.goto('/') + // The shell paints after a short async boot (plugins load first -- + // docs/goals/0249); a keypress before anything is visible is not a + // user primitive, so the first press waits for the painted nav. + await expect(page.getByTestId('sidebar-nav')).toBeVisible() await page.keyboard.press('Meta+k') const palette = page.getByRole('dialog', { name: 'Command palette' }) await palette.getByRole('combobox').fill('Export everything') diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 998dedf1d..aa0e8afd5 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -12,15 +12,10 @@ import '@primer/primitives/dist/css/functional/themes/dark.css' // the wrong element). import './mill-tokens.css' import { ThemeProvider, BaseStyles } from '@primer/react' -// App is imported DYNAMICALLY inside bootstrap() below -- a static -// import would evaluate the whole app module graph (including the -// tool-registry snapshot) before the plugin loader has run. See -// src/plugins/loader.ts's boot-order contract. +import App from './App' import { AppErrorBoundary, CrashProbe } from './AppErrorBoundary' -// QuickPanelApp/ApprovalPromptApp are dynamic for the same boot-order -// reason as App: their own import graphs are deep enough to reach the -// tool-registry snapshot, and hand-auditing them forever is exactly -// the maintenance trap the dynamic import avoids structurally. +import { QuickPanelApp } from './QuickPanelApp' +import { ApprovalPromptApp } from './ApprovalPromptApp' import { COLOR_MODE_STORAGE_KEY } from './theme' // Read once, synchronously, before the first render, to seed @@ -70,24 +65,21 @@ if (import.meta.env.PROD && 'serviceWorker' in navigator) { }) } -// The main window loads runtime plugins BEFORE the app module graph -// evaluates (docs/goals/0249): activation must precede the tool-list/ -// command-table snapshots those modules take at eval. Raced against a -// deadline so a hung plugin import can never brick the boot -- the app -// then simply starts without the slow plugin, whose row shows the -// state. The auxiliary windows (Quick Panel, approval prompt, crash -// probe) render immediately -- none of them mounts a canvas. +// The main window loads runtime plugins before the FIRST RENDER +// (docs/goals/0249): the module graph above evaluates statically (CSS +// cascade order and chunking stay exactly as before), and the +// registry snapshots those modules export are LAZY +// (shared/lazySnapshot.ts) -- they materialize on first access, which +// is always a render- or event-time read, after activation. Raced +// against a deadline so a hung plugin import can never brick the +// boot. The auxiliary windows render immediately -- none of them +// mounts a canvas. async function bootstrap() { const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) if (isCrashProbe || isQuickPanel || isApprovalPrompt) { - const aux = isCrashProbe - ? - : isQuickPanel - ? await import('./QuickPanelApp').then((m) => ) - : await import('./ApprovalPromptApp').then((m) => ) root.render( - {aux} + {isCrashProbe ? : isQuickPanel ? : } , ) return @@ -99,7 +91,6 @@ async function bootstrap() { ]) const { markPluginsSettled } = await import('../plugins/loadGate') markPluginsSettled() - const { default: App } = await import('./App') root.render( diff --git a/frontend/src/atlas/atlasTools.ts b/frontend/src/atlas/atlasTools.ts index d8fb0bbee..cebdc37a0 100644 --- a/frontend/src/atlas/atlasTools.ts +++ b/frontend/src/atlas/atlasTools.ts @@ -1,5 +1,6 @@ import { ATLAS_TOOL_IDENTITIES, type AtlasToolIdentity } from '../shared/atlasToolIdentity' import { warnIfSnapshotBeforePlugins } from '../plugins/loadGate' +import { lazyArray } from '../shared/lazySnapshot' import { assertRegistryAgreesWithIdentity, orderedRegisteredTools } from './atlasNounRegistry' // The canvas tool registry (goal 0169 slice 1, re-platformed onto @@ -31,15 +32,21 @@ import { assertRegistryAgreesWithIdentity, orderedRegisteredTools } from './atla // somewhere, and nowhere else needs to enumerate the tools/ directory. import.meta.glob(['./tools/*.ts', '!./tools/*.test.ts'], { eager: true }) -warnIfSnapshotBeforePlugins() - // Fails fast (at the module-eval time every test/dev/build reaches by // importing this file) if a noun's identity and registered descriptor // ever disagree -- a noun that half-exists on either side never ships // half-wired. assertRegistryAgreesWithIdentity() -export const ATLAS_TOOLS = orderedRegisteredTools() +// LAZY snapshot (shared/lazySnapshot.ts, docs/goals/0249): built on +// first ACCESS, not at eval -- runtime plugins register between this +// module's eval and the first render, and every read of ATLAS_TOOLS +// is a render- or event-time read. The tripwire fires if that +// ordering ever regresses. +export const ATLAS_TOOLS = lazyArray(() => { + warnIfSnapshotBeforePlugins() + return orderedRegisteredTools() +}) export { cardTool, type AtlasCardArtifact } from './tools/cardTool' export { noteTool, type AtlasNoteArtifact } from './tools/noteTool' diff --git a/frontend/src/shared/commands.ts b/frontend/src/shared/commands.ts index 8585551ec..60f05af35 100644 --- a/frontend/src/shared/commands.ts +++ b/frontend/src/shared/commands.ts @@ -4,6 +4,7 @@ import { useAppStore } from './store' import type { View } from './store' import { useUISignalStore } from './uiSignalStore' import { drainedPluginCommands } from '../plugins/pluginCommands' +import { lazyArray } from './lazySnapshot' import { CONFIGURE_CREATE_COMMANDS } from './configureCreateCommands' import { ATLAS_BOARD_COMMANDS } from './atlasBoardCommands' import { SETTINGS_COMMANDS } from './settingsCommands' @@ -122,7 +123,10 @@ function cycleWorkTab(direction: 1 | -1): void { activateWorkTab(keys[next]) } -export const COMMANDS: Command[] = [ +// LAZY snapshot (shared/lazySnapshot.ts, docs/goals/0249): plugin +// commands are collected during activation, which lands between this +// module's eval and the first read (always render- or event-time). +export const COMMANDS: Command[] = lazyArray(() => [ { id: 'tab.close', label: 'Close tab', @@ -408,7 +412,7 @@ export const COMMANDS: Command[] = [ // keybinding for third-party code is assigned in Settings, never // shipped by the plugin. ...drainedPluginCommands().map((c) => ({ id: c.id, label: c.label, defaultBinding: null, run: c.run })), -] +]) export function findCommand(id: string): Command | undefined { return COMMANDS.find((c) => c.id === id) diff --git a/frontend/src/shared/lazySnapshot.ts b/frontend/src/shared/lazySnapshot.ts new file mode 100644 index 000000000..8c406f108 --- /dev/null +++ b/frontend/src/shared/lazySnapshot.ts @@ -0,0 +1,25 @@ +// lazyArray -- a module-scope array constant whose CONTENTS are built +// on first access instead of at module eval (docs/goals/0249's +// boot-order contract): runtime plugins register into the registries +// after the module graph evaluates but before the first render, so a +// snapshot taken at eval would miss them, while one taken at first +// access (always a render- or event-time read) includes them. The +// Proxy materializes exactly once; consumers keep plain-array usage +// (`.map`, `.find`, iteration, indexing) untouched. +export function lazyArray(build: () => T[]): T[] { + let materialized: T[] | null = null + const get = (): T[] => { + if (materialized === null) materialized = build() + return materialized + } + return new Proxy([] as T[], { + get(_target, prop, receiver) { + const value = Reflect.get(get(), prop, receiver) + return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(get()) : value + }, + has: (_t, prop) => Reflect.has(get(), prop), + ownKeys: () => Reflect.ownKeys(get()), + getOwnPropertyDescriptor: (_t, prop) => Reflect.getOwnPropertyDescriptor(get(), prop), + getPrototypeOf: () => Array.prototype, + }) +} diff --git a/frontend/src/views/ExtensionsSection.tsx b/frontend/src/views/ExtensionsSection.tsx index 3f40333f9..5557d1e80 100644 --- a/frontend/src/views/ExtensionsSection.tsx +++ b/frontend/src/views/ExtensionsSection.tsx @@ -7,6 +7,7 @@ import { SettingsService } from '../shared/bindings' import { refreshDisabledExtensions, useExtensionEnablementStore } from '../shared/extensionEnablementStore' import { ExtensionRow } from './ExtensionRow' import { ExtensionsInstalledPlugins } from './ExtensionsInstalledPlugins' +import { lazyArray } from '../shared/lazySnapshot' import { groupSectionLabel, toolLessRowSource, toolRowSource, type ExtensionRowSource } from './extensionMeta' import type { AtlasNounGroup } from '../atlas/atlasNounRegistry' import styles from '../shared/ListCard.module.css' @@ -38,14 +39,14 @@ import styles from '../shared/ListCard.module.css' // its own row states its narrower disable scope directly (see each // noun's own `extension.disableScopeNote`, atlasNounRegistry.ts). const CARD_TOOL_ID: AtlasToolID = 'card' -const EXTENSION_ROWS: ExtensionRowSource[] = [ +const EXTENSION_ROWS: ExtensionRowSource[] = lazyArray(() => [ // Runtime plugin tools are excluded here -- they get their own // richer row (manifest metadata, load state) in the installed- // plugins section below, never a second compiled-in-style one. ...ATLAS_TOOLS.filter((tool) => !(tool as { thirdParty?: boolean }).thirdParty).map(toolRowSource), ...toolLessNounExtensions().filter((n) => !isThirdPartyToolId(n.kind)).map(toolLessRowSource), -] -const NON_BUILT_IN_IDS: string[] = EXTENSION_ROWS.filter((r) => r.id !== CARD_TOOL_ID).map((r) => r.id) +]) +const NON_BUILT_IN_IDS: string[] = lazyArray(() => EXTENSION_ROWS.filter((r) => r.id !== CARD_TOOL_ID).map((r) => r.id)) // The list's own three sections (goal 0237 S3's review rider -- // "group the list, stop repeating the group"), in the same From e1ecf46817c1672d8c3aa78b0ee025e6d65bcd19 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Sat, 29 Aug 2026 03:53:40 -0400 Subject: [PATCH 6/6] fix: the guarded-loop spec pins the post-commit render before clicking 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 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- frontend/e2e/runtime-plugins.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/e2e/runtime-plugins.spec.ts b/frontend/e2e/runtime-plugins.spec.ts index aff2daab2..5b7faede2 100644 --- a/frontend/e2e/runtime-plugins.spec.ts +++ b/frontend/e2e/runtime-plugins.spec.ts @@ -107,6 +107,11 @@ test('a guarded action parks for the human, renders in Review, and the approve/d await face.locator('[data-testid="bookmark-url-input"]').click() await page.keyboard.type('example.com') await page.keyboard.press('Enter') + // The commit re-renders the face (payload change); wait for the + // derived title so the Open click below hits the CURRENT + // elements, not the doomed pre-commit ones a slower runner can + // still be swapping out. + await expect(face.locator('span').nth(1)).toHaveText('example.com') // Open asks the guardrail; ClassExternal's ask-by-default parks. await face.locator('[data-testid="bookmark-open"]').click()