Files
multica/examples/plugins/deploy-sentinel/README.md
T
f45b591623 MUL-6469: host plugin artifacts, bind installs to immutable versions (#7321)
* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden surface validation and error reporting

* fix(migrations): renumber plugin package migrations

* fix(plugins): validate classic surfaces and reset failures

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 16:03:44 +08:00

5.3 KiB

Deploy Sentinel

A production-incident plugin. It correlates an incident issue with recent deploys, lets an agent file a rollback request under the team's own rules, pages on-call when an incident is filed, and adopts the team's existing metrics MCP server.

It exists because the other examples in this directory each demonstrate one thing. This one is the shape of a plugin somebody would actually write: four triggers, both transports, real business rules that refuse things, and a skill that teaches an agent when to use any of it.

What it contributes

Contribution Kind What it demonstrates
incident-response skill resource A SKILL.md becomes an ordinary workspace skill. Uninstall removes it.
correlate_deploys hook, agent + manual + ui One hook, three callers. The agent gets a tool; a person gets a button; the panel gets a function.
request_rollback hook, agent + manual A write action that refuses: too old, or no written reason.
page_oncall hook, event Fires on issue.created / issue.status_changed. Never blocks anything.
metrics hook, mcp transport Adopts an external MCP server's tools, subject to admin approval.
incident issue_panel surface The human view, in a sandboxed iframe with no credential.

The part worth copying

request_rollback says no. It refuses a deploy past the configured window, and it refuses a reason shorter than 20 characters. An agent that has to write evidence before it can file a change request produces better change requests — and the refusal text explains why, so the agent can act on it rather than retry blindly. Hook handlers that only ever succeed are the easy case; this one is the useful one.

correlate_deploys reports "nothing changed" as an answer, not as an empty list. The skill then tells the agent that an empty result means it should stop looking at deploys. A tool that returns [] and a tool that says "no deploys in this window, this is unlikely to be a deploy" cost the same to write and behave very differently in an agent's hands.

The skill and the hooks are designed together. SKILL.md names the tools and says what order to use them in. Neither half works as well alone.

Running it locally

Two servers, both the plugin author's own. Multica never runs either.

Both serve HTTPS, because a hook's transport URL must be an https:// URL or the manifest will not install. Make a certificate once:

openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout dev-key.pem -out dev-cert.pem \
  -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1"
# The hook endpoint. The signing secret is the whsec_… shown once when you
# issued the plugin's token in workspace settings.
MULTICA_SIGNING_SECRET=whsec_... node server/handler.mjs      # :8788

# The MCP server behind the `metrics` hook.
METRICS_TOKEN=metrics-dev-token node server/metrics-mcp.mjs   # :8789

Both read TLS_CERT / TLS_KEY, defaulting to dev-cert.pem / dev-key.pem in the working directory.

Then point Multica at them. Both endpoints are on loopback, which the outbound guard refuses by design, so name them explicitly:

export MULTICA_PLUGIN_DIR=examples/plugins
export MULTICA_PLUGIN_DEV_ORIGINS=https://127.0.0.1:8788,https://127.0.0.1:8789
export MULTICA_PLUGIN_DEV_CA=/path/to/your-dev-ca.pem

Publish it from MULTICA_PLUGIN_DIR as deploy-sentinel, install the version that appears, fill in the config form, then open the metrics approval panel and tick the tools you want agents to reach. Nothing under metrics is callable until you do — that is the whole difference between the mcp transport and an http hook.

Note what MULTICA_PLUGIN_DIR does and does not cover. It is a publishing shortcut for the FRONTEND artifact only; the two servers above are still yours to run, and the net: scopes are still what authorises reaching them.

Both endpoints must be HTTPS even locally: the manifest validator requires it, and MULTICA_PLUGIN_DEV_CA is how you get a self-signed certificate trusted. The dev switches change which certificate is trusted and whether the address must be public. They never disable verification, and they never widen the net: scopes — a hook still cannot reach a host the administrator did not approve.

Try breaking it

  • Ask an agent to roll back d-4802. It is 380 minutes old; the plugin refuses and explains why.
  • Ask for a rollback with the reason "broken". Refused — too short.
  • Add a tool to server/metrics-mcp.mjs and reopen the approval panel. It appears unapproved, and agents cannot call it until somebody ticks it.
  • Change an approved tool's inputSchema and restart. The agent's task starts fine, but that connection is refused: the schema no longer matches what was approved.

Tested

server/internal/handler/plugin_example_test.go installs this manifest, not a copy, and drives it: the skill lands in the skill table, the agent tool list contains exactly the hooks that declare the agent trigger, an agent hook call goes out signed and comes back, and the metrics tools are discovered, refused before approval, and pinned by schema digest after it.

The .mjs files are what you run by hand. The test uses Go servers that answer the same contract, so CI does not need node.