32 KiB
id, title, status, system, owners, created, last_updated, requirements
| id | title | status | system | owners | created | last_updated | requirements | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| plugins-isolated-web-app-contributions-design | Plugin web-application contributions system design | draft | plugins |
|
2026-08-26 | 2026-09-21 |
|
Plugin web-application contributions system design
Purpose and boundaries
The Plugins system provides packaged web_app documents beside native bundles.
The target runtime uses trusted same-origin iframes, not opaque-origin isolation.
It does not import package code into the SPA module registry.
The same-origin decision amends the browser trust boundary. Implementation is complete under the same-origin delivery plan.
This design owns plugin instances, immutable releases, capability grants, runtime tokens, web data access, shared instance state, and live browser events. The Canvases system owns canvas scope changes and user workflows.
The canvas distribution design adds a static distribution profile and an inert retained project-source subtree. Preview images belong to registry listings. The profile reuses this runtime boundary; native plugin package installation does not become a canvas import path.
This design implements ADR-2026-08-26-plugin-backed-web-app-canvases. It extends the data boundary from ADR 0043.
Requirement mapping
| Requirement | Design sections |
|---|---|
REQ-PLUGINS-ISOLATED-WEB-APPS-001 |
Web-application contribution, Browser host |
REQ-PLUGINS-ISOLATED-WEB-APPS-002 |
Package and release contract, Release activation |
REQ-PLUGINS-ISOLATED-WEB-APPS-003 |
Instance grants, Request authorization |
REQ-PLUGINS-ISOLATED-WEB-APPS-004 |
Browser data protocol |
REQ-PLUGINS-ISOLATED-WEB-APPS-005 |
Shared instance state |
REQ-PLUGINS-ISOLATED-WEB-APPS-006 |
Live event protocol |
REQ-PLUGINS-ISOLATED-WEB-APPS-007 |
Browser security boundary |
REQ-PLUGINS-ISOLATED-WEB-APPS-008 |
Plugin instance model, Compatibility |
REQ-PLUGINS-ISOLATED-WEB-APPS-009 |
Diagnostics and observability |
REQ-PLUGINS-ISOLATED-WEB-APPS-010 |
Artifact storage, Protocol compatibility |
REQ-PLUGINS-ISOLATED-WEB-APPS-011 |
Host appearance protocol |
REQ-PLUGINS-ISOLATED-WEB-APPS-012 |
Runtime startup protocol |
REQ-PLUGINS-ISOLATED-WEB-APPS-013 |
Same-origin transport and trust |
Existing plugin contracts
The implementation extends these current boundaries:
internal/plugins/manifestowns manifest parsing and validation.internal/plugins/pkgtarowns safe plugin package extraction.internal/pluginsowns installation, activation, actions, Host data, state, events, and agent tools.pkg/pluginsdkandproto/kandev/plugin/v1/plugin.protoown the managed backend contract.apps/packages/plugin-sdkowns the trusted native frontend contract.lib/pluginsloads native bundles and stores frontend contributions.
The new web-application runtime does not use PluginHostApi or
PluginRegistry. Existing native bundles keep those interfaces unchanged.
Web-application contribution
Extend manifest.UISection with web_apps:
ui:
web_apps:
- key: main
title: Task board
entry: ui/index.html
placements: [task-canvas, workspace-canvas]
network_origins: [https://api.example.com]
key is unique inside the plugin. entry is a package-relative HTML file.
The first placement vocabulary contains task-canvas and
workspace-canvas. network_origins is an optional list of exact HTTPS
origins for this web application. It cannot contain paths, credentials,
wildcards, query strings, or fragments. Later specifications can add other
host placements.
The host owns the displayed navigation label, icon, scope, route, and actions. The supported canvas UI occupies its document rectangle. Trusted same-origin code can also access the parent document; geometry is not a security boundary.
A package can contain these contribution combinations:
| Package form | Native bundle | Web application | Managed backend |
|---|---|---|---|
| Existing native plugin | optional | none | required |
| Static canvas package | none | required | none |
| Managed web plugin | optional | required | required |
A web-application-only package omits runtime. A managed plugin keeps
runtime.type: binary and can use declared authenticated actions.
Plugin instance model
Installed plugin records stay the source for package identity and managed backend lifecycle. Add explicit plugin instances for scoped activation.
plugin_instances
idplugin_idsource_kindasinstalledorlocal_canvasscope_kindasinstance,workspace,task,session, orrepository- nullable trusted scope identifiers
statusaspending,active,disabled,archived,error, orremoved- nullable
active_release_id - monotonic
grant_generationincremented on scope, release, status, or grant changes - timestamps
Only identifiers required by scope_kind can be set. A database constraint and
service validation reject incomplete or mixed scopes.
Existing installed plugins receive one implicit instance scope. This
compatibility projection does not change current settings, enablement, process
lifecycle, or native UI loading.
plugin_releases
idplugin_idpackage_digestsource_kindsource_actor_kind- nullable source user, task, and session identifiers
manifest_jsondeclared_permissions_jsonartifact_pathvalidation_status- nullable safe validation error
created_at
Release rows and extracted artifacts are immutable. The artifact path is under the Kandev data directory. It is not an agent workspace path.
plugin_instance_grants
plugin_instance_idpermission_kindresource- optional exact HTTPS origin
- trusted scope ceiling
- approving user and time
The service normalizes grants before persistence. It rejects a grant that the active or pending release does not declare.
Package and release contract
A local web application package contains the manifest, source, built static files, and optional metadata. Kandev serves only files under the declared web root. It stores the source so an edit agent can create the next draft.
The agent builds any TypeScript, React, or other source in its task executor. Kandev does not install dependencies or run package scripts during validation. Simple canvases can use direct HTML, CSS, and JavaScript without a build step.
Initial package limits are:
- 10 MiB compressed package
- 25 MiB expanded package
- 512 files
- 5 MiB for one file
- 64 KiB manifest
- 240 bytes for one normalized path
Retained artifacts use these initial storage limits:
- 2 GiB for one workspace
- 10 GiB for one Kandev installation
- one active valid release, one prior valid release, and one pending release for each plugin instance
The admission transaction reserves the compressed artifact bytes before it moves an artifact into immutable storage. A concurrent publish cannot exceed a workspace or installation limit. A rejection does not create a release row or change the active release.
The validator accepts HTML, CSS, JavaScript, JSON, common web images, fonts, and source-map files. It uses content sniffing and file extensions. It rejects device files, links, duplicate normalized paths, traversal, absolute paths, and files outside the package root.
The capability path is rooted at the declared entry directory for ordinary
relative asset requests. For example, ui/index.html can load ./app.js and
./app.css from ui/. The reserved _kandev/v1 path remains rooted at the
capability URL and is dispatched before ordinary asset mapping.
pkgtar provides the extraction pattern. Web packages use a separate validator
because they can omit a managed backend executable.
Release activation
Release activation uses these steps:
- Stream the bounded package into a temporary directory.
- Validate all package paths and sizes before extraction.
- Parse and validate the complete manifest.
- Validate each declared web application and its entry document.
- Compute the package digest and declared permission set.
- Move the validated artifact into immutable release storage.
- Create the release row.
- Compare the permission set with the current instance grants.
- Activate the release in one transaction when no new grant is required.
- Publish one instance lifecycle event after commit.
If the release needs a new grant, the service records it as pending. The active release stays unchanged. A user can approve the permissions and activate the pending release in one transaction.
The canvas service can supply recorded initial creation authority through its creation transaction. The instance store validates and inserts the exact grants in that transaction. The generic plugin publication and import paths cannot infer this exception from a package manifest, source-user field, or instance ownership.
The initial retention rule keeps the active release and one prior valid release for each local canvas instance. A pending release is also retained until approval, replacement, or removal.
Instance grants
The effective permission calculation is:
manifest declaration
intersect instance grant
intersect trusted instance scope
intersect current caller authorization
The permission vocabulary reuses current manifest capabilities:
api_readapi_writeeventsstate- declared authenticated
actions
Add exact HTTPS origin grants for browser network access. Each web application
declares its own network_origins, so one app cannot inherit another app's
external origins. Remote script origins are not grantable in this version.
Task-scoped instances default to the current task as their scope ceiling. Workspace access requires a separate approved grant. Promotion performs a new review because it changes the scope ceiling.
Browser host
Add one host component for web applications. The component requests a runtime URL for an authorized instance, release, placement, and user. The backend returns a short-lived capability URL.
WebAppFrame and BuildContentSecurityPolicy both declare
sandbox="allow-scripts allow-forms allow-same-origin". Both must change in
one implementation slice. Either opaque policy would otherwise retain the failure.
The runtime keeps its served origin, including top-level capability navigation.
Form submissions remain denied by form-action 'none'.
The host injects no native API or credential value. Appearance and startup messages stay bounded. Host chrome remains outside the frame for presentation; trusted frame code can access that chrome through normal same-origin DOM APIs.
The desktop shell adds a narrow frame-src rule for the loopback backend.
BuildContentSecurityPolicy always prepends the literal CSP source 'self'
to frame-ancestors, then appends normalized explicit launcher and Tauri
origins. Keep normalizeFrameAncestors restricted to exact origins; the host
adds the keyword separately. Never derive ancestors from inbound Host, Origin,
Referer, or forwarded headers, and never use wildcard hosts or ports.
Relative capability URLs keep the browser UI and runtime on the same public origin, including TLS termination at a reverse proxy. Each DNS alias works independently without a configured list. An alias does not authorize framing a different alias. Cross-origin frontend/runtime hosting requires an explicitly trusted origin; this repair adds no new operator configuration for that topology. Preserve the current exact development ports and Tauri exceptions. Framing permission and browser same-origin identity remain separate decisions. Browser coverage must use real runtime responses through two custom HTTPS hosts and an unrelated-parent negative case, not just assert the header text.
Host appearance protocol
The isolated runtime has one host-to-frame presentation message. Version 1 has this shape:
{
"type": "kandev.web_app.appearance",
"version": 1,
"mode": "dark",
"tokens": {
"background": "...",
"foreground": "...",
"card": "...",
"cardForeground": "...",
"muted": "...",
"mutedForeground": "...",
"border": "...",
"primary": "...",
"primaryForeground": "...",
"accent": "...",
"accentForeground": "...",
"destructive": "...",
"destructiveForeground": "...",
"ring": "..."
}
}
WebAppFrame resolves these values from the current host theme. Each value is
a bounded serialized CSS color from a fixed key allowlist. The envelope
contains no identity, capability, data, storage, navigation, or action field.
After the iframe load event, the host sends the initial envelope to that
iframe's contentWindow. Reveal additionally requires the current startup
acknowledgement described below and one animation frame for appearance. The
host sends another envelope when the resolved theme changes without reload.
The existing appearance sender targets the exact frame window with
targetOrigin: "*" to retain support for web and distinct-origin desktop hosts. The application listener accepts only messages whose
source is window.parent and whose type, version, mode, keys, and value bounds
match the contract. This source check prevents a sibling frame from setting
appearance values. The wildcard does not grant authority because the payload
contains public presentation data only.
The bundled scaffold maps the token keys to documented CSS custom properties. It includes safe light and dark fallbacks. An application can ignore this message, but it does not receive another theme API or a privileged reply channel.
Runtime startup protocol
The plugin runtime owns a bounded startup monitor, not authored application code or an optional scaffold. During entry-HTML serving, insert a host-owned bootstrap before authored scripts using the existing Go HTML tokenizer dependency. Preserve the doctype, encoding, entry-relative paths, and original markup bytes around the insertion. Support valid HTML with an omitted head. Bound processing by the entry-file limit; fail safely if insertion cannot be performed. Non-entry assets stay byte-identical. Stored files, digests, and edit source never change. Compute Content-Length from the served representation.
Serve the bootstrap from a reserved capability-relative
_kandev/host-runtime.js route before versioned protocol dispatch. Validate the
capability and apply normal runtime headers. Package files cannot shadow it.
The bootstrap installs capture-phase script/asset error and unhandled-rejection
listeners before authored code. It waits for document load, then fetches
./_kandev/v1/context using the existing capability path and
credentials: "same-origin". Never use omit for this host-owned request. It reports only safe
startup result codes. A handled application data error can still render inside
a successfully started frame; Ready is not proof of application correctness or
business-operation success.
WebAppFrame creates a fresh bounded attempt nonce for every mount/runtime URL.
After load it sends kandev.web_app.startup_probe version 1 with that nonce to
the exact frame window. The bootstrap accepts only window.parent and echoes
the nonce with kandev.web_app.startup_result, version 1, and result ready or
failed after the startup outcome is known. It retains one pending probe and
one outcome; no repeating message or polling loop is required. Failure codes
are a closed vocabulary (document_error, context_unavailable). Arbitrary
error text and request URLs are never forwarded.
The parent checks exact message shape, version, nonce, and
event.source === iframe.contentWindow. Origin alone is not sufficient
authority. Same-origin code is trusted and can access host state directly.
Replies from a previous mount, sibling window, wrong nonce, or unsupported
version are ignored. The reply changes presentation
only. No domain APIs, grants, navigation, or credentials cross this channel.
Top-level capability navigation has no host probe and remains sandboxed.
The 15-second startup deadline begins when the iframe is mounted. It is separate from the 15-minute capability lifetime. No acknowledgement, including CSP-blocked navigation, causes unavailable status and frame teardown. Retry obtains a fresh descriptor and nonce through existing host actions. Late replies cannot clear failure; listeners and timers are cancelled on teardown. Authority changes still unmount the frame immediately. Renewal mounts a new attempt; live theme changes do not restart startup. Existing retained releases receive the bootstrap at serve time and need no author edits or republish.
Runtime token
The runtime URL contains a random capability segment. The backend stores only a digest of this value. The capability expires after 15 minutes and can use a bounded sliding renewal while the host page remains authorized.
The capability binds these values:
- user identity
- plugin instance
- active release
- web-application key
- placement
- scope kind and trusted resource identifiers
- grant generation
Each request loads the current instance and compares these bindings. A scope, grant, release, status, or access change revokes the old capability on its next request.
Approved external origins are a deliberate direct-browser exception to this per-request runtime check. Kandev cannot inspect a request after the browser has sent it to a third-party origin. The WebSocket lifecycle broadcaster sends an authority-change notification to the host, and the host unmounts the matching iframe immediately. The replacement iframe is mounted only after a fresh HTTP metadata and runtime-binding load. This is the revocation boundary for direct external requests. Kandev runtime and protocol requests still run the binding validator on every request.
The runtime route accepts the capability independently of ambient session cookies.
A reverse proxy can require its own cookie before forwarding the request.
Kandev does not accept a runtime token from a general header or query field.
Legacy opaque-origin CORS responses remain restricted; no credentialed null
origin or wildcard CORS permission is added.
Browser data protocol
The capability path is the application base. The application uses relative requests. Example paths are:
GET ./_kandev/v1/context
GET ./_kandev/v1/data/tasks
GET ./_kandev/v1/data/tasks/{id}
PATCH ./_kandev/v1/data/tasks/{id}
POST ./_kandev/v1/data/tasks/{id}/messages
GET ./_kandev/v1/data/workflows
GET ./_kandev/v1/data/workflows/{id}/steps
GET ./_kandev/v1/state
GET ./_kandev/v1/state/{key}
PUT ./_kandev/v1/state/{key}
DELETE ./_kandev/v1/state/{key}
GET ./_kandev/v1/events
POST ./_kandev/v1/actions/{key}
context returns only public instance metadata, scope identifiers that the
application can already use, active release identity, and effective
capabilities. It does not return a user token, Kandev session, filesystem path,
secret, or private plugin configuration.
The data routes reuse the DTOs, filters, opaque pagination, and conservative
write fields from the Plugin Host data API. The shared task projection adds
workflow_step_id. The browser and gRPC adapters return this field with the
same nullability and authorization rules.
The task message route reuses the Plugin Host SendMessage service. It accepts
a bounded prompt and an optional session ID. The runtime context supplies the
plugin instance, user, workspace, and source attribution. A static canvas uses
this route for actions such as sending continue to a task.
Extract shared application services from internal/plugins/host_data_* when
the current gRPC handler owns logic that both adapters need.
All writes use Kandev domain services. They never write a repository directly. The service publishes the same task and workflow events as first-party writes.
Initial browser request limits are 256 KiB for a request and 1 MiB for a response page. Each request has a 30-second deadline and supports cancellation.
Shared instance state
Add plugin_instance_state or extend plugin_state with a non-null instance
identity. The migration must preserve current instance-global plugin state.
Each entry contains:
- plugin instance
- state key
- JSON value
- monotonic revision
- update time
- safe writer kind
The browser uses If-Match with the current revision for an update or removal.
A stale request returns 409 plugin_state_conflict with the current revision.
The response does not include the current value unless the caller still has
read permission.
Canvas agent tools use the same state service. They derive the plugin instance from trusted canvas and session context.
Live event protocol
The browser opens the relative Server-Sent Events endpoint. The event gateway subscribes to declared Kandev event subjects and filters every event through the current instance scope.
The stream sends public, bounded DTOs. It never forwards internal event objects or unfiltered plugin bus payloads.
Each stream has a process generation and monotonic sequence. A bounded
in-memory ring retains recent events for active instances. The browser sends
Last-Event-ID after reconnect.
The initial stream contract uses these operational limits:
- one heartbeat comment every 15 seconds
- immediate header and event flushing
- at most two streams for one user and instance
- at most 20 streams for one user
- at most 1,000 retained events or five minutes of replay for one instance
The response sets Cache-Control: no-cache, no-transform and
X-Accel-Buffering: no. The server releases subscriptions and counters when
the request context ends. EventSource cannot set an authorization header, so
the event endpoint uses the capability path and accepts no query token.
The gateway sends runtime.resync_required when the generation changed or the
sequence is no longer available. The application then reloads its authoritative
data and state snapshot. Domain data and plugin state remain the sources of
truth.
Managed plugin actions
A managed plugin can declare a web application and authenticated actions. The
relative action route reuses the authorization, scope verification, deadlines,
body limits, status projection, and cancellation contract from
POST /api/plugins/:id/actions/:key.
A static web-application-only package has no custom action handler. Its browser code uses the Host data and state routes. A later backend-runtime decision can add another handler type without changing the browser protocol.
Browser security boundary
The runtime document response uses this minimum header policy:
Content-Security-Policyincludessandbox allow-scripts allow-forms allow-same-origin,default-src 'none',form-action 'none',base-uri 'none', andobject-src 'none'frame-ancestorscontains the host-owned'self'keyword and normalized explicit Kandev launcher and Tauri originsX-Content-Type-OptionsisnosniffReferrer-Policyisno-referrerCross-Origin-Resource-Policyremainscross-originfor distinct-origin Tauri hosts- capability-bearing HTML and API responses use
Cache-Control: no-store
The response uses a restrictive resource policy:
- packaged and inline application scripts can run
- dynamic code evaluation is denied
- remote scripts are denied
- styles and packaged assets can load
- data images can load within a bounded policy
- data connections can reach the runtime API
- approved HTTPS origins can receive explicit
connect-src, image, or font access while the iframe binding is current - direct external requests are not server-proxied in this version. Lifecycle authority notifications tear down the iframe before a replacement load
- framing is limited to the Kandev host
The implementation must build the policy from normalized grants. It must not copy manifest text into a response header.
Runtime headers remain host-owned. They do not grant broader service-worker scope. Same-origin code can access origin storage and the parent document; service-worker unavailability must not be claimed as an isolation guarantee. Authors use instance state for shared values and memory for temporary values. They must not modify host internals or depend on shared browser-storage keys. These are authoring rules, not enforced browser boundaries.
Same-origin transport and trust
This change applies to the shared ui.web_apps runtime, including local,
imported, and installed-plugin canvases. It adds no manifest option, profile
switch, database migration, or separate iframe transport.
The startup bootstrap uses credentials: "same-origin". Standard relative
fetch, XHR, asset loads, and EventSource requests use their same-origin
browser behavior. No global fetch shim rewrites authored requests. Retained
packages that explicitly omit credentials need an author edit and republish.
Cross-origin credential delivery, including separate-origin desktop hosts,
is not enabled globally. Capability-only local operation remains supported.
The proxy must preserve the public Host for writes that carry Origin.
corsMiddleware already accepts matching-host origins through AllowedOrigin.
Do not trust arbitrary forwarded headers or broaden the general CORS policy.
The HTTPS test proxy must preserve browser cookies and public Host; it must not
supply missing cookies from its Node client or mock successful protocol replies.
The proxy cookie only passes the outer gate. Runtime.Serve still validates
its token and binding before assets, data, state, actions, and events. Cookies
must not change the bound actor, release, scope, or grant generation. Invalid
capabilities and denied writes still fail through existing backend services.
Direct first-party API requests use the ordinary user authorization boundary;
canvas grants do not constrain them. Revoking a runtime token cannot undo DOM
changes, shared storage, or callbacks already installed in the parent page.
Keep script-src, network grants, form policy, and exact frame ancestors.
These govern the served document, not trusted code acting through its parent.
Proxy HTML injection remains incompatible with the startup monitor and CSP.
Operators must exclude runtime responses from Cloudflare RUM and other
injected scripts that violate policy. Do not allow remote analytics scripts
or suppress document errors to conceal this problem.
UI behavior stays unchanged. Test Ready, data, a permitted write, and an event through a real local cookie gate. Test missing cookies, reauthentication and Retry, invalid tokens, and unchanged package bytes. Record desktop and phone results separately.
Compatibility
The native plugin frontend remains an in-process trusted extension. A package
must declare ui.bundle to use that path. A ui.web_apps declaration does not
receive host.api, host.storage, host.context, host.ui, or registry
methods.
The managed backend gRPC contract remains unchanged for a static web application. Managed plugins can combine both frontend contribution types.
The plugin list and settings API add instance and web-application projections without removing current installed-plugin fields.
The ./_kandev/v1 browser protocol remains available while any retained
release declares version 1. A later protocol version uses a new relative path.
Kandev does not rewrite an immutable release. If a future Kandev version cannot
serve a retained protocol, startup marks the release unavailable before it can
execute and the host shows edit, rollback, or removal actions.
Artifact storage and recovery
Validated artifacts remain immutable files under the Kandev data directory. The database stores their digest, relative path, byte count, and availability. The artifact directory is part of the Kandev-home recovery boundary.
SQLite database snapshots do not contain release artifacts. PostgreSQL backups also require a backup of the Kandev artifact directory. Public operations and configuration documentation state this boundary.
Startup reconciles retained release rows with the artifact inventory before it
registers runtime routes. A missing file, digest mismatch, or unsafe path marks
the release plugin_release_unavailable. Reconciliation never executes or
silently removes the artifact. It records a content-free diagnostic and leaves
the release metadata available for recovery.
Workspace and instance removal create durable artifact-cleanup jobs in the same transaction that removes release ownership. A worker removes the files after commit and retries after restart. This order preserves cleanup inventory if the process stops between the database change and file removal.
Failure and recovery
- An invalid package never enters release storage as a valid release.
- An activation transaction publishes no event before commit.
- A stale runtime token returns
runtime_token_stale. - A revoked grant returns
plugin_permission_denied. - A stale state write returns
plugin_state_conflict. - A lost event range returns
runtime.resync_required. - A missing active release returns
plugin_release_unavailable. - An unavailable managed backend disables its action routes but does not block static asset and Host data routes.
The browser host maps stable codes to localized messages. It never shows raw backend or package errors.
Observability
Record bounded result counters and durations for validation, activation, runtime access, data/state operations, events, policy denials, and artifact storage. Logs may identify plugin, instance, release, operation, result, and duration. Never log source, bodies, state values, event payloads, tokens, or credentials.
Test strategy
- Validate manifests, package combinations, traversal, links, duplicates, limits, digests, activation, grant intersection, and SQLite/PostgreSQL migrations.
- Exercise runtime bindings, stale tokens, CORS, safe errors, shared state, pagination, and browser/gRPC projection parity.
- Verify both sandbox declarations, cookie delivery, form and remote-script denial, nested assets, exact ancestors, and capability revocation.
- Verify scoped events, replay, generation changes, and resync.
- Cover failed documents/assets/context, absent and stale acknowledgements, sibling replies, timeout cleanup, retries, renewal, and unchanged artifacts.
- Verify initial and live appearance in direct, Dockview, and phone hosts.
- Execute the cookie-proxy and authentication tests in the same-origin delivery plan.