Specification

Cirrulet v1 extension platform

Maintained beside the host it describes, and published here from docs/plugin-platform.md at a82b8bab2fda. New here? Start with Build a plugin.

Status: architecture, plus a shipped vertical slice
Audience: host, runtime, SDK, store, and security implementers
Last reviewed: 2026-08-20

Most of this document is the target design. Section 22 records what the code actually does today, including the packaging contract a marketplace must build against, and the gaps that remain. Where the two disagree, section 17 wins.

1. Context

The current repository is a Swift 6.2 package for an Apple Silicon, macOS 26+ accessory app. Its existing targets deliberately separate immutable activity and screen values (EdgedDomain), AppKit screen discovery (EdgedInfrastructure), SwiftUI/AppKit island presentation (EdgedUI), and process composition (EdgedApp). ActivityPresentationStore is main-actor isolated, while provider input is normalized and ranked before presentation. The extension platform must preserve those dependency and actor boundaries.

Cirrulet should provide a Raycast-like local extension experience without loading third-party code into the host process. Extension authors use TypeScript or JavaScript. The host owns rendering, permissions, process supervision, updates, and privileged operating-system access.

The v1 host target is darwin-arm64, matching the current app. Package and protocol fields remain platform-neutral so additional hosts can be implemented later without changing extension identity.

The central security claim is deliberately narrow:

An extension is untrusted code. A subprocess boundary provides crash isolation, but it is not by itself a security boundary. Cirrulet may claim security isolation only where a restricted JavaScript runtime, an operating-system sandbox, and host-side capability enforcement all succeed.

1.1 Goals

  • A fast TypeScript/JavaScript authoring loop with typed APIs and hot reload.

  • One isolated subprocess per active extension, communicating with the host over a versioned JSON-RPC protocol.

  • Declarative local actions, launcher providers, background work, and event subscriptions.

  • Native, accessible, host-rendered UI from a constrained schema.

  • Optional isolated webviews for experiences that cannot fit the native schema.

  • Default-deny, manifest-declared capabilities with host-owned permission prompts.

  • Reproducible packages, signed distribution, safe updates, rollback, and third-party registries.

  • Optional MCP interoperability without making MCP the internal extension protocol.

  • A useful, local-first open core that does not require the hosted Cirrulet service.

1.2 Non-goals for v1

  • In-process plugins or a stable ABI for native libraries.

  • Arbitrary native Node addons, FFI, shell execution, child processes, listening sockets, or post-install scripts.

  • Arbitrary HTML or CSS inside native surfaces.

  • A browser extension model or access to browser cookies.

  • Extension-to-extension code imports or direct process communication.

  • Mobile support.

  • Guaranteed execution of every npm package. Packages must bundle without native code and operate through the Cirrulet SDK or supported web-compatible APIs.

  • A general-purpose daemon manager. Background work is event-driven, bounded, and supervised.

2. Product and licensing model

Cirrulet should use a true open-core model: the local product remains useful without a commercial service.

2.1 Open core

The recommended license for original core code is Apache-2.0 because it is permissive and includes an explicit patent grant. The open core should include:

  • Manifest, package, protocol, UI, registry, and signature specifications.

  • JSON Schemas and protocol conformance fixtures.

  • TypeScript SDK and testing utilities.

  • CLI, bundler, package verifier, and local development host.

  • Extension runtime, supervisor, capability broker, and native schema renderer.

  • Local install, sideload, local registry support, and manual updates.

  • MCP adapter and a reference registry implementation.

The Cirrulet name and logo can remain protected trademarks. Extension authors choose their own SPDX-declared license. A community registry may require an OSI-approved license as policy, but the package format must not require MIT or prohibit private extensions.

2.2 Commercial layer

Commercial services may provide:

  • Hosted public and private registries.

  • Automated malware analysis and package review.

  • Verified publisher identity and managed signing-key recovery.

  • Team policy, allowlists, audit logs, and approval workflows.

  • Cross-device settings and extension synchronization.

  • Fleet rollout, hosted telemetry, and support.

The commercial layer must use the public package and registry protocols. Sideloading and alternate registries must remain available in the open core.

3. Architecture

                         optional hosted services
                    registry / review / team policy
                                  |
                                  v
+--------------------------- Cirrulet host -----------------------------+
| launcher + command router                                             |
|                                                                       |
|  permission UI    package manager    event scheduler    MCP gateway   |
|         \              |                   |                /          |
|          +--------- capability and lifecycle supervisor -------------+|
|                                |                                      ||
|                      versioned JSON-RPC                               ||
|                                |                                      ||
|                +---------------+----------------+                     ||
|                | isolated extension subprocess |                     ||
|                | bundled JS runtime + SDK       |                     ||
|                +---------------+----------------+                     ||
|                                | UI document / patches                 ||
|                                v                                      ||
|                   validator -> native renderer                        ||
|                                |                                      ||
|                       optional webview broker                          ||
+-----------------------------------------------------------------------+

The host is the sole authority for:

  • Package identity and active version.

  • Capability grants and user-gesture state.

  • Secrets, external files, network requests, clipboard, notifications, and OS integration.

  • Native rendering and webview creation.

  • Process resource limits, cancellation, logs, and crash recovery.

  • Event delivery, scheduling, provider indexing, and MCP transport.

An extension subprocess receives:

  • Its immutable bundle and declared assets.

  • A dedicated JSON-RPC channel.

  • A minimal locale/theme/platform snapshot.

  • The capabilities already granted for the current session.

  • No secrets in arguments, environment variables, command-line flags, or process-global configuration.

The subprocess must not receive ambient access to the user's home directory, host environment, browser state, network, or other extension data.

3.1 Integration with the current Swift shell

The extension platform is a set of new host services, not a replacement for the existing activity model or panel ownership:

EdgedExtensionProtocol     Codable, Sendable manifests/RPC/UI DTOs
          |
          +--> EdgedExtensionRuntime    actor-based process/RPC supervisor
          |
          +--> EdgedExtensionHost       packages, grants, brokers, events
                         |
                         +--> ExtensionActivityBridge --> EdgedDomain.Activity
                         |
                         +--> EdgedExtensionUI --> validated native surfaces

EdgedApp composes services and hands presentation snapshots to EdgedUI.

Recommended target boundaries:

  • EdgedExtensionProtocol depends only on Foundation-compatible value types. It contains no AppKit, SwiftUI, process launch, Keychain, or registry networking.

  • EdgedExtensionRuntime owns subprocesses, byte framing, JSON-RPC state, deadlines, and resource accounting in actors. It does not update UI or hold secrets.

  • EdgedExtensionHost owns package verification, grants, host method dispatch, storage, scheduling, and normalized outputs. Platform adapters remain behind protocols so security policy can be tested without AppKit.

  • EdgedExtensionUI converts only validated UI DTOs into host views. Extension code and arbitrary decoded dictionaries never reach SwiftUI.

  • EdgedApp remains the composition root. Startup and termination start/stop the extension supervisor alongside IslandPanelCoordinator.

The current shell is a SwiftPM executable without an app bundle, signing identity, or sandbox entitlements. That is sufficient for the UI foundation but not for public untrusted extensions. A signed application target and a separately signed extension-runtime helper are prerequisites for Stage 3.

The media/system-surface design may require a hardened, directly distributed main app rather than App Sandbox. Extension workers must not inherit that main process's broader filesystem, automation, or private-helper access. If the main app stays unsandboxed, a dedicated, sandboxed runtime launcher owns extension subprocesses and exposes only the private JSON-RPC channel plus a narrow host IPC bridge. MediaRemote, SkyLight, Apple Events, and other provider-specific privileges are never made available to extension workers.

A plugin publishes a bounded activity through the island's live-activity methods, under ui.island; section 10.4 states that contract. The host sets sourceIdentifier from the verified plugin ID so it cannot be spoofed, namespaces IDs per plugin, normalizes kind, phase, text, progress, priority, and timestamps by the same rules as core providers, reserves critical and system presentation, and clears a plugin's activities on disable, uninstall, revocation, or expiry. Runtime actors emit immutable snapshots, and a main-actor adapter — not the worker or the RPC decoder — updates ActivityPresentationStore.

The island is therefore the first constrained native surface. A full launcher list/detail/form renderer is an additional EdgedExtensionUI surface and must reuse the host's panel/focus policies rather than letting extensions create windows.

3.2 Runtime choice

The package target is edged-js-v1, not a public Node or Deno ABI. Source TypeScript is bundled to one ESM worker plus assets. This keeps the authoring model stable if the underlying engine changes.

The recommended first implementation is a pinned, host-bundled Deno-class runtime because it supports TypeScript/JavaScript and deny-by-default runtime permissions. It should run with:

  • Network, external filesystem, subprocess, FFI, native addon, environment, and interactive permission prompts disabled.

  • Only the immutable package directory readable.

  • Dynamic remote imports disabled and dependency resolution completed at build time.

  • fetch and selected Web APIs replaced or wrapped by SDK implementations that call the host broker.

This runtime restriction is defense in depth, not a substitute for an operating-system sandbox. A smaller embedded engine such as QuickJS remains an option if public-store threat modeling shows that a general runtime has too much attack surface.

3.3 Process model

  • Each active extension gets its own subprocess and process group.

  • Different extensions never share a JavaScript isolate, runtime process, writable directory, or webview data store.

  • Contributions within one extension share a worker in v1. The SDK serializes handler calls by default; provider query cancellation is explicit.

  • Workers are spawned on demand. Ordinary actions do not create a permanent daemon.

  • A declared background provider may remain warm for a bounded idle period when the user grants background capability.

  • Durable events can cold-start a worker. The worker exits after completion or its idle deadline.

  • All descendants are prohibited in v1. The supervisor kills the entire process group on shutdown or policy violation.

The transport uses a dedicated inherited socket or pair of anonymous pipes, not stdout. The implemented macOS/POSIX contract maps a private full-duplex socket to child file descriptor 3 and advertises EDGED_PROTOCOL_FD=3 plus EDGED_PROTOCOL_FRAMING=ndjson-v1 in a fixed environment containing only those transport keys, LANG=C, LC_ALL=C, and PATH=/usr/bin:/bin; no host environment entries are inherited. Standard input is /dev/null. Stdout and stderr are separately captured diagnostic streams, so an accidental console.log cannot corrupt the protocol. A future non-Unix launcher may use an inherited named-pipe handle while preserving the same separation.

4. Authoring and package format

4.1 Source project

An extension is a normal npm project. package.json remains valid npm metadata and contains one top-level edged object. This follows familiar JavaScript tooling while keeping Cirrulet-specific fields namespaced.

Typical source layout:

github-extension/
├── assets/
│   └── icon.png
├── src/
│   └── extension.ts
├── test/
│   └── extension.test.ts
├── package.json
├── package-lock.json
└── tsconfig.json

package-lock.json, pnpm-lock.yaml, or yarn.lock is required for registry publication. The build runs with lifecycle scripts disabled and fails on native addons or unresolved dynamic imports.

4.2 Manifest

Future-facing manifest sketch (not accepted by the shipping SDK because its network, notification, and scheduling families have no app backends). The authoritative buildable manifest is examples/plugins/hello-edged/package.json, and section 10.2 lists the capabilities accepted today:

{
  "$schema": "https://schemas.edged.dev/package/v1.json",
  "name": "@acme/edged-github",
  "version": "1.2.3",
  "type": "module",
  "description": "Search and manage GitHub issues",
  "license": "MIT",
  "engines": {
    "edged": ">=1.0.0 <2.0.0"
  },
  "edged": {
    "manifestVersion": 1,
    "id": "dev.acme.github",
    "displayName": "GitHub",
    "publisher": "acme",
    "minimumMacOS": "26.0",
    "icon": "assets/icon.png",
    "runtime": {
      "target": "edged-js-v1",
      "entrypoint": "src/extension.ts",
      "protocol": "^1.0.0",
      "sdk": "^1.0.0"
    },
    "platforms": ["darwin-arm64"],
    "capabilities": {
      "required": [
        {
          "id": "github-api",
          "name": "network.fetch",
          "reason": "Read issues and submit requested changes",
          "scope": {
            "origins": ["https://api.github.com"],
            "methods": ["GET", "POST", "PATCH"]
          }
        },
        {
          "id": "github-token",
          "name": "secrets.use",
          "reason": "Authenticate brokered GitHub requests",
          "scope": {
            "keys": ["github-token"]
          }
        }
      ],
      "optional": [
        {
          "id": "notifications",
          "name": "notifications.post",
          "reason": "Notify when assigned issues change",
          "scope": {}
        },
        {
          "id": "issue-refresh",
          "name": "background.schedule",
          "reason": "Refresh assigned issues every 15 minutes",
          "scope": {
            "minimumIntervalMinutes": 15
          }
        }
      ]
    },
    "contributes": {
      "actions": [
        {
          "id": "open-issues",
          "title": "Open Issues",
          "description": "Search assigned GitHub issues",
          "handler": "actions.openIssues",
          "presentation": "view",
          "uses": ["github-api", "github-token"]
        },
        {
          "id": "create-issue",
          "title": "Create Issue",
          "description": "Create an issue in a repository",
          "handler": "actions.createIssue",
          "presentation": "view",
          "uses": ["github-api", "github-token"]
        }
      ],
      "providers": [
        {
          "id": "issues",
          "title": "GitHub Issues",
          "handler": "providers.issues",
          "minimumQueryLength": 2,
          "maximumResults": 50,
          "cacheTtlSeconds": 30,
          "uses": ["github-api", "github-token"]
        }
      ],
      "events": [
        {
          "id": "scheduled-refresh",
          "event": "schedule.tick.v1",
          "handler": "events.refresh",
          "schedule": "*/15 * * * *",
          "delivery": "at-least-once",
          "uses": [
            "github-api",
            "github-token",
            "issue-refresh",
            "notifications"
          ]
        }
      ],
      "preferences": [
        {
          "key": "github-token",
          "title": "GitHub token",
          "description": "Token used for GitHub API requests",
          "type": "secret",
          "required": true
        }
      ],
      "mcp": {
        "exports": [
          {
            "kind": "tool",
            "name": "github_search_issues",
            "action": "open-issues",
            "headlessHandler": "mcp.searchIssues"
          }
        ]
      }
    }
  },
  "scripts": {
    "dev": "edged extension dev",
    "test": "edged extension test",
    "build": "edged extension build"
  },
  "devDependencies": {
    "@edged/cli": "^1.0.0",
    "@cirrulet/sdk": "^1.0.0",
    "typescript": "^6.0.0"
  }
}

Normative rules:

  • edged.id is immutable, globally unique, lowercase reverse-DNS notation.

  • (publisher, id) and the publisher signing key form package identity.

  • edged.minimumMacOS is an optional release version in major.minor or major.minor.patch form. Omission defaults to the app floor, macOS 26, and validated manifests normalize it to three components (26.0.0). A manifest declaring anything older than the app floor is rejected. Prerelease and build metadata are invalid.

  • Contribution IDs and capability IDs are stable within an extension.

  • Every contribution references the exact capability entries it may use.

  • Capability scopes are static upper bounds. Runtime requests may narrow but never widen them.

  • Paths are relative, normalized UTF-8 paths. Absolute paths, .., symlinks escaping the package, duplicate archive paths, and case-colliding paths are invalid.

  • Unknown required manifest fields, capabilities, or contribution kinds fail closed.

  • The CLI verifies titles, icons, argument schemas, capability rationale, schedule limits, and cross-references before building.

4.3 Built artifact

edged extension build creates a deterministic .edgedx ZIP archive:

dev.acme.github-1.2.3.edgedx
├── manifest.json
├── dist/
│   ├── worker.mjs
│   └── worker.mjs.map
├── assets/
│   └── icon.png
├── integrity.json
├── sbom.spdx.json
├── LICENSE
└── signature.json
  • manifest.json is the normalized, canonical build manifest. Its runtime entry is dist/worker.mjs; source-only fields are removed.

  • Shipped today: the archive extension is .edgedplugin, the manifest inside it is package.json, the signature file is edged-package-signature.json, and the canonical runtime entry is dist/plugin.js. See section 22.1.

  • integrity.json lists the SHA-256, byte size, and media type of every entry except signature.json.

  • signature.json signs the canonical integrity.json digest and binds the extension ID, version, publisher, signing-key ID, and build timestamp.

  • The archive uses sorted paths, normalized permissions and timestamps, no extra ZIP fields, and fixed compression settings.

  • Source maps may be omitted from public packages or uploaded separately. If included, they are integrity-covered and treated as potentially source-revealing.

  • The maximum compressed and expanded sizes are enforced before extraction. Extraction is never performed by trusting archive paths.

5. Contribution model

5.1 Actions

An action is a user- or MCP-initiated operation visible in launcher search.

  • presentation: "view" returns a native UI document or opens an approved webview.

  • presentation: "no-view" returns a typed result, toast, copied value, or external-open request.

  • Arguments are declared as JSON Schema and collected by the host.

  • An invocation receives an immutable snapshot of arguments, locale, selected text or files only when permitted, and a host-generated user-gesture token.

  • Closing a view cancels its work unless the action explicitly starts a separately authorized background task.

5.2 Providers

A provider contributes results to global launcher search.

  • The host owns ranking, result merging, keyboard handling, and final invocation.

  • Queries are bounded requests, not an unending stream. v1 supports up to 100 items and an opaque continuation cursor.

  • The host debounces queries and cancels stale requests.

  • Results may reference only declared actions and validated asset handles. A result's actionId and its optional opaque payload reach the Plugins window, and picking the result dispatches that action with the payload as its arguments. Dispatch goes through the ordinary action path, so a result naming an action the manifest does not contribute, or one whose capabilities are not granted, is refused exactly as a launcher invocation would be. A result with no actionId is inert and is not presented as something to run. The host does not read payload: it is a round trip between two halves of the same plugin.

  • cacheTtlSeconds is a hint; the host may cache less or not at all.

  • A future host.provider.invalidate may ask the host to discard cached results. It is not exposed by the current SDK or app handler.

  • A provider may maintain an index in namespaced local storage, but it is not entitled to stay resident indefinitely.

5.3 Event subscriptions and background work

Subscriptions are declared in the manifest. No extension can subscribe dynamically to a broader event class.

Initial v1 events should be small and versioned:

  • host.started.v1

  • host.localeChanged.v1

  • host.themeChanged.v1

  • network.connectivityChanged.v1

  • schedule.tick.v1

  • preference.changed.v1

  • fileHandle.changed.v1 for explicitly granted handles

Sensitive events such as clipboard changes, active application changes, selected text, or browser navigation require separate capabilities and should be deferred until their privacy behavior is specified.

Delivery classes:

  • Volatile UI events are best effort.

  • Durable scheduled or file-handle events are at least once with a stable event ID and bounded retry policy.

  • Handlers must be idempotent. The SDK exposes the event ID for deduplication.

  • A background handler cannot cause a permission prompt. Missing permission produces a diagnostic and skips or retries according to policy.

  • v1 schedules have a minimum 15-minute interval, host-applied jitter, sleep coalescing, and no promise of exact wall-clock execution.

5.4 Notch-height live activities

Plugins extend the existing island contract; they do not receive a window or a second rendering surface. A plugin declares ui.island with scope.liveActivityIDs, bounded maximumPriority, maximumUpdatesPerMinute, and maximumLifetimeSeconds. Determinate progress also requires deterministicProgress: true; a routed action must appear in both scope.actionIDs and the manifest's action contributions.

The TypeScript SDK exposes:

const activity = island(host).live;
await activity.publish({
  activityID: "build",
  title: "Building",
  value: "42%",
  state: "active",
  icon: { kind: "system-symbol", name: "gearshape.fill" },
  progress: { value: 0.42, evidence: "build-job-7" },
  ttlSeconds: 30,
  priority: "normal",
});
await activity.update({ /* the complete activity with the same ID */ });
await activity.dismiss("build");

A determinate progress — on a live activity or on a deterministic-progress edge signal — may also declare how fast it is moving:

await activity.publish({
  activityID: "pomodoro",
  title: "Focus",
  state: "active",
  progress: {
    value: 1,
    evidence: "pomodoro-1",
    unitsPerSecond: 1 / 1500,
    direction: "draining",
  },
  ttlSeconds: 30,
  priority: "normal",
});

unitsPerSecond is a fraction of the whole per second and direction is "filling" or "draining", defaulting to "filling". Together they tell the host when the value arrives, and that changes two things. The island interpolates between samples and draws the fill itself, so a twenty-five minute countdown costs one publish rather than fifteen hundred updates. And the signal's lifetime comes from its arrival rather than from the 300-second lease open-ended plugin signals are held to — see §5.4's TTL note below.

Both fields are optional. Omitting unitsPerSecond, or dropping it on a later update, is how a plugin pauses a countdown: the fill freezes where it was last put and the signal goes back to the open-ended lease. A direction without a unitsPerSecond is rejected, because a value that is not moving is not moving in either direction, as is a rate outside 0 < unitsPerSecond <= 1, a rate on a signal with no progress value, and a rate whose arithmetic puts the arrival more than four hours out (island-progress-deadline-ceiling). Four hours is the ceiling the island clamps any plugin deadline to; the wire refuses to go past it rather than silently granting less than was asked for.

Progress needs two things beyond the numbers. The ui.island scope must set deterministicProgress: true, which is what makes a determinate fill a thing the user agreed to rather than a thing a plugin took. And the activity's state must be "active": a determinate ring says "this is proceeding on its own", a warning or error says "this needs you", and one badge cannot say both. A plugin that wants a gauge on a warning drops the ring and keeps the number in value.

These call host.island.live.publish.v1, host.island.live.update.v1, and host.island.live.dismiss.v1. Publish rejects an existing ID; update and dismiss reject stale or expired IDs. Each plugin may own at most two activities, but deterministic arbitration shows at most one activity from a plugin at a time so it cannot monopolize both notch wings.

The payload is host-rendered data only: bounded title/value, a semantic state, an approved SF Symbol or integrity-covered packaged asset, optional evidence-backed determinate progress or indeterminate state, bounded TTL and priority, and an optional declared host-routed action. Plugins cannot specify coordinates, wing, color, animation cadence, panel dimensions, height, window level, SwiftUI, or HTML. Cirrulet owns truncation, accessibility labels, Reduce Motion, left/right placement, notchless degradation, and the invariant that passive activity changes width only.

ttlSeconds is a lease on an open-ended signal, and a signal that knows when it ends does not need one: a countdown whose plugin dies is still a countdown that was always going to switch itself off. So a progress that declares a rate takes its lifetime from its own arrival, up to a four-hour ceiling for plugins, instead of the 300-second ceiling that otherwise applies. The bridge keeps recognising such an activity's ID for at least that long too, so a plugin can always update or dismiss a chip the user can still see. Everything else — the two-activity quota, maximumUpdatesPerMinute, maximumLifetimeSeconds, arbitration — is unchanged.

Live activity text and progress evidence are not written to audit/history records. Activities are cleared on dismiss, TTL expiry, capability revocation, disable, worker exit/crash, privacy teardown, session lock, or sleep. Core approval/blocker/failure signals retain reserved priority above plugin activities. A teardown clears the island but does not necessarily lose the activity; see §6.5 for what the host may put back afterwards.

6. Lifecycle

6.1 Package lifecycle

downloaded
    |
    v
validated -> signature verified -> permission review -> staged
                                                    |
                                                    v
                                      activated atomically
                                                    |
                         +--------------------------+------------------+
                         |                                             |
                         v                                             v
                      updated                                      disabled
                         |
                         v
                 previous version retained
                    for safe rollback

Installation fails before activation if integrity, signature, manifest, compatibility, package limits, or required permissions fail. Activation is an atomic pointer change; the host never executes directly from a download or temporary extraction path.

6.2 Worker lifecycle

stopped -> spawning -> initializing -> ready -> busy -> idle
                |          |            |       |       |
                +----------+------------+-------+-------+--> stopping
                                      |
                                      +--> crashed -> backoff -> quarantined
  1. The host resolves the active, verified package and an immutable grant snapshot.

  2. The supervisor requires an OS sandbox, resource limits, separate log pipes, and the RPC channel. If the OS sandbox adapter is unavailable, production launch fails closed; an explicitly named development/test mode may run trusted fixtures without claiming isolation.

  3. The host starts the pinned runtime with only the validated absolute entrypoint argument and a fixed clean environment. Session identity and grants are sent in the handshake rather than arguments or ambient environment.

  4. extension.initialize is the first protocol request and must complete within two seconds.

  5. The host checks the exact protocol, host API, SDK, UI schema, manifest digest, and registered handler IDs.

  6. The host sends extension.initialized; only then can contributions run.

  7. A worker being stopped receives extension.shutdown and a short grace period before termination.

The idle -> idleExpired -> stopping edge in the state machine is declared but not driven: there is no idle reaper and no idle timeout, so a started worker runs until something stops it. That is deliberate for ambient plugins — a Pomodoro timer is idle by construction between ticks — and §6.6 covers the resource ceiling that replaces an idle policy.

An update sends extension.prepareForUpdate, stops accepting new work, waits briefly for cancellable work, terminates the old process, activates the staged package, and starts the new version on demand. Failure during initialization rolls back automatically without transferring process memory.

6.3 Crash policy

  • One crash fails only that extension's active requests.

  • The host records a small, redacted crash receipt: extension/version, host/runtime versions, exit reason, resource counters, and the last protocol method names without payloads.

  • Restarts use exponential backoff with jitter.

  • Three unexpected exits in five minutes quarantine the version. A foreground user may retry once; background restarts remain blocked.

  • A newly updated version that repeatedly crashes rolls back to the last known good version.

  • A malformed frame, invalid repeated UI document, protocol flood, sandbox violation, or hard resource-limit breach terminates the worker and can quarantine immediately.

6.4 Ambient execution and privacy teardown

Plugins are ambient background contributors. Enabling a plugin is the whole statement that it should run: the host starts every installed, enabled plugin at app start and keeps it running while the user works in other applications. There is no separate "launch" step the user must remember, and Cirrulet losing frontmost status stops nothing. An accessory app that draws into the notch is almost never frontmost, so treating focus loss as a lifetime boundary would make a timer, a watcher, or a countdown impossible to write.

Two signals still tear every worker down, because both mean nobody is at the Mac:

SignalMeaningEffect
NSWorkspace.sessionDidResignActiveNotificationScreen lock or Fast User SwitchingTear down; restore on sessionDidBecomeActiveNotification
NSWorkspace.willSleepNotificationSystem sleepTear down; restore on didWakeNotification
NSApplication.didResignActiveNotificationCirrulet is no longer frontmostNot observed

The two teardown signals overlap constantly — locking a Mac and then closing the lid produces both — so the host tracks them as a set of reasons and issues at most one teardown and one restore per unattended window. Waking to the login window is a wake with no session activation, and correctly leaves the workers down.

Restoration must not resurrect something the user deliberately stopped, so the host separates two kinds of stop:

  • Stopped by the user — the Stop button, Stop All in the Plugins window, or disabling the plugin. Recorded for the app session. Nothing automatic starts it again; only Start, Restart, or turning Enabled off and on.

  • Stopped by a privacy teardown — recorded nowhere, deliberately. A teardown is the host acting on the user's behalf at a boundary, not an answer to "should this run", so the plugin is eligible again the moment the boundary lifts.

The registry's enabled flag cannot carry this difference on its own: a stopped plugin stays enabled, because that is the state its Start button expects to find. Enablement is the durable "should this ever run"; the user-stop is the session-scoped "not right now".

An automatic start that fails is retried at most three times per app session. A package that cannot start is not made startable by an unlock, and retrying on every wake would turn a broken plugin into an unwatched spawn loop.

6.5 Island state across a teardown

A teardown takes down the worker and everything it had published. For a plugin whose whole purpose is a countdown, losing the chip is losing the feature, so the host captures each plugin's live activities and republishes the ones that are still true after the restore.

Still true means the activity names an absolute instant that has not yet passed. A countdown can be recomputed from the clock; an open-ended activity — "Syncing", "3 items left" — was only accurate at the moment the plugin published it and the host has no way to age it, so it is dropped and the restarted plugin republishes it if it still applies.

Restoration is not a way around the publish-path rules. The restored TTL is the shortest of what remains of the plugin's original lifetime, the time left until the deadline, and the same 300-second host ceiling; a restore stops at the same two-activity-per-plugin quota; the ui.island grant is re-read from the durable store first, so a capability revoked during the teardown does not come back with the chip. Snapshots are held in memory for the app session only — an activity captured before Cirrulet itself restarted is never restored, because the wall-clock gap is unbounded.

A live activity's progress can now say how fast it is moving (§5.4), which is enough to compute when it ends, but the restore path does not read it yet. The host reads a deadline through a single seam, PluginIslandDeadlineResolver, whose shipped implementation still resolves nothing, so every activity is treated as open-ended and restoration degrades to a no-op rather than putting stale countdowns back on the island.

6.6 Resource ceiling for background workers

Workers that run all day change the shape of the resource question, and the state machine's idle path is not driven, so nothing reclaims a worker that has stopped doing useful work. The host applies two per-worker ceilings rather than an idle policy or a scheduler:

  • Memory, sampled from the worker's phys_footprint every 100 ms against a 256 MiB default. Shipped since v1.

  • Sustained CPU, sampled from the worker's cumulative CPU time against a default of 0.9 of one core averaged over a rolling 60-second window. New with ambient execution.

Both are per-worker defaults on PluginRuntimeResourceLimits and both report through the same PluginResourceLimitBreach receipt, so the Plugins window can say which ceiling was hit. The CPU window is rolling and averaged on purpose: a plugin doing real work in a burst is fine, and only a worker that keeps a core busy indefinitely is a runaway.

An idle reaper is deliberately not shipped. An ambient plugin is idle by construction between ticks, so idleness is not evidence of a runaway; killing on it would break exactly the plugins this model exists for.

7. JSON-RPC protocol

7.1 Transport and framing

The protocol uses JSON-RPC 2.0 over a private full-duplex byte stream. The implemented v1 framing contract is ndjson-v1: each frame is one complete UTF-8 JSON object followed by LF (0x0A). A receiver also accepts CRLF and removes the CR. JSON string newlines remain escaped, so a physical newline always terminates the frame.

{"jsonrpc":"2.0","protocolVersion":"1.0",...}\n

Rules:

  • The JSON body before LF is at most 8 MiB in v1, which is also the largest frame size the runtime helper accepts when it validates its initialization header. A partial line may arrive in any number of reads, and one read may contain multiple lines.

  • Empty lines, unterminated final lines, JSON-RPC batches, and positional parameters are invalid. There is no whitespace keepalive.

  • Every message carries a top-level exact protocolVersion in major.minor form in addition to JSON-RPC's fixed "jsonrpc": "2.0".

  • Assets and large blobs use host-issued handles, never base64 in RPC.

  • JSON must be valid UTF-8 with no duplicate object keys and a maximum nesting depth.

  • IDs are non-empty strings. Host IDs start with h: and extension IDs with e:.

  • Named object parameters are required; positional parameters and JSON-RPC batches are not supported in v1.

  • At most 16 requests may be outstanding in either direction. The transport applies backpressure.

  • Notifications are used only for advisory or safely lossy messages. Anything requiring acknowledgement is a request.

  • Unknown methods return JSON-RPC -32601; unknown fields are accepted only where the negotiated schema marks them extensible.

  • Protocol payloads are validated before dispatch. Invalid extension payloads never reach privileged host APIs or the renderer.

The earlier architecture draft incorrectly described LSP Content-Length headers. No implemented Cirrulet v1 codec used that framing. Hosts do not auto-detect or accept both formats: dual parsing creates ambiguity, complicates bounds enforcement, and can become a downgrade path. A development plugin built from that draft must update to an NDJSON-capable SDK before launch. Any future framing change requires a new pre-launch transport identifier and runtime-target contract (for example, a successor to edged-js-v1); it cannot be introduced as a protocol-minor change on an existing stream.

7.2 Version negotiation

The host initiates:

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "h:1",
  "method": "extension.initialize",
  "params": {
    "supportedProtocolVersions": ["1.0"],
    "supportedApiVersions": ["1.4.0"],
    "supportedUiSchemaVersions": [1],
    "supportedSdkVersions": ["1.3.0"],
    "host": {
      "version": "1.4.0",
      "platform": "darwin-arm64",
      "locale": "en-GB"
    },
    "extension": {
      "id": "dev.acme.github",
      "version": "1.2.3",
      "manifestDigest": "sha256:..."
    },
    "session": {
      "id": "xs_01K...",
      "grantsVersion": "g_42",
      "limits": {
        "maxMessageBytes": 8388608,
        "maxOutstandingRequests": 16,
        "maxQueuedTransportBytes": 33554432,
        "memoryBytes": 268435456
      }
    }
  }
}

The extension replies:

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "h:1",
  "result": {
    "protocolVersion": "1.0",
    "apiVersion": "1.4.0",
    "uiSchemaVersion": 1,
    "sdkVersion": "1.3.0",
    "manifestDigest": "sha256:...",
    "handlers": {
      "actions": ["open-issues", "create-issue"],
      "providers": ["issues"],
      "events": ["scheduled-refresh"],
      "mcp": []
    }
  }
}

Before launch, the host intersects its exact supported versions with the verified manifest ranges and orders each offer from newest to oldest. The first initialize envelope uses the first offered protocol version. The initialize response envelope uses that same bootstrap version; the result selects one exact offered protocol, API, UI schema, and supported SDK version. The host rejects a digest mismatch, a version outside the offer or manifest ranges, duplicate handlers, or handler IDs that differ from the manifest. There is no best-effort fallback after the worker starts.

The current headless manifest model compares action, provider, and event registrations exactly and requires handlers.mcp to be empty. MCP contribution registration remains a later vertical slice.

The selected versions are fixed for the connection. After selection, every envelope must use the selected protocol version. No other method is legal before the host sends:

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "method": "extension.initialized",
  "params": {
    "protocolVersion": "1.0",
    "apiVersion": "1.4.0",
    "uiSchemaVersion": 1,
    "sdkVersion": "1.3.0"
  }
}

7.3 Common invocation context

Host-to-extension work requests include:

{
  "invocationId": "inv_01K...",
  "traceId": "tr_01K...",
  "source": "launcher",
  "deadlineUnixMs": 1787216400000,
  "userGesture": true,
  "grantsVersion": "g_42"
}

The extension may echo invocationId in host calls, but cannot mint or upgrade a gesture. The host looks up gesture state and grants in its own session table; it never trusts extension-supplied booleans or capability names.

7.4 Action request

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "h:17",
  "method": "extension.invokeAction",
  "params": {
    "context": {
      "invocationId": "inv_01K...",
      "traceId": "tr_01K...",
      "source": "launcher",
      "deadlineUnixMs": 1787216400000,
      "userGesture": true,
      "grantsVersion": "g_42"
    },
    "actionId": "open-issues",
    "arguments": {
      "repository": "acme/widgets"
    }
  }
}

An initial native surface can be returned directly:

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "h:17",
  "result": {
    "status": "ok",
    "presentation": {
      "kind": "native",
      "document": {
        "schemaVersion": 1,
        "surfaceID": "issues",
        "revision": 0,
        "root": {
          "type": "list",
          "id": "issue-list",
          "props": {
            "title": "Open Issues",
            "searchPlaceholder": "Filter issues"
          },
          "children": []
        },
        "actions": []
      }
    }
  }
}

No-view results use presentation.kind: "none" and typed effects such as a toast or clipboard write. Every effect is re-authorized by the host.

7.5 Provider request

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "h:18",
  "method": "extension.queryProvider",
  "params": {
    "context": {
      "invocationId": "inv_01K...",
      "traceId": "tr_01K...",
      "source": "launcher-provider",
      "deadlineUnixMs": 1787216400000,
      "userGesture": true,
      "grantsVersion": "g_42"
    },
    "providerId": "issues",
    "query": "login bug",
    "limit": 20,
    "cursor": null
  }
}
{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "h:18",
  "result": {
    "items": [
      {
        "id": "issue-482",
        "title": "#482 Login fails after refresh",
        "subtitle": "acme/widgets",
        "icon": {
          "kind": "asset",
          "path": "assets/icon.png"
        },
        "actionId": "open-issues",
        "payload": {
          "issue": 482
        }
      }
    ],
    "nextCursor": null,
    "cacheTtlMs": 30000
  }
}

The host reads id, title, subtitle, actionId, and payload; anything else in an item is ignored. Picking the result invokes actionId with payload as the invocation's arguments, subject to the same declaration and capability checks as any other action dispatch. The request's own deadline is two seconds (§12), not the sixty-second action deadline.

7.6 Event delivery

Durable events are requests:

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "h:19",
  "method": "extension.deliverEvent",
  "params": {
    "context": {
      "invocationId": "inv_01K...",
      "traceId": "tr_01K...",
      "source": "background-event",
      "deadlineUnixMs": 1787216400000,
      "userGesture": false,
      "grantsVersion": "g_42"
    },
    "subscriptionId": "scheduled-refresh",
    "event": {
      "id": "evt_01K...",
      "type": "schedule.tick.v1",
      "occurredAt": "2026-08-20T09:45:00Z",
      "deliveryAttempt": 1,
      "payload": {
        "scheduledFor": "2026-08-20T09:45:00Z"
      }
    }
  }
}

The result is one of:

{"ack": "processed"}
{"ack": "retry", "retryAfterMs": 5000}
{"ack": "discard", "reason": "not-applicable"}

The host caps attempts and total age. An extension cannot request infinite retries.

7.7 Extension-to-host calls

Privileged operations use narrow methods rather than one generic escape hatch:

  • host.secrets.metadata

  • host.secrets.list

  • host.secrets.delete

  • host.secrets.revoke

  • host.island.view.upsert.v1

  • host.island.view.clear.v1

  • host.island.edge.publish.v1

  • host.island.edge.clear.v1

  • host.island.live.publish.v1

  • host.island.live.update.v1

  • host.island.live.dismiss.v1

  • host.storage.get.v1

  • host.storage.set.v1

  • host.storage.delete.v1

  • host.storage.list.v1

  • host.calendar.calendars.list.v1

  • host.calendar.events.query.v1

  • host.calendar.events.get.v1

  • host.calendar.events.create.v1

  • host.calendar.events.update.v1

  • host.calendar.events.delete.v1

  • host.oauth.authorize.v1

  • host.oauth.status.v1

  • host.oauth.revoke.v1

  • host.mcp.servers.list.v1

  • host.mcp.tools.list.v1

  • host.mcp.tools.call.v1

This is the complete shipping host-call catalog. Secret calls require the request's declared secrets.use capability; host.oauth.authorize.v1 requires the declared oauth.authorize capability, while host.oauth.status.v1 and host.oauth.revoke.v1 require only that the provider be declared (section 10.5); island calls require the plugin's declared ui.island capability; storage calls require the plugin's declared storage.local capability; the three calendar reads require calendar.read and the three calendar writes require calendar.write; host.mcp.tools.list.v1 and host.mcp.tools.call.v1 require at least one of mcp.client and mcp.userSelected.client — the platform's only disjunctive requirement — while host.mcp.servers.list.v1 requires no capability and answers only out of the plugin's existing grant (section 10.8). The dispatcher resolves those declarations and calls the session's CapabilityAuthorizer before the concrete secret broker, island bridge, or storage handler runs its own parameter, ownership, scope, quota, and resource checks. Calls from planned families such as files, clipboard, notifications, generic UI patches, and provider invalidation return -32008; they are not SDK methods. The reason distinguishes three cases, enumerated by PluginWithdrawnHostOperation: reserved-host-capability when the whole capability family is reserved, superseded-host-operation when only the spelling was withdrawn (host.storage.get became host.storage.get.v1), and unsupported-host-capability when the host has never served the method.

Example island call:

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "e:44",
  "method": "host.island.view.clear.v1",
  "params": {
    "viewId": "build-status"
  }
}

7.8 UI event and cancellation

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "h:20",
  "method": "extension.handleUIEvent.v1",
  "params": {
    "event": {
      "surfaceID": "issues",
      "revision": 3,
      "actionID": "open",
      "event": "primaryAction",
      "nodeID": "issue-482",
      "payload": {
        "values": {}
      }
    }
  }
}

extension.handleUIEvent.v1 is the only native-interaction method. The bundled runtime validates the wrapper and event shape, then calls the extension's optional uiEvents(context, event, host) handler. The event is produced only after the host renderer resolves the declared node/action, bounds the payload, and authorizes every capability attached to the contribution.

Cancellation is advisory first, then enforced:

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "method": "extension.cancel",
  "params": {
    "requestId": "h:18",
    "invocationId": "inv_01K...",
    "reason": "superseded"
  }
}

The SDK maps cancellation to AbortSignal. After the method-specific grace period, the host abandons the request and may terminate a worker that repeatedly ignores cancellation.

7.9 Error codes

Standard JSON-RPC errors are preserved. Cirrulet uses the server-reserved range:

  • -32001: incompatible protocol or schema

  • -32002: permission denied

  • -32003: quota or rate limit exceeded

  • -32004: stale UI revision

  • -32005: cancelled or deadline exceeded

  • -32006: invalid UI document or result

  • -32007: foreground user interaction required

  • -32008: extension disabled or quarantined

error.data may include a stable machine-readable reason and retry hint. It must not include secret values, raw authorization headers, unrestricted paths, or another extension's data.

8. Native UI schema

The wire schema is a versioned, JSON-serializable tree. The TypeScript SDK may expose builders or JSX, but React, functions, component code, HTML, CSS, and event closures never cross the process boundary.

8.1 v1 surfaces and nodes

Root surfaces:

  • list: searchable rows grouped into sections.

  • grid: visual items grouped into sections.

  • detail: title, sanitized Markdown body, metadata, and actions.

  • form: host-native fields, validation, and submit actions.

  • empty: icon, title, description, and actions.

Child nodes:

  • section

  • item

  • text

  • markdown

  • image

  • badge

  • separator

  • form.text

  • form.password

  • form.checkbox

  • form.select

  • form.date

  • action

Every node has a stable ID within a surface. Values are JSON primitives or schema-defined records. Unknown node types are rejected rather than rendered approximately.

Example:

{
  "schemaVersion": 1,
  "surfaceID": "issues",
  "revision": 3,
  "root": {
    "type": "list",
    "id": "issue-list",
    "props": {
      "title": "Open Issues",
      "searchPlaceholder": "Filter issues",
      "isLoading": false
    },
    "children": [
      {
        "type": "section",
        "id": "assigned",
        "props": {
          "title": "Assigned to me"
        },
        "children": [
          {
            "type": "item",
            "id": "issue-482",
            "props": {
              "title": "#482 Login fails after refresh",
              "subtitle": "acme/widgets",
              "accessories": [
                {
                  "kind": "badge",
                  "text": "bug",
                  "tone": "critical"
                }
              ]
            },
            "children": []
          }
        ]
      }
    ]
  },
  "actions": [
    {
      "id": "open",
      "title": "Open Issue",
      "event": "primaryAction",
      "targetNodeIDs": ["issue-482"],
      "shortcut": {
        "key": "enter",
        "modifiers": []
      }
    }
  ]
}

8.2 Rendering constraints

  • Markdown uses a documented CommonMark subset. Raw HTML, scripts, inline event handlers, remote embeds, and unsafe URL schemes are removed.

  • Text is always treated as text, never as markup.

  • Icons are host symbols or package assets. Remote images are fetched through the host, content-sniffed, size-limited, decoded out of process, and represented by opaque handles.

  • Extensions choose semantic tones such as critical or success, not arbitrary colors.

  • Font, spacing, focus, keyboard navigation, reduced motion, contrast, and accessibility semantics are host-controlled.

  • Forms use declared field types and host validation. Password field values are masked and are not persisted unless explicitly saved as a secret preference.

  • Action shortcuts are suggestions. The host resolves conflicts and reserves global shortcuts.

  • The host enforces limits on depth, nodes, text length, actions, image dimensions, update rate, and total serialized bytes.

8.3 Updates

Full native documents are the shipping update mechanism. The following host.ui.apply patch design is reserved for a future protocol and is not accepted by the current app handler:

{
  "jsonrpc": "2.0",
  "protocolVersion": "1.0",
  "id": "e:45",
  "method": "host.ui.apply",
  "params": {
    "invocationId": "inv_01K...",
    "surfaceID": "issues",
    "baseRevision": 3,
    "revision": 4,
    "operations": [
      {
        "op": "setProps",
        "nodeId": "issue-list",
        "props": {
          "isLoading": true
        }
      }
    ]
  }
}

Allowed operations are setProps, replaceNode, insertChildren, removeNodes, and moveNodes. They address stable node IDs, not unrestricted JSON Pointers. The host validates the resulting tree atomically. A stale revision returns -32004; the extension must send a fresh full document.

8.4 Horizontal pages

Schema v2 documents may include horizontalPages: an ordered collection of stable page IDs, short titles, and page roots. The island renders only the selected root and preserves its ID across refreshes. The collection remains bounded by the existing document node and payload limits.

Within an active plugin view, a physical swipe right moves forward and a swipe left moves backward. A page may opt into swipeBindings.leftActionID or rightActionID; the declared document action takes precedence over navigation for that direction and is delivered once through extension.handleUIEvent.v1, with bounded direction and pageId metadata. Bindings must reference actions in the same document. Inactive views receive nothing, momentum is deduplicated by the host, and an unbound surface does not fall back to the top-level carousel.

9. Optional isolated webviews

Webviews are an explicit fallback, not the default UI model.

  • The manifest must declare ui.webview and each action that may use it.

  • Initial content is package-local and served through an extension-specific virtual origin such as edged-extension://dev.acme.github/.

  • Each extension receives a separate web content process where supported, an ephemeral data store, and no shared cookies, service workers, cache, credentials, or browser profile.

  • file:, javascript:, data: navigation, popups, downloads, external protocols, and top-level remote navigation are blocked by default.

  • The default CSP is default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' blob:; font-src 'self'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'.

  • No webview network path is shipped; direct fetch is blocked and the host has no network RPC backend.

  • Webview-to-worker messages are schema-validated JSON, capped at 256 KiB and rate-limited. No host object is injected into page JavaScript.

  • A validated {type: "action", actionID, payload} bridge message invokes extension.invokeAction with that exact object under the canonical arguments key. Unknown actions, disallowed origins/frames, and oversized or deeply nested payloads are rejected before the worker runs.

  • Clipboard, file pickers, external URLs, and secrets remain host-mediated.

  • Native accessibility and keyboard conventions remain the preferred path. Store review may reject a webview that merely recreates standard native controls.

Remote content, arbitrary iframes, and per-extension persistent web storage should remain disabled in v1 unless the unresolved webview threat model is closed.

10. Capability and permission model

10.1 Principles

  1. Default deny: an undeclared capability cannot be requested.

  2. Least privilege: scopes identify origins, methods, secret keys, handles, event classes, or action IDs.

  3. Host authority: the host checks every privileged request; the SDK is convenience, not enforcement.

  4. Clear provenance: prompts show extension, publisher, signer, contribution, reason, and exact scope.

  5. Gesture binding: sensitive operations may require a recent host-generated user gesture.

  6. No background prompting: background work fails safely when it lacks a grant.

  7. Monotonic review: update scope expansion requires fresh consent before activation.

  8. Revocability: users can inspect and revoke every grant without uninstalling.

Grants bind:

(extension ID, publisher signing key, capability ID, normalized scope)

A version update with the same identity retains equal or narrower grants. A signer change, capability ID replacement, scope expansion, or newly required capability invalidates the affected grant.

10.2 Shipping capability vocabulary

CapabilityScopePrompt behavior
background.providerProvider IDs and warm-idle budgetOne-time grant
events.subscribeVersioned event typesOne-time grant; sensitive events separate
ui.islandView/action/live-activity IDs, priority, rate, lifetimeInstall or first use
ui.imagesPackaged image useInstall or first use
ui.webviewAction IDsInstall or first use
secrets.useSecret preference keysOne-time grant; value remains opaque
storage.localmaximumBytes total budget (≤ 10 MiB)One-time grant
calendar.readcalendarSources (required), calendarIDsFirst use; also triggers the system Calendars prompt
calendar.writecalendarSources (required), calendarIDsRe-authorized per write; Allow Once covers one call
oauth.authorizeproviderIDs and origins (both required)First authorize call; re-authorized per injected request
mcp.exportExported tool/resource IDsExplicit external-access prompt
applications.enumerateapplicationIDs (optional; absent means every installed application)First use; the prompt names the whole inventory when the scope is absent
applications.openapplicationIDs (required)Re-authorized per open; also requires a recent user interaction
urls.openExternalschemes (required), originsRe-authorized per open, narrowed to the one scheme and origin
files.searchsearchRoots (required), itemTypesRe-authorized per search, narrowed to the roots it will read
files.userSelected.readitemTypesRe-authorized per panel and per read; the user's pick is the bound, so there are no roots
files.userSelected.writeitemTypesRe-authorized per panel and per write; a save destination is never readable
clipboard.history.readitemTypes, maximumHistoryItems (both required, ≤ 100)Re-authorized per read, carrying the depth requested
clipboard.readitemTypes (required)Re-authorized per read, narrowed to the one type it will take
clipboard.writeitemTypes (required)Re-authorized per write; also requires a recent user interaction
selection.readapplicationIDs, itemTypes (both required)Re-authorized per read, carrying the whole declared application list
commands.publishactionIDs (required)One grant covering publish, list, and invoke; each operation prompts separately
mcp.clientserverIDs (required), toolIDs (absent means every tool), resourceIDsRe-authorized per listing and per call; listing which declared servers exist needs no grant
mcp.userSelected.clienttoolIDs, resourceIDs; no serverIDsThe servers are chosen by the user at the prompt, once, and that choice is the grant

storage.local is the first persistence the platform grants a plugin: a key-value store, one file per plugin, that survives worker restarts. The manifest must declare the total byte budget it needs as scope.maximumBytes, and the host caps that declaration at 10 MiB. Anything larger than a store a plugin can hold in memory wants a blob store with its own quota accounting, which does not exist yet.

Native action documents are a baseline surface, but every control interaction is still routed through the contribution's declared capability requests.

10.2.1 Reserved capability vocabulary

PluginCapabilityName is the distribution vocabulary: every name a submitted package may declare. It is wider than the eight names above, because a name has to be publishable before the operations behind it can be written. The remainder are reserved: the marketplace accepts them, the packager signs them, and the runtime grants them nothing.

CapabilityScopeReserved because
network.fetch, network.privateorigins, methods, pathPrefixesNo network broker; host.network.fetch is refused
files.persistentitemTypesNo handle store that outlives the worker
notifications.postnoneNo notification broker
background.schedulesubscriptionIDs, minimumIntervalMinutesNo plugin scheduler

calendar.read and calendar.write now ship for the eventkit source; see section 10.6. They remain separate names rather than one name with a write flag, because a grant is bound to a capability ID and scope: read and write must be revocable independently, and a plugin that only reads must not have a prompt whose Allow also covered writes. calendarSources (eventkit or remote) is part of the grant because reaching a plugin's own hosted calendars must not thereby reach the TCC-protected EventKit stores. A declaration naming only remote is accepted, and reaches nothing until the remote provider lands.

mcp.client now ships; see section 10.8. It is the one name in the table whose reservation ended without the surface behind it widening: a plugin reaches the MCP servers the user already configured for the assistant, by the names the user gave them, and cannot add one or choose what command one runs.

mcp.userSelected.client is the same reach, bound at a different moment: the author names no servers and the user picks them from their own catalog when the prompt arrives. It is a second capability name rather than a serverIDs-optional form of the first, and the argument for that is worth stating plainly because the two will keep looking like duplication.

Everything downstream of the manifest — the marketplace's review queue, the listing page, the capability scanner's findings, the SDK's validator messages — reasons about capability names. Scope crosses that boundary as data the reviewer may or may not read; the name is what gets classified, filtered, and shown. "This plugin talks to the two servers its author published" and "this plugin will talk to whatever servers you own" are different review risks, and the second is unauditable before install by construction: nobody but the installing user knows what is in their catalog. A distinction that cannot be made from the name alone cannot be reviewed at scale, so it has to be in the name. files.search and files.userSelected.read are the same split for the same reason, and <domain>.userSelected.<role> is now the pattern for it.

The corollary is that the two names are alternatives, never a pair: they are one reach bound at signing time or at grant time, so no manifest declares both, and the host operations behind them accept either (section 10.8).

applications.enumerate, applications.open, urls.openExternal, files.search, files.userSelected.read, files.userSelected.write, clipboard.history.read, clipboard.read, clipboard.write, selection.read, and commands.publish now ship; see section 10.7. They remain eleven names rather than a "launcher" bundle for the same reason read and write calendar access are two: a grant is bound to a capability ID and scope, and a plugin that lists applications must not thereby be able to launch them. files.search is distinct from files.userSelected.* because no user picks the files a metadata query returns, and holding both still grants no way to read a search hit; files.persistent remains reserved and is distinct from files.userSelected.* because a handle that outlives the worker is a different question from one that does not.

The three clipboard names are three grants, and none supplements another. A point-in-time read sees one item; a history read sees a host-maintained buffer the user has stopped thinking about; a write replaces what is there. They are not ordered by severity, so no one of them can be inferred from another: the buffer is the larger exposure by volume, but it starts empty at the moment the user grants it and only ever holds what they copy afterwards, whereas clipboard.read sees whatever is sitting there now — including something copied minutes before the plugin was installed. A plugin wanting both declares both and the user answers both.

selection.read now ships; see section 10.7.1. It is the one launcher name whose scope field is required where the neighbouring applications.* names leave theirs optional, and the asymmetry is deliberate. A launcher cannot name in advance the applications it will list, so applications.enumerate has an honest unbounded form and a prompt that says "every installed application". Reading a selection has none: the same unbounded grant covers a sentence in a text editor, a password manager's notes field, and a terminal, and there is no wording for that prompt a user could reasonably answer. Requiring applicationIDs is what makes the question answerable, so it is required.

Which names are reserved is not commentary. PluginCapabilityName.availability in Sources/EdgedPluginHost/CapabilityVocabulary.swift classifies every name as host-operation, contribution-gate, payload-gate, or reserved, and Tests/EdgedPluginHostTests/Fixtures/capability-vocabulary.json carries the same table verbatim for the SDK and the marketplace to pin against. CapabilityVocabularyTests fails if a name classified host-operation has no shipping PluginHostOperation, if one that does is classified otherwise, or if the fixture and the enum disagree. Implementing a reserved capability is therefore: add the host operation, change one line of availability, and edit the fixture — the SDK and marketplace suites then fail until they are told.

A plugin that declares a reserved name is refused by the SDK's runtime validator with the code reserved-capability rather than an unknown-name error, and a worker that calls a withdrawn host method whose whole family is reserved is refused with reason reserved-host-capability rather than unsupported-host-capability, so a publisher can tell "not built yet" from "you typed it wrong".

system.shell, arbitrary process spawning, unrestricted filesystem roots, native addons, raw sockets, and FFI are intentionally absent from the public v1 vocabulary. Adding one later requires a threat model and likely a separate trust tier.

oauth.authorize is a separate name rather than a widening of secrets.use, and the split is load-bearing. secrets.use is a plugin's own stored values, addressed by key and revocable one key at a time; an OAuth grant is the user's third-party account, addressed by provider and origin, and it is spent by the host on the plugin's behalf rather than named by the plugin. Folding them together would mean one prompt whose Allow covered both "use the API key you configured" and "sign in to my Google account", one revocation that could not separate them, and a secrets.use scope that had to grow provider and origin fields it means nothing for. It is also not an extension of network.fetch: a plugin needs both, and they answer different questions — network.fetch decides which requests may be made at all, oauth.authorize decides which of them carry the user's identity. Requiring both means revoking either one stops the authenticated traffic.

secrets.reveal and host.secrets.reveal are also deferred, not accepted v1 capability/method names. Supporting plaintext secret delivery would require a separate threat-model and policy decision; opaque secrets.use remains the implemented contract.

10.3 Permission prompts

  • Installation shows required capabilities. Denying one cancels activation rather than silently weakening a required contract.

  • Optional capabilities are requested only when a foreground contribution reaches the relevant feature.

  • A prompt cannot be rendered or worded by the extension. Manifest rationale is shown as quoted publisher text beside host-authored risk language.

  • A prompt displays whether access is once, while using, or remembered. The capability defines which durations are legal.

  • Updates show a semantic permission diff. The old version remains active until added permissions are accepted; it is not replaced by a partially authorized new version.

  • Permission fatigue is treated as a security failure. The host coalesces compatible requests and rate-limits denied re-prompts.

Shipped today: prompting happens at the moment a plugin attempts a gated operation, not at install. When CapabilityAuthorizer finds no stored decision for a binding it asks PluginPermissionBroker, which coalesces concurrent requests for the same binding into one question, rate-limits a plugin to five prompts per five minutes, and holds a thirty-second cooldown after a refusal. The app presents an app-modal window naming the plugin, the operation (for example host.island.live.publish.v1), the requested scope, the publisher key, and the manifest's stated reason, with Allow Once, Allow Always, and Deny. Allow Once is stored with once lifetime and consumed by the operation that asked; Allow Always is remembered; Deny is stored for the session; a dismissed or rate-limited prompt stores nothing and the operation fails closed. A host that wires no prompter denies undecided operations outright.

The headless host includes an abstract permission-decision store plus an atomic file-backed implementation. Its durable schema contains only extension ID, signer ID, capability ID/name, normalized declared scope, decision/lifetime, and timestamps. It cannot represent secret values, authorization headers, opaque file handles, file contents, or operation payloads. Remembered decisions are written through a mode-0600 same-directory temporary file, fsync, and atomic rename; existing symlinks, non-regular files, oversized files, unsupported schema versions, and group/world-readable files are rejected. Once and session decisions remain memory-only and do not survive host restart. Keychain/credential storage is a separate service and is not implemented by this decision store.

10.4 Network mediation

Direct worker network access is still disabled: the worker has no com.apple.security.network.client entitlement and BundledJavaScriptRuntime refuses to launch a helper that carries one. A plugin reaches the internet only through host.network.fetch.v1, which the host performs on its behalf against the network.fetch capability's declared origins, methods, and optional pathPrefixes.

The operation takes url, method, headers, and an optional body with a bodyEncoding of utf8 or base64. It answers with status, url (the URL the response was finally read from), redirected, headers, and body with the same bodyEncoding tag: a body that is valid UTF-8 crosses as text, and anything else crosses as Base64. The encoding is always stated rather than inferred, because a plugin that guessed wrong would parse Base64 as JSON. @cirrulet/sdk's network() client wraps the same shape with text(), json(), and bytes().

What is enforced, against the seven requirements above:

  • Canonicalization. The URL is reduced to scheme, lowercased host, explicit port, dot-segment-free path, and query before any authorization decision, and the canonical form is what is dialed. Credentials in the URL, fragments, non-HTTPS schemes, backslashes, and percent-encoded dot segments are refused outright rather than normalized.

  • HTTPS only. There is no plain-HTTP policy at all; http:// never canonicalizes. TLS 1.2 is the floor and the certificate is evaluated against the declared hostname.

  • Host-side DNS with address filtering. The host resolves the name with getaddrinfo and every returned address must be globally routable: loopback, link-local (including 169.254.169.254), unique-local, private IPv4, carrier-grade NAT, multicast, broadcast, unspecified, IPv4-mapped and IPv4-compatible IPv6, and the documentation ranges are all refused. One bad address in the answer fails the whole request, so a split answer cannot be used to smuggle an internal target past the check. network.private is still reserved and grants nothing.

  • Redirects revalidate. Each hop is canonicalized and re-authorized against the manifest — origin, method, and path prefix — and re-resolved and re-filtered before it is dialed. The chain is capped at five hops. A 301, 302, or 303 rewrites the method to GET and drops the body, as browsers do; 307 and 308 preserve both, and the rewritten method must itself be declared.

  • Empty cookie jar per exchange. Cookies live for the duration of one host.network.fetch.v1 call and are discarded with it. They are matched by exact origin, Domain and Path attributes are ignored rather than honored, and nothing is shared with the app, with other plugins, or with the same plugin's next call. Set-Cookie is never returned to the plugin.

  • Limits. 1 MiB request body, 4 MiB response body, 4 concurrent requests per plugin, 16 MiB per rolling 60-second window, and 30 seconds of wall clock per exchange (10 of which may be spent connecting), plus 32 request headers / 8 KiB, 100 response headers / 64 KiB, and a 2 KiB URL. The response ceiling sits below the 8 MiB RPC frame cap so a permitted response always fits in a frame. Streaming is out of scope: an oversized response fails with response-too-large rather than arriving truncated, because a plugin cannot tell a short document from a cut-off one.

  • No TLS client keys, no ambient credentials. The broker builds its own NWConnection parameters per exchange. It never presents a client identity, never consults the app's URLSession configuration, the shared cookie storage, the shared credential storage, or the system proxy's stored authentication. The only credential it ever adds is a host-held OAuth token for an origin the manifest and the host's provider catalog both bind to a provider — section 10.6.

Enforcement is not bypassable by DNS rebinding, within the limits of the platform. The broker resolves the name, validates every address, and then dials one of those addresses as an IP literal through NWConnection, with sec_protocol_options_set_tls_server_name supplying the SNI and the name the certificate is checked against. There is no second resolution between the check and the connection. The honest caveats: the address is validated at resolution time and the operating system's routing could in principle change underneath a still-valid address; and because connections are dialed by literal, HTTP/2 and connection pooling are given up — every exchange is a fresh HTTP/1.1 connection with Connection: close, which also means no cross-plugin socket reuse.

Refusals name the plugin's own manifest — origin-not-declared, method-not-declared, path-not-declared, redirect-not-declared — while anything learned from the network collapses into one destination-unavailable code covering "did not resolve", "resolved somewhere unroutable", and "would not connect", so the broker cannot be used to map the host's network.

10.5 Host-owned OAuth and credential injection

A plugin that needs a third-party account does not get a token. It gets an authorization the host holds, and requests to the origins its manifest bound to that provider start carrying it.

The operations. Three, gated by the new oauth.authorize capability:

MethodParametersAnswer
host.oauth.authorize.v1providerID, scopes?{ providerID, handle, authorized, grantedScopes, expiresAt? }
host.oauth.status.v1providerIDthe same shape, authorized: false and no handle when none is held, plus needsReauthorization when a grant was held and the provider destroyed it
host.oauth.revoke.v1providerID{ revoked: true }

handle is an opaque authz_… string that no operation accepts. It exists so a plugin can notice that the user re-authorized with a different account, and so a log line can name an authorization without naming a token. There is no host.oauth.reveal, for the reason section 11 gives for refusing secrets.reveal.

Only authorize requires the capability. status and revoke are baseline: a plugin checking at launch whether to draw a "Connect" button must not thereby raise a sign-in prompt, and revocation removes authority rather than escalating it. Both still refuse a provider the manifest did not declare.

Where the client credentials live: host-side, in PluginOAuthProviderRegistry. Endpoints and resourceOrigins are compiled in; the client ID comes from the app's Info.plist under CirruletOAuthClientIDs, and the client secret, for providers that require one, from CirruletOAuthClientSecrets beside it. Both are written by packaging. Not the manifest, for three reasons. A client's secret cannot ship inside a signed tarball the user can unpack, so it stays in the app bundle where the host reads it and no plugin operation returns it. resourceOrigins — the list of origins a provider's token may be sent to — is a boundary the plugin is the adversary for; a plugin that could declare it could name its own server. And a provider only redirects to a URI registered against that client, so a plugin-declared client would put the consent screen's publisher name under the plugin author's control. The cost is real and deliberate: a plugin cannot integrate with a provider Cirrulet has not registered.

The secret is not a defence, and is required anyway. These are separate facts and conflating them is what broke the flow once already. Cirrulet's clients are public clients: PKCE S256 is the control, and RFC 8252 §8.5 says a native application's secret authenticates nothing. Google's Desktop client type nonetheless refuses a token request that omits client_secret, after the user has granted consent — see docs/google-oauth-verification.md §11. So a registration carries the value when the provider demands it, sends it on the authorization-code exchange and on every refresh, never puts it in the authorization URL, and a build with a client ID but no secret for a provider that needs one registers no provider at all rather than one that fails at the last step. PluginOAuthProviderRegistry.Descriptor.requiresClientSecret has no default, so adding a provider means answering the question for that provider rather than inheriting the assumption that broke Google.

The redirect is one of two host-owned mechanisms, and the registration picks which. PluginOAuthRedirectMethod is a two-case enumeration with no associated values, so a registration selects a mechanism but names no destination; the destination is computed by the host at the moment of the flow. A plugin has no input to either choice, and the enumeration is not reachable from a manifest.

hostScheme is the original path and remains the default. ASWebAuthenticationSession(url:callbackURLScheme:) captures cirrulet://plugin-auth in-process; the app's URL-open handler never runs, and the app has none. One redirect URI serves every plugin and provider: a custom scheme is first-come-first-served across installed applications and so cannot be a boundary between plugins, and it is not asked to be one — the session captures the callback in-process, state binds it to the running flow, and the code is worthless without the PKCE verifier, which never leaves the process. The session runs out-of-process in Safari's engine, so the provider's page renders with the real address bar and certificate rather than in a window a plugin could imitate.

loopback exists because Google will not accept cirrulet://plugin-auth, or any other arbitrary custom scheme: its current native-app guidance says custom URI schemes "are no longer supported due to the risk of app impersonation" and points macOS clients at http://127.0.0.1:<port>. The host binds a socket to 127.0.0.1 on an ephemeral port the kernel chooses, with a listen backlog of one, then opens the authorization page in the user's default browser and waits for the browser to arrive on that port. It reads one request line, answers with a small page telling the user to return to Cirrulet, and closes the socket before the code is exchanged. ASWebAuthenticationSession cannot serve this case at all — it matches a callback by scheme, and the scheme here is http.

The invariant survives the second mechanism for the same reasons it held for the first, restated in loopback's terms. The port is chosen by the kernel, not by any input the plugin supplies, so the plugin cannot predict it; the listener exists only for the duration of one flow and answers exactly one redirect, so it is not a standing surface; the socket is bound to the loopback address, so nothing off the machine can reach it; the state parameter is compared before the code is read, so another process racing to the port with a guessed code fails the comparison; and the code is still worthless without the PKCE verifier. The redirect URI the host sends to the provider and the one it posts at token exchange are the same computed value, so a mismatch is a provider-side failure rather than a silent acceptance. If the socket cannot be bound the flow fails with redirectUnavailable — it never falls back to the custom scheme, because that is a destination the provider has not registered and the fallback would turn a local failure into a confusing one at Google.

The binding is an intersection. An origin carries a provider's token only if the manifest's oauth.authorize scope names it and the host registration's resourceOrigins covers it. A declared origin no registration vouches for, or one two registrations claim, is a load refusal: the plugin gets no OAuth broker at all, and therefore no injection, rather than a permission prompt promising something the host will not honour. It still loads and its network.fetch still works.

Injection is decided per hop, and it is decided twice. Before serializing a hop the broker asks whether the hop's origin is bound. That question is answered from the manifest and the catalog only — never from whether a credential is currently held — because it is what the collision rule keys off, and a rule that changed with authorization state would be an oracle for it. If the origin is bound, a plugin-set Authorization header is refused (network-authorization-reserved), not overridden. Overriding would silently send a request the plugin did not write; refusing is a deterministic error the author hits the first time they run the plugin. If the origin is bound and no credential is held, the request is refused (network-credential-unavailable) rather than sent unauthenticated. If the origin is not bound, nothing is attached and the plugin's own header goes through untouched — a plugin holding its own API key for an unrelated origin is unaffected.

Because the question is re-asked with the new origin after each redirect, a redirect from a bound origin to an unbound one drops the credential, and a redirect between two origins of the same provider keeps it.

Refresh is broker-driven, on demand, never in the background. A token within sixty seconds of expiry is refreshed before the hop; a 401 from an origin whose credential was injected triggers one refresh and one retry of the identical hop, not counted against the redirect budget and bounded at one per fetch. A rotated refresh token is persisted before the retry goes out. There is no background refresh task: a plugin that is not running has no reason to be talking to Google, and a scheduler would turn an idle install into recurring traffic.

Storage is separate from secrets.use. Records live in their own keychain service (dev.edged.plugin-oauth.v1) under a handle derived from plugin, publisher key, capability, and provider. Nothing addressable through host.secrets.* can reach them: the secrets broker refuses a capability ID whose declaration is not secrets.use, and even if it did not, it is looking in a different service. host.oauth.revoke.v1 deletes locally first and tells the provider second — a provider that will not accept a revocation must not leave a token in the keychain — and a failed delete leaves a session tombstone so a partially failed revocation cannot resurrect a credential. The user-initiated disconnect described below inverts that order, for a reason given there.

What the user sees. One prompt, naming the providers and the origins, whose headline says both halves of what is being granted: signing in, and the standing consequence that requests to those sites will carry the sign-in. The grant appears in the Plugins window like any other and withdrawing it stops injection on the very next request, because every hop re-authorizes rather than consulting a cache. A hostScheme provider then presents its sign-in in a sheet over Cirrulet; a loopback provider presents it as a tab in the user's default browser, which is the visible cost of Google's rule and not a choice.

The Google registration's client identity, scopes, and the verification submission built on them are recorded in docs/google-oauth-verification.md.

Connected Accounts is where a user ends a grant, and it is a surface of its own. oauth.authorize already appears in the plugin's permission list, and listing the account there would have been less code and wrong. Withdrawing the capability stops the host attaching the credential to the next request, which is real and immediate, but it tells the provider nothing and the grant stays live — a user who switched a toggle off under a heading that says Permissions would reasonably believe they had disconnected their Google account, and would be wrong. That false belief is the defect this surface exists to remove, so it must not be recreated in a new place. Two further reasons: an account outlives its plugin, because a revocation the provider could not be told about survives the uninstall and there is no plugin page left to show it on; and the question is asked about the app, not about a plugin — "which accounts does Cirrulet have access to" is how a person thinks about it and how a verification reviewer asks it, and a surface reachable only by first picking the right plugin answers a different question. It sits in the Plugins window beside publisher trust, the existing precedent for app-level state that is about plugins without belonging to any one of them.

A directory records what a grant is, so it can be revoked after the plugin is gone. The keychain address is a digest of plugin ID, publisher signing key, capability ID, and provider ID. Three of those were only ever written down in the plugin record, so deleting the plugin first makes the token unreachable: present, live at the provider, and impossible even to name. FilePluginOAuthGrantDirectory keeps those four values, plus the display names and scopes needed to describe the grant to a person, in one JSON document beside the permission decisions — atomic replace, 0600, and a document that will not parse degrades to an empty list rather than to a launch failure. It is deliberately not in the keychain: nothing in it is secret, and putting it there would mean an unlock prompt to render a settings pane. There is no field a token could go into, and a test encodes a row and asserts no part of a credential appears in it.

The user-initiated disconnect calls the provider first. This is the opposite of host.oauth.revoke.v1, because the promise is different. A plugin asking to revoke wants the host to stop using a credential and must not have that blocked by a provider being down. A user pressing Disconnect has been told their account will be disconnected, and the only way to keep that promise is to still hold the token when the provider is asked; deleting first would make a failed call unrecoverable — a live grant at Google, nothing left to revoke it with, and a success message already shown. PluginOAuthDisconnectService therefore posts the refresh token to the revocation endpoint (RFC 7009 §2.1: a provider may kill the whole grant from it, where an access token may leave the refresh token alive) and only then destroys the record. It reports one of five outcomes, and the surface prints a different sentence for each, because "revoked at Google" and "Cirrulet has forgotten it but Google was never told" are different facts and a user who is shown the first when the second is true stops looking.

It runs without the plugin, and without the plugin's CapabilityAuthorizer. A broker exists only while a plugin is loaded and is built from its manifest; every path this serves is one where the plugin is stopped, has had oauth.authorize withdrawn, or has been deleted. Not consulting the authorizer is deliberate rather than incidental: authorization asks whether a plugin may acquire authority, and gating the giving-up of authority on a plugin's permission state would let a denied capability or a crashed plugin stop a user from disconnecting their own Google account.

A revocation that cannot be delivered is held, never orphaned and never faked. When the endpoint is unreachable the record is moved to a second keychain address (…​.pending-revocation) that the injector does not read, so the plugin stops being able to spend the credential at the moment the user pressed the button, while the token itself survives to carry the revocation later. The directory row stays, marked awaitingRevocation with an attempt count, and the list says so. Retries happen when the app starts and when Connected Accounts is opened — the two moments a machine that was offline is plausibly online again — and not on a timer, for the same reason section 10.5 refuses background refresh: a loop retrying a revocation is recurring traffic from an idle install, and the retry is not urgent because the credential is already out of reach. A Forget Sign-In action exists for a revocation that will never succeed; it is named for what it does, deletes the held token without contacting anyone, and says in as many words that the access may stay live until the user removes Cirrulet in the provider's own settings. A 4xx other than 408 or 429 is treated as final rather than retried, because identical bytes to the same endpoint cannot produce a different answer.

Uninstalling a plugin revokes what it connected, silently, and cannot be blocked by it. Silently because the user is already being asked — the removal dialog names the providers it will disconnect, so a second prompt would be asking the same question twice, and the alternative default (leave the grant) is the behaviour that was wrong. The revocation is best-effort in the sense that it never fails the uninstall: an undeliverable one leaves a held token and a row whose plugin is marked uninstalled, which is exactly the state the retry sweep knows how to finish. Grants are per-plugin, not per-provider: two plugins that both connect Google run two authorizations and hold two records at two addresses, so one uninstall revokes one grant. The filter that enforces that lives inside disconnectEverything(connectedBy:in:), which takes the whole directory and selects from it, rather than in the caller — putting it in the caller would make it a rule every future caller has to remember. What the host cannot control is the provider's own grant model: Google may treat two authorizations of one client by one person as a single grant and end both. So the confirmation names the other plugins by name before the user commits, and the collateral case surfaces through the reconnect state below.

A grant revoked at the provider's end surfaces as "reconnect", not as an error. A user who removes Cirrulet in their Google account settings has done a documented, legitimate thing, and the host finds out the way RFC 6749 §5.2 says it will: the next refresh answers invalid_grant. That one error code — distinguished from every other exchange failure, because every other one is worth retrying and this one is not — deletes the dead tokens, marks the row needsReauthorization, and makes host.oauth.status.v1 answer authorized: false with needsReauthorization: true. The flag is what lets a plugin word its own prompt as "reconnect" rather than "connect", and what lets the surface explain that the provider no longer recognises the sign-in instead of showing a token-exchange failure. A refresh that later succeeds clears the flag, so a stale prompt retires itself.

10.6 Calendar mediation

EventKit is in the app process, never in the worker. BundledJavaScriptRuntime refuses to launch a helper whose signature carries com.apple.security.personal-information.calendars, and the helper ships with exactly two entitlements (app-sandbox and allow-jit). A plugin reaches the user's calendars only through the six host.calendar.* methods, each of which is authorized against the plugin's own manifest before the app touches the store.

The operation family. Three reads gated by calendar.read and three writes gated by calendar.write:

MethodParametersAnswer
host.calendar.calendars.list.v1nonecalendars[] of { id, title, color?, writable, isDefaultForNewEvents }
host.calendar.events.query.v1start, end, calendarIds?, limit?events[], truncated
host.calendar.events.get.v1eventIdevent
host.calendar.events.create.v1event, calendarId?event
host.calendar.events.update.v1eventId, changes, spanevent
host.calendar.events.delete.v1eventId, spandeleted: true

Identifiers are opaque and source-qualified. A calendar is <source>:<opaque>; an event handle is <source>:<occurrence>:<opaque>, where the occurrence is - for a non-recurring event and otherwise the whole seconds since 1970 of the occurrence's originally scheduled start, so a handle survives the user dragging one occurrence to another day. A plugin stores and echoes both; neither shape is a contract, and the remote provider will mint its own.

Authorization, and when the prompt fires. Both capabilities request full EventKit access, and the host does the narrowing itself. macOS 26's write-only access cannot enumerate calendars or read back an event, so under it the host could not tell whether a write landed in a calendar the grant names — a scope the platform promises to enforce would silently become unenforced. Refusing to depend on it is the honest option; a plugin holding only calendar.write still gets a host that knows which calendar it wrote to.

The system prompt fires on first use, not at install and not at app start, and only after the plugin's own capability request has been allowed: a user who denies the plugin never sees a Calendars prompt on its behalf. The status is then cached for the app's lifetime, so a denial is not re-asked on every call. A plugin sees calendar-access-denied (permission denied) when the user or an administrator refuses Cirrulet, and calendar-read-access-required if Cirrulet somehow holds only add-only access. Both are ordinary JSON-RPC errors; nothing crashes and nothing silently returns an empty list.

How calendarIDs is enforced. The grant is resolved once per session from the declaration: calendarSources must include eventkit for any of this to apply, and each calendarIDs entry must be a source-qualified identifier — a bare one is a calendar-scope-malformed refusal rather than a silently unmatchable filter. An absent calendarIDs means every calendar in the named sources, and the permission prompt says so in those words.

Enforcement is by resolution, never by trusting the caller: calendars.list returns only the intersection of the store and the read grant; a query with no calendarIds is issued against the grant's calendars rather than all of them; a query that names one is checked first; and an event handle is resolved to an event and then checked against the calendar that event actually lives in. Out-of-scope and nonexistent collapse into the same two refusals — calendar-not-found and calendar-event-not-found — so the surface cannot be used to discover that a calendar or an event exists. Read and write grants are independent: a calendar can be readable and not writable, and omitting calendarId on a create falls back to the user's default calendar only when the write grant already covers it.

The event model, and what v1 leaves out. An event carries its handle, calendar, title, notes, location, URL, timing, status, availability, recurrence, organizer and attendees, alarms, modifiable, and lastModified. Timing is one of two shapes that share no keys: a timed event has start/end (RFC 3339, always answered in UTC, any offset accepted) plus the IANA timeZone it was authored in; an all-day event has startDay/endDay as YYYY-MM-DD with both ends inclusive, matching EKEvent rather than iCalendar's exclusive DTEND. A plugin that ignores allDay and reads start gets undefined rather than a date that is right in London and wrong in Auckland.

Deliberately out of v1: attendee email addresses (a slice of the contact graph, which is not what the prompt describes — isCurrentUser answers "have I replied"), writable attendees (adding one sends an invitation in the user's name, and EKParticipant has no public initializer anyway), conferencing links as a structured field (a join link goes in url, where Calendar's own integrations put one), structured locations, travel time, attachments, reminders (a different EventKit entity), and creating or deleting calendars. Alarms are limited to relative-offset and absolute display triggers, at most five: an alarm is the only part of this surface that can interrupt the user, and EKAlarm's email, sound, and proximity triggers are not something a plugin gets to set.

Recurrence. Every mutating call takes a mandatory span of thisOccurrence or thisAndFutureOccurrences, including for an event that does not recur, where both mean the same thing. There is no default: a defaulted span is a guess about how much of the user's series to rewrite, and demanding it means a plugin never has to have read the event first to know whether the field mattered. EventKit has no "entire series" span, so neither does this; a plugin reaches a whole series by using the first occurrence's handle, which it can find because every occurrence reports recurrence.seriesStart.

The rule vocabulary is a strict subset of EKRecurrenceRule: frequency, interval, an optional weekday set, and at most one ending (until or count, never both). An event whose stored rule uses anything else — positional rules, day-of-month, set positions, or more than one rule — is reported as { kind: "opaque", seriesStart }, and an attempt to rewrite it is refused with calendar-recurrence-unsupported. The rest of that event stays editable. Changing a rule requires thisAndFutureOccurrences; asking for it with thisOccurrence is calendar-recurrence-span-mismatch. The same principle governs alarms: an event carrying a trigger this vocabulary cannot express reports alarmsEditable: false and refuses a wholesale alarm replacement rather than dropping what the user configured.

Writes. A write needs no confirmation beyond the capability grant, but the grant is re-authorized on every call and each method carries its own operation string, so an Allow Once for host.calendar.events.create.v1 covers one create and nothing else — never a delete. A plugin may delete an event it did not create: a triage plugin legitimately needs to, provenance tracking would be a new persistent store to get wrong, and the real containment is the scoped grant, the per-call prompt, and an audit trail that records plugin, session, calendar, and action without copying titles or notes.

Limits. Named in PluginCalendarLimits: a 366-day query range, 500 events per query (100 by default; a larger request is clamped rather than refused, and results are ordered by start so a plugin pages by moving start forward), 64 explicitly named calendars, 512-byte titles, 8 KiB notes, 1 KiB locations, 2 KiB URLs, five alarms, a recurrence interval of 999 and occurrence count of 1000, a 366-day event duration, and 30 successful writes per rolling 60 seconds — a refused write does not consume the budget. The event ceiling sits under the 8 MiB frame cap: a worst-case event encodes to roughly 12 KiB, so 500 is about 6 MiB with room for the envelope.

Packaging. The app is signed with Packaging/Edged.entitlements, which contains exactly com.apple.security.personal-information.calendars. Cirrulet is not sandboxed, but it is hardened, and under the hardened runtime tccd refuses a Calendars request from a process whose signature lacks that entitlement — the request is denied before the user ever sees it. Packaging/Info.plist.in supplies NSCalendarsFullAccessUsageDescription; without it EventKit terminates the process on first access rather than prompting. Scripts/package-app.sh passes the entitlements file when it signs the bundle and then reads the signature back to confirm the entitlement is there, and Scripts/verify-app.sh checks both the entitlement and the usage string — and checks that the worker still does not carry the calendars entitlement.

What the remote half must match. The remote provider owns everything behind PluginCalendarStoring and nothing in front of it. To be indistinguishable to a plugin it must mint remote:-prefixed calendar identifiers and remote:<occurrence>:<opaque> handles, honour the same span semantics, report a rule it cannot express as opaque rather than flattening it, use inclusive all-day dates and UTC instants with a separate IANA zone, and produce the same calendar-not-found / calendar-event-not-found refusals for out-of-scope items. Its authorization step is an OAuth grant rather than a TCC prompt, which is the one thing that legitimately differs — and it is invisible in the wire shape, because both are just an authorization failure the plugin is told about in the same way.

10.7 Launcher mediation

Eleven capabilities a Raycast-style launcher needs, and the most invasive set on the platform: what you have installed, what files you have, what you have copied, and the ability to act as you. Everything below lives in Sources/EdgedPluginHost/Launcher/, and every limit is a named constant in PluginLauncherLimits with its reasoning beside it.

MethodCapabilityParametersAnswer
host.applications.list.v1applications.enumeratebundleIds?, limit?applications[] of { bundleId, name, version?, iconHandle }, truncated
host.applications.open.v1applications.openbundleIdopened: true
host.urls.open.v1urls.openExternalurlopened: true
host.files.search.v1files.searchtext, roots?, itemTypes?, limit?items[], complete, unavailableRoots[]
host.files.userSelected.open.v1files.userSelected.readitemTypes?, allowsMultiple?files[] of { handle, name, access, byteSize?, contentType?, modifiedAtUnixMs? }
host.files.userSelected.read.v1files.userSelected.readhandlename, byteSize, bytesBase64, contentType?
host.files.userSelected.save.v1files.userSelected.writeitemTypes?, suggestedName?file
host.files.userSelected.write.v1files.userSelected.writehandle, bytesBase64byteSize
host.files.userSelected.release.v1none (broker-gated)handlereleased
host.clipboard.history.read.v1clipboard.history.readlimit?, itemTypes?entries[] of { text, itemType, byteLength, truncated, copiedAtUnixMs }
host.clipboard.read.v1clipboard.readitemType?content — { text, itemType, byteLength, truncated } or null
host.clipboard.write.v1clipboard.writetext, itemType?written: true
host.selection.read.v1selection.readitemTypes?selection of { text, itemType, applicationId, applicationName?, byteLength, truncated, readAtUnixMs }, or null
host.commands.publish.v1commands.publishcommands[]commands[] of published descriptors
host.commands.list.v1commands.publishnonecommands[] from other running publishers
host.commands.invoke.v1commands.publishcommandId, arguments?result

host.applications.open, host.urls.openExternal, host.clipboard.read, and host.clipboard.write are withdrawn spellings of shipping methods, as are host.files.pick, host.files.read, and host.files.write of the user-selected family; all seven are refused with a reason that names the replacement rather than reported as reserved.

What enumeration tells a plugin, and what it does not. A row and a way to launch it: bundle identifier, display name, version, and an opaque icon handle. Withheld deliberately — the file path, because /Users/<shortname>/Applications/… publishes the account name and where the user installs software, and nothing needs it since open takes a bundle ID the host resolves through LaunchServices; icon bytes, because an .icns carries representations up to 1024×1024 and a few hundred would not fit the 8 MiB frame, so iconHandle goes into a UIImageSource.opaqueHandle and the host draws it; and last-used date, launch count, and running state, because those describe the user's day rather than their disk. The name comes from CFBundleDisplayName, not FileManager.displayName, which honours a Finder preference and would otherwise return "Calculator.app" for some users.

The inventory is a walk of five fixed domains (/Applications, /System/Applications, /System/Library/CoreServices/Applications, /System/Cryptexes/App/System/Applications, ~/Applications) at most three levels deep, terminating at any .app because a bundle is a directory. LSUIElement and LSBackgroundOnly bundles are dropped: nobody launches them, and listing them would leak which helpers each installed suite runs. It is not a LaunchServices sweep, which would also report every app the user has ever opened from a DMG or a backup. One cache is shared by every plugin, rebuilt lazily after 60 seconds, so the per-plugin rate limit bounds what a plugin learns rather than what the disk is asked.

An absent applicationIDs grants the whole inventory. That is the legitimate launcher ask, and the prompt says "every application you have installed" rather than rendering a blank scope line that would read narrower than the grant. A declaration that names bundle IDs turns enumeration into a set of yes/no questions about those identifiers, and a filter naming anything outside it is refused rather than intersected. open refuses an undeclared bundle ID before consulting the system, so the refusal is identical whether or not the application is installed and cannot be used as an inventory oracle by a plugin without applications.enumerate.

Acting as the user. applications.open and urls.openExternal are the only operations whose effect is indistinguishable from something the user did, so both require a recent user interaction. The plugin cannot assert one: it is never the thing the user clicked — they clicked a launcher row, and the host then ran the plugin's handler — and a flag a worker sends is a flag a worker can send whenever it likes. The host therefore keeps a PluginUserActivationLedger, stamped only where genuine interaction enters a plugin (the action-invoke path in LiveDashboardPluginService), and spent by the brokers. Background event delivery, provider queries, scheduled work, and cross-plugin command hops all deliberately do not stamp. An activation lives 15 seconds and authorizes at most three opens, counting applications and URLs together; re-stamping replaces the remaining budget rather than topping it up, and a session ending drops it so a restart cannot inherit a click aimed at the process before it.

URLs. The declared schemes bound what may be opened and origins narrows http and https further, with exact origin comparison after default-port and case normalization — github.com.evil.test does not match a github.com grant. Three groups of schemes are refused whatever a manifest declares: file, ftp, smb, afp, nfs, because otherwise the capability is an arbitrary-path open primitive with none of the bounding files.search requires; javascript, data, blob, about, vbscript, view-source, because opening one is closer to running code in the user's browser than to following a link; and cirrulet, the app's own scheme, because cirrulet://marketplace-auth is a real callback and a plugin must not be able to speak to Cirrulet as if it were the system. A URL carrying a username or password is refused, and so is one containing a newline, which is how one URL becomes two.

How searchRoots is enforced rather than hinted. The plugin supplies a literal string; the host writes the query. There is no way to pass a kMDItem predicate, because one would let a plugin ask for kMDItemTextContent and read a document by binary search, or kMDItemWhereFroms and learn browsing history — none of which a search root bounds, because all of them are answers about files inside the root. The text is bound as a predicate argument, never interpolated, and only name attributes are matched.

NSMetadataQuery.searchScopes is a request to Spotlight, so it is not the enforcement. Roots are canonicalized once per session — ~ expanded, symlinks resolved, which matters because /tmp is a link to /private/tmp on every Mac — and every returned path is canonicalized the same way and re-checked for containment on a path-component boundary, so /Users/gad/Documents-private is not inside a /Users/gad/Documents grant and a symlink inside the root pointing at ~/.ssh resolves outside every root and is dropped. CapabilityScope.validate already refuses a root that is relative or contains .., so an escaping root is not expressible in a manifest at all. Declared itemTypes are re-checked against the file itself with UTType conformance rather than against whatever the index reported, because the index is a cache and a grant that says "only images" must not leak a .env after a rename.

The query is started, gathered once, disableUpdates()-ed, stopped, and released inside one call. A live NSMetadataQuery left running is a persistent file-system observer that reports every file the user touches inside the granted roots — a far larger grant than "search my Documents", and one no prompt describes. Without Full Disk Access the TCC-protected roots read as unreadable; those are reported in unavailableRoots rather than silently skipped, because a plugin that cannot tell "no matches" from "not allowed to look" shows the user an empty list and no way to fix it. If every root is unreadable the call fails rather than returning nothing.

Files the user picks. files.search answers questions about files and never opens one. The two files.userSelected.* capabilities open exactly one thing: the file the user chose in a panel, and nothing else.

The panel. open raises an NSOpenPanel and save an NSSavePanel, titled with the plugin's installed display name and carrying the reason its manifest declared — the same sentence the permission prompt showed. Nothing a worker sends at call time reaches the panel's title, message, or prompt. A modal window wearing Cirrulet's chrome is the best phishing surface the platform has, and "Cirrulet needs you to select ~/.ssh/id_rsa to continue" is a sentence no plugin gets to write. Directories cannot be chosen and file packages are not traversed, so a grant over one document does not become a grant over a tree. A panel needs a recent user interaction for the same reason applications.open does and a sharper one: a window that takes the screen from a background timer is an ambush. It does not spend from the three-open activation budget — a panel is one window the user must dismiss before another can appear, which the one-concurrent-panel limit already guarantees.

The boundary against files.search. A path a metadata query returned is not a file the user chose, and the separation is structural rather than checked. No operation in this family accepts a path. open and save take a type narrowing; read, write, and release take an opaque handle and nothing else. A plugin holding both capabilities and a search hit has nowhere to put it, and files.search is deliberately not a supplemental capability on any of the five. The handle also carries no path in the other direction — a descriptor reports a name, a size, and a type, never a location — so a read cannot confirm where anything files.search reported actually lives.

Handle lifetime, and where files.persistent begins. A handle is valid for the lifetime of the worker process that obtained it, and no longer. The registry is per-broker state, and a broker is built in makeRuntime and released when the runtime stops — user restart, crash, or idle reaping alike. That is enforced rather than incidental: a plugin holding storage.local can write a handle string to disk and present it after a restart, so besides the registry being empty, every handle carries a per-broker random nonce and a string from an earlier generation cannot collide with a live entry however many handles the new broker mints. Ask again; there is no silent re-acquisition, by design. A handle that survived process death would be a durable grant over a file the user picked once, which is what the still-reserved files.persistent would mean — that needs its own capability, its own prompt, and its own answer to "how does the user see and revoke this later", and none of those are questions this capability asks.

Reading is not writing. open mints read handles and save mints a write handle; the access mode is a property of the handle, not of the call that redeems it. A file the user opened for reading can never be written to, and a save destination can never be read back. Each direction needs its own declaration, and neither is supplemental to the other — the same rule as calendar.read and calendar.write, for the larger reason that "let this plugin read a file I choose" is not consent to overwrite it. Writes are atomic, so a plugin that fails midway leaves the user's file as it was.

What is re-checked after the panel. The chosen file is canonicalized, confirmed to be a regular readable file, and re-tested for UTType conformance against the manifest's declared itemTypes — the panel filters, and the host decides, because a drag into the panel's sidebar and a resolved alias both arrive by routes allowedContentTypes does not own. A save panel's suggestedName must be a plain file name: separators, .., leading dots, and control characters are refused, because the one string a plugin puts in front of the user must not be able to aim the panel somewhere the user is not looking. Every read and write re-authorizes against the live permission, so a grant revoked between the pick and the read stops the read.

Reads buffer, they do not stream. A file is read whole and crosses the wire as base64 — base64 rather than a JSON string because the files a user picks are as often a PDF or a PNG as they are UTF-8, and guessing an encoding in the host would corrupt every binary file silently. That makes 4 MiB a real ceiling in both directions, enforced twice on read (before the load, from stat, and again on the bytes, since a file can grow in between) and refused rather than truncated.

The clipboard buffer. It exists only in memory and is never written to disk, encrypted or otherwise. The exclusion markers are the only thing standing between it and the user's passwords and they are advisory, so some fraction of what lands there will be a token from a shell or a code from Messages. Plaintext on disk would put those in the app container for anything running as the user to read, surviving reboots; encrypting them would not help, because the buffer must be readable unattended, so the key must be available unattended, so anything that can read the container can get the key. It would buy the appearance of protection and nothing else. Entries expire after 30 minutes, the buffer holds at most 100, quitting Cirrulet destroys it, and clear() is wired for a user-facing control.

Never recorded: anything marked org.nspasteboard.ConcealedType (the password-manager convention), de.petermaurer.TransientPasteboardType (its older spelling, still what several shipping apps set), org.nspasteboard.TransientType, org.nspasteboard.AutoGeneratedType, or com.agilebits.onepassword; anything accompanied by a file URL, because a copied file's path is a filesystem disclosure the grant does not cover; and anything that is not plain text, because an image is megabytes and RTF is the same text with a document attached. Recording is reference-counted by consumers: an install with no plugin holding clipboard.history.read never samples the pasteboard, and when the last one stops the poll stops and the buffer is wiped. AppKit offers no change notification, so sampling is a one-second changeCount poll — one cheap IPC round trip, below the rate at which a person copies twice.

Reading and writing the clipboard as it is now. clipboard.read answers with what is on the pasteboard at the moment of the call; clipboard.write replaces it. Both require scope.itemTypes, and the host carries exactly the list the history buffer records — plain text and nothing else — so a type can never be readable point-in-time and unrecordable, or the reverse. A call may narrow to one declared type; naming an undeclared one is refused rather than intersected.

A read applies the recorder's exclusions, and the case for them is stronger here than there. The buffer starts empty when the user grants it and only ever sees what they copy afterwards; a point-in-time read sees whatever is sitting there now, including the password a manager put on the pasteboard four seconds ago. Concealed content, a copied file, an image, and an empty pasteboard therefore all answer null — one indistinguishable absence, deliberately. Reporting "concealed" separately would tell a plugin that the user has a credential on their clipboard right now, which is a fact worth having for exactly one kind of caller. Text longer than 16 KiB comes back truncated with truncated: true and the original byteLength, so a plugin can tell a long clipboard from a short one without the host carrying the whole of it.

Reads are limited to 30 per minute, and that bound is the reason the two capabilities can coexist. Without it a plugin holding only clipboard.read could poll and reconstruct the history buffer, which would make clipboard.history.read's separate and more alarming prompt decoration. The counter is charged before the grant is consulted, because a loop being refused at the prompt is still a loop.

A write additionally requires a recent user interaction, for the reason the activation ledger exists: silently swapping what is on somebody's clipboard is the whole of the classic hijack — the wallet address that changes between copy and paste — and it depends entirely on being able to write while the user is doing something else. A plugin that writes in the action the user invoked is unaffected. The activation is not spent, unlike an open: a command that copies a result and then opens a link is one gesture doing two ordinary things. Writes are limited to 10 per minute and to 16 KiB, and empty or whitespace-only text is refused rather than used to blank the clipboard. The host hands a writer to the broker only when the manifest declares the write half, so a plugin holding only clipboard.read has no object in its session that could replace the user's clipboard.

10.7.1 selection.read, and the permission it actually needs

Accessibility is not already granted, and this is the thing most likely to make the capability useless. Cirrulet holds one other Accessibility code path — MediaKeyInterceptor, for the System HUD — and it is easy to conclude from that the permission is paid for. It is not. SystemLevelIndicatorService.reconcile(enabled:) starts the interceptor only when systemHUDReplacementEnabled is true, ensureAccessibilityTrust() is reached only from that start, and the preference defaults to false in both ApplicationPreferences.init and its decoder. On a default install nothing in Cirrulet has ever raised the Accessibility dialog and AXIsProcessTrusted() is false.

So granting a plugin selection.read is not sufficient on its own, and the host does not pretend otherwise. A read without trust is not reported as an empty selection — that failure mode is worse than leaving the name reserved, because a reserved name at least fails honestly at validate:manifest, while a granted-but-silent capability leaves the author debugging their own code. Instead SystemPluginSelectionSource reports missing trust as its own sighting, the broker turns it into the refusal reason selection-accessibility-not-trusted, and the source raises the system dialog once per process run — reached only after the user has already answered Cirrulet's own permission prompt with Allow, so it is never unattended. The SDK documents the throw and tells authors to surface it rather than treat it as no selection.

The scope is the applications, and the prompt says so. applicationIDs is required. The permission prompt reads "read what you have highlighted in the apps listed below", renders only in these apps: <sorted bundle identifiers>, and adds the three bounds in the sentence the user is deciding on: the plugin sees only highlighted text, only while a listed app is in front, never a password field, and at most 30 times a minute. The broker authorizes against the whole declared list rather than the one application currently in front, because an Allow Always covers the whole list and a prompt naming only today's frontmost app would understate what it grants.

An out-of-scope selection is not a refusal. Once authorized, the broker compares the focused application against the grant and returns null when it does not match — the same answer as no selection at all. A refusal would be a frontmost-application oracle: a plugin declaring one obscure bundle ID could poll and learn, from which calls came back refused rather than empty, exactly when the user switched to it. That question is applications.frontmost, it is a separate grant with a louder prompt, and host.selection.read.v1 deliberately requires no supplemental capability precisely because it never answers it.

Privacy and rate. A secure text field — anything whose AX role is AXSecureTextField — reads as null on the clipboard buffer's reasoning: concealed content must be indistinguishable from no content, or the absence of text becomes evidence that a password is on screen. Text is truncated at 16 KiB with truncated set and byteLength carrying the pre-truncation length. Reads are capped at 30 per rolling minute, the same ceiling as the clipboard history and for the same reason: polling a selection is how a translator becomes a keylogger, and a cap low enough to stop that is still far above what a user-invoked action needs. The gate runs before the Accessibility read, so an ungranted plugin never causes the host to look at the user's screen at all.

The command registry's trust model. Four rules. Nothing is transferred: a published action runs in its publisher's worker under its publisher's authorizer and grants, so a plugin gains no capability by invoking one. Publishing is declared twice, in contributes.actions and in the capability's actionIDs, so a reviewer can see which entry points other plugins can reach. The registry is mutual: commands.publish gates listing and invoking as well as publishing, so a plugin that reads what others offer is itself a participant the user was prompted about, and a pure consumer cannot use the index as an inventory oracle. An invocation is never a user gesture: the registry does not touch the activation ledger, so a command hop cannot open an application or a URL on the strength of a click aimed at a different plugin.

Command IDs are <publisher plugin id>/<action id>, minted by the host — a publisher that could choose its own prefix could publish com.trusted.vendor/sign-in into somebody else's launcher. The caller does learn the publisher, because a launcher that cannot say whose command this is cannot show the user what they are about to run. Publishing replaces a plugin's whole set, so an empty array withdraws. A stopped publisher's commands are withdrawn and an invocation naming one is refused: the host does not start a plugin because another plugin asked, since that would mean running background code and reactivating standing grants at a third party's request.

Recursion is the problem §15.3 raises, and the command registry answers it in its most general form: a hop that stays inside the host. (Section 10.8 answers the mcp.client half, where the hop leaves.) Every hop carries a chain held as host-side state keyed by the plugin currently executing — never a token the plugin passes back — and a hop is refused when it would nest more than three deep, when the publisher is already in the chain (including a plugin invoking its own command), when the root has already caused sixteen invocations at any depth, or when the root's 30-second budget has expired. Depth alone would not bound work, because a plugin at depth one can fan out, which is why the invocation count is charged against the root and shared by every descendant. A plugin executing several inbound commands at once has its outbound hop charged to the deepest chain it is inside, which can only tighten the bound.

Limits. PluginLauncherLimits: 500 applications per response, 3 directory levels, a 60-second inventory lifetime, 6 enumerations per minute; a 15-second activation lifetime and 3 opens per activation; 2,048-character URLs; file-search queries of 2–256 characters, 100 results per call from at most 2,000 inspected items, a 5-second deadline, 1 concurrent search, 30 searches per minute; 4 MiB per user-selected file either way, 32 live handles, 16 files per open panel, 1 concurrent panel, 6 panels and 30 reads and 30 writes per minute, 255-character suggested names; 100 buffered clipboard entries, a 30-minute lifetime, 16 KiB per entry, a 1-second poll, 30 point-in-time reads and 10 writes per minute; 32 published commands per plugin, 256 listed, depth 3, 16 invocations per chain, a 30-second chain budget, 120-character titles, 200-character subtitles, 16 KiB of arguments and 64 KiB of result. Every per-plugin rate limit is counted over a 60-second window.

10.8 MCP client mediation

The other direction from section 15.1: a plugin consuming the MCP servers the user runs, rather than Cirrulet exporting itself as one. Everything below lives in Sources/EdgedPluginHost/MCP/, with the connection pool in Sources/EdgedInfrastructure/PluginMCP/, and every limit is a named constant in PluginMCPLimits.

MethodCapabilityParametersAnswer
host.mcp.servers.list.v1none (see below)noneservers[] of { serverId, available }
host.mcp.tools.list.v1mcp.client or mcp.userSelected.clientserverIdtools[] of { serverId, name, description, inputSchema }, truncated
host.mcp.tools.call.v1mcp.client or mcp.userSelected.clientserverId, tool, arguments?content[] of text and image blocks, isError

The two tool operations are the platform's only disjunctive authorization: they demand at least one of a pair rather than naming one capability outright. That is not a general "any of these will do" mechanism and should not become one. The pair exists because mcp.client and mcp.userSelected.client are one reach bound at two different moments (section 10.2.1), so demanding both would be satisfiable by no manifest and demanding either separately would need two spellings of each operation. PluginHostOperationAuthorizationPolicy expresses it as allowedCapabilities plus requiresAtLeastOneCapability; the SDK mirrors it as an array in OPERATION_CAPABILITY, and the marketplace scanner as supplementalCapabilities with requiresAtLeastOneSupplemental. All three must agree, and the drift test in the SDK fails when they stop.

What an MCP server is here: neither a process the plugin picks nor a URL it names. It is an entry in the list the user already maintains in Settings for the agent harness — a name, a command, arguments, and environment they typed themselves. A plugin holding mcp.client declares serverIDs containing those names and nothing else; a plugin holding mcp.userSelected.client declares no server names at all and receives whichever ones the user ticks at the prompt. There is no command field, no argument list, no environment, and no URL anywhere in the manifest schema or on the wire, so "may a plugin name a server the user has not approved" has the strongest possible answer: there is nowhere to put one. Spawning a process of the plugin's choosing would be a far larger grant than fetching a URL — the platform refuses node:child_process inside plugin code for exactly that reason — and this surface does not grant it. Every process the host starts here is one the user configured and already runs for the assistant.

Remote HTTP MCP is deliberately deferred rather than folded in. It needs a credential model (oauth.authorize's, not this one), an origin scope (network.fetch's), and above all a user-maintained catalog to name servers out of — and there is no such catalog for remote servers yet. Shipping it by letting a manifest carry a URL would reintroduce precisely the "name a server the user never approved" hole that the Settings catalog closes, so the local case ships first and the remote case waits for its catalog.

What the plugin holds, and what the host holds. The plugin holds a string it wrote in its own manifest. That is the whole of its side. It never receives a connection, a handle, a process identifier, or a file descriptor, and there is no connect or disconnect operation to call. The host holds which servers exist, what command each runs, its arguments and environment, the process, its stdio, the JSON-RPC framing, and the connection's lifetime. This is the division the network broker draws — the plugin names a destination, the host owns the socket — and the one section 10.5 draws for OAuth, where the plugin names a provider and the host owns the token.

Why listing servers needs no grant. host.mcp.servers.list.v1 is baseline, on host.oauth.status.v1's reasoning exactly. Listing tools or calling one reaches a program on the user's machine and is an escalation; asking which of the servers already inside this plugin's grant are switched on is not.

Two rules make that true, and both are enforced rather than argued:

  1. It answers out of the grant, never out of the catalog. Under mcp.client the grant is the manifest's serverIDs, so the answer is a boolean per name the plugin itself wrote. Under mcp.userSelected.client the grant is the set the user ticked, so the answer is a boolean per name the user handed it. Neither can enumerate the catalog, which is the only thing that would make this operation an escalation.

  2. It never raises the picker. Before a user-selected grant exists there is no set to report on, and the operation returns an empty list rather than prompting to create one. The broker reads the stored decision directly for this call — CapabilityAuthorizer.userSelectedIDs, which looks up and does not ask — precisely so that reading the list cannot become the thing that puts a modal on screen.

The second rule is what the earlier justification got wrong, and it is worth being explicit about because the wrong version was defensible for a year. "The answer is a boolean per name the plugin itself wrote" was true when mcp.client was the only shape and is false now; the property that survives user selection is that the answer is bounded by the grant and that asking never widens it.

Were it gated, a plugin deciding at launch whether to draw an "MCP results" section would raise a prompt nobody asked for — the unattended prompting the permission model exists to prevent. A granted server the user does not currently have is reported available: false rather than omitted, so a plugin can say "turn on the Foo server" instead of showing an empty list; a server outside the grant is absent whether or not it exists.

Scope, and the asymmetry inside it. Under mcp.client, serverIDs is required, so "any MCP server the user has" is not expressible in a manifest. toolIDs is optional and absent means every tool the named servers offer — the same asymmetry calendar.read draws between calendarSources and calendarIDs, and for the same reason: a plugin cannot know a server's tool list before connecting to it, and a server may add one in an update. The permission prompt therefore renders the absence as "every tool those servers offer" rather than a blank line that would read narrower than the grant. A tool outside a narrow toolIDs is filtered out of a listing and refused on a call: filtering keeps a server's update from turning a working plugin's listing into an error, while a call names one tool and either may reach it or may not. resourceIDs is accepted and narrows nothing today, because the runtime exposes tools and not resources; it predates this work in the vocabulary and refusing it now would invalidate manifests that validate.

Both checks are the broker's own and run before the authorizer is consulted, so an undeclared server cannot make the user answer a question about a server the plugin never published. The authorization request itself carries the whole declared scope rather than a per-call narrowing, because CapabilityScope.isWithin reads an omitted declared list as "nothing" — right for keys and origins, the exact opposite of what an omitted toolIDs means here. Separation between listing and calling comes from the operation string, as it does for the calendar writes: the two prompts read differently, and an Allow Once spent on a listing is gone before the call, which asks again.

Consent under mcp.client. The prompt names the plugin, the operation, and the servers, and adds one host-authored line the plugin cannot influence: the plugin can only reach servers you added in Settings, only the ones listed, and it cannot add a server, choose what command one runs, or see the command and environment of the ones it uses. That sentence is the bound that makes the question answerable — the fear a user should have about "let a plugin use MCP" is that it brings its own server, and the prompt is where that has to be denied.

Consent under mcp.userSelected.client. The same modal, with the server list replaced by a checklist of the user's own catalog — ServerChoicePicker in Sources/EdgedApp/PluginIntegration/PluginPermissionServerChoicePicker.swift, presented as the alert's accessory view. Allow is disabled until at least one server is ticked, because a grant of nothing is not a decision the user could have meant. A server the user has configured but switched off is listed and annotated rather than hidden, so ticking it is possible and the plugin's later available: false is legible.

This is the same shape as files.userSelected.read: the prompt is not "may it reach your files", it is the file picker, and the pick is the grant. What differs is durability. A file panel's grant covers the files that panel returned and nothing after it; a server selection is meant to persist, because a launcher re-picking its servers on every keystroke would be unusable. The selection is therefore stored on the decision itself, as PermissionBinding.userSelectedIDs, alongside the decision's lifetime — Allow Once still expires at the end of the invocation, so persistence here is a property of Allow Always and not of the picker.

When the catalog changes. The stored selection is a set of names, and MCPServerConfiguration.id is the server's name. That makes a deleted github and a differently-configured github added a week later the same string to a stored grant, so simply holding the names would let a re-add silently re-attach the plugin to a different program. The rules that close that:

  • Removing a server narrows the grant and re-asks. On the plugin's next MCP operation the grant is intersected with the catalog, and if anything was dropped the prompt returns with the survivors pre-ticked. The user re-confirms a set that is now real. The re-ask is deliberately attached to a real operation rather than fired at the moment of deletion: deleting a server in Settings should not summon a modal about a plugin the user was not thinking about.

  • Adding a server does not widen it. A grant covers what was ticked, and a name appearing in the catalog later is not something the user consented to. The plugin sees the new server only if the user revises the selection.

  • Revising is a first-class action, not a revoke-and-redo. The Plugins pane offers Change… on a granted user-selected capability, which re-opens the picker seeded with the current choice (DashboardServicing.reviseUserSelection). Revocation-only would price every adjustment at a full re-grant, which is the wrong price for a set the user is meant to curate.

Because the selection is the grant, revoking a user-selected capability from the Plugins pane clears every stored decision for that capability rather than flipping one to deny; a leftover allow carrying an old set would otherwise be found first the next time the plugin asked.

What the plugin observes. Its own grant, and only through host.mcp.servers.list.v1. There is no separate "which servers was I granted" operation and no field on the handshake, because the server list is exactly what that operation already returns and a second spelling of it would be a second thing to keep in step. A plugin holding mcp.userSelected.client before any grant exists sees an empty list, which is the honest answer and the one that makes "draw the MCP section only if there is something in it" work without prompting.

Lifetime. Connections are host-owned, pooled across plugins, and opened lazily on first use, so a plugin being reaped cannot close a server another plugin is using and cannot hold one open either. One pool serves the whole app: a server process is the user's, not a plugin's, and two plugins reaching the same server must not mean two copies of it running. An idle connection is reaped after two minutes, and the pool is torn down entirely on the privacy boundary the workers use — a node process Cirrulet started so a plugin could reach it has no more business running through a screen lock than the plugin that asked for it. Reconnection is lazy on the next call, so the cost of the teardown is one startup, paid by whoever asks first.

Recursion, which section 15.3 left open, is bounded the way the command registry bounds it and by one addition: the pool refuses to connect to Cirrulet's own MCP export endpoint, so a plugin cannot reach the host through the front door and arrive back inside itself. Beyond that the bounds are the same in kind as the launcher's — four calls in flight, thirty per rolling minute, a ten-second deadline on each — which bound work rather than depth. A plugin's outbound MCP call is not a user gesture and never stamps the activation ledger.

Limits. PluginMCPLimits: a 10-second deadline on a call, 5 seconds on a listing, 10 seconds to connect; 16 KiB of arguments; 1 MiB of returned content; 128 tools per listing; 4 concurrent operations; 30 per rolling 60 seconds; a 120-second idle connection lifetime. Arguments are measured before the call, because the point is that the bytes do not leave; content is measured after, because they have already crossed into the host process and the question is only whether the plugin receives them. A result over the ceiling fails the call rather than arriving truncated, since a plugin handed half a JSON document would parse it as a different answer. A slow server is a mcp-timeout failure, not a hung worker.

What the audit trail records. The plugin, the server, the tool, and how many bytes came back. Not the arguments and not the result: those are the plugin's question and the server's answer, which is exactly the data this capability bounds, and a trail carrying them would be a second copy of it somewhere the user was never told about. The same guarantee PluginNetworkAuditEvent makes about tokens.

11. Secrets and sensitive data

Secret preferences are stored under an OS credential-store entry keyed by extension ID, signing-key ID, and preference key.

The shipping worker can inspect, list, delete, or revoke extension-owned opaque secret handles through the four host.secrets.* methods in section 7.7. It cannot reveal plaintext.

The network broker in section 10.4 is where a credential is spent without being revealed, and section 10.5 is how one gets there. PluginNetworkBroker.perform serializes the request head per hop, after the plugin's own headers have been sanitized and after the hop has been authorized, and that is the single point at which a host-held Authorization header is attached: the plugin names a provider rather than supplying a value, the header is added to the serialized head, and it never appears in the response, the audit record, or anything the plugin can read back. The audit record carries the provider ID that was injected, so an operator can see that a credential was attached and to which origin, and never what it was. proxy-authorization is a reserved request header and www-authenticate, proxy-authenticate, and authentication-info are withheld from the response, so a challenge cannot become a side channel for an injected credential's shape. authorization remains un-reserved in general, because a plugin holding its own API key is a legitimate case; it is reserved per origin, for the origins the manifest and the host catalog jointly bind to a provider, and a plugin that sets it for one of those is refused rather than overridden.

Plaintext retrieval through a possible future host.secrets.reveal would be a distinct, high-risk capability because a network-enabled extension could exfiltrate the value. It is not accepted by the implemented v1 manifest or dispatcher. If introduced later, public-registry policy should reject it unless opaque use is technically impossible and the review records why.

Additional rules:

  • Secrets are never placed in the manifest, environment, command line, crash report, analytics, UI document, webview, or default logs.

  • Structured logs mark sensitive fields. The host also applies exact-value redaction for secrets it injected, while acknowledging that transformed values cannot be reliably scrubbed.

  • Clipboard data, selected text, file contents, MCP responses, and provider queries are treated as sensitive even when they are not credential-store secrets.

  • A signer change does not inherit secrets automatically.

  • Uninstall deletes grants and namespaced storage. Secret deletion is confirmed separately so users may retain credentials for reinstall if desired.

  • Team-managed secrets are resolved by the host policy layer and remain opaque to the package and registry.

12. Resource and crash isolation

Initial defaults, adjustable downward by policy:

  • 256 MiB resident memory per worker.

  • One worker and no child processes per extension.

  • 16 in-flight RPC requests per direction.

  • 8 MiB per RPC frame and 32 MiB queued transport bytes.

  • 2 MiB native UI document, 5,000 nodes, depth 32, and 30 UI updates per second.

  • 100 provider results per response and a two-second hard provider deadline. The deadline is PluginRuntimeResourceLimits.providerQueryTimeout, separate from the sixty-second requestTimeout every other call gets, and it is applied by PluginProviderGateway rather than by the transport: a provider answers a field the user is typing into, so its budget is a person's patience. Expiry cancels that one request. The worker keeps running and its other in-flight calls are unaffected.

  • Two-second initialization deadline.

  • Sixty-second foreground action deadline unless the surface remains interactive and the host renews it.

  • Ten-second background event CPU/wall budget, with a bounded extension-specific retry policy.

  • 1 MiB logs per session and 100 log records per second.

  • 10 MiB default key-value storage; larger data uses an explicitly quota-managed cache API.

The operating-system implementation should use process groups plus available primitives such as App Sandbox profiles, restricted tokens/AppContainer, job objects, cgroups, namespaces, seccomp, and rlimits. Exact primitives are platform-specific, but conformance tests must verify observable denials.

Current implementation boundary: the POSIX launcher validates non-symlink executable/package/entrypoint paths, prevents entrypoint symlink escape, sets the child working directory to the validated package root, builds a fixed environment rather than inheriting the host environment, creates a dedicated process group and protocol socket, captures stdout/stderr separately in bounded diagnostic queues, enforces protocol byte/queue/request bounds, and supervises handshake, deadlines, cancellation, exit, graceful shutdown, SIGTERM, and SIGKILL escalation.

The 256 MiB ceiling is enforced. The helper samples its own phys_footprint through proc_pid_rusage on the interval named in its initialization header and exits with status 66 when it crosses the limit; the supervisor samples the child independently and terminates it if the helper does not stop itself, records a PluginResourceLimitBreach, and the Plugins window shows the reason the plugin stopped. RLIMIT_AS is deliberately not used: JavaScriptCore reserves large virtual ranges up front, so an address-space cap either refuses to start or fires far from real memory use.

The macOS sandbox is enforced through the helper's own code signature rather than a sandbox profile applied at launch. A production launch only proceeds for the bundled helper after BundledPluginRuntimeVerifier confirms it is a non-symlink, 0500, correctly owned regular file with a valid signature, a matching team identifier and hash, com.apple.security.app-sandbox present, and no network, file-access, Apple-events, camera, microphone, location, address-book, or calendar entitlements. Anything else throws operatingSystemSandboxUnavailable and never launches. The explicitly named unenforced mode exists only for trusted development and deterministic test fixtures and is not evidence that untrusted plugins are contained.

Still unenforced at the process level: syscall filtering beyond the sandbox profile, CPU-time and open-handle ceilings, and per-plugin disk quotas.

The supervisor monitors:

  • Memory, CPU, wall time, open handles, transport backlog, log volume, and UI churn.

  • Unexpected syscalls or runtime permission failures where surfaced.

  • Repeated permission denials, malformed messages, invalid patches, and ignored cancellation.

Soft-limit breaches first return -32003 or throttle. Hard-limit or malicious-pattern breaches terminate the process. An extension cannot suppress host diagnostics.

13. Install, signing, update, and revocation

13.1 Install sources

  1. Registry install: trusted registry metadata identifies an exact digest and publisher key.

  2. Signed sideload: the user selects a .edgedx, reviews signer fingerprint and permissions, and installs without a registry.

  3. Developer install: an unpacked local project runs only in Developer Mode, with a persistent warning. Background schedules and MCP exports are off until separately enabled.

Unsigned archives are not normal sideloads. They are developer artifacts and never auto-update.

13.2 Publisher and registry signatures

The base design uses Ed25519 publisher keys:

  • The package signature authenticates publisher intent and package integrity.

  • A registry signature authenticates registry inclusion, channel, review status, and expected digest.

  • The client pins a registry root and consumes expiry-protected root, targets, snapshot, and timestamp metadata following The Update Framework model.

  • The client stores the highest trusted version and metadata generation to detect rollback and freeze attacks.

A hosted registry may additionally use Sigstore identity and transparency, but package verification must work offline after metadata is cached. Whether Sigstore replaces or augments long-lived publisher keys is an unresolved security decision.

13.3 Build and publication

  • CI installs from a lockfile with lifecycle scripts disabled.

  • The CLI bundles dependencies and rejects native code, external runtime downloads, post-install output, undeclared assets, and nondeterministic package metadata.

  • sbom.spdx.json records bundled direct and transitive components.

  • A reproducibility check rebuilds the package in a clean environment and compares integrity.json.

  • Registry review examines capability rationale, domain scopes, secret handling, minified or generated code, known vulnerabilities, license metadata, and source-to-artifact provenance.

  • Published (id, version, digest) tuples are immutable.

13.4 Safe update

  1. Fetch and verify expiry-protected registry metadata.

  2. Download to a non-executable staging area with strict size limits.

  3. Verify archive structure, integrity map, publisher signature, registry target digest, compatibility, and revocation state.

  4. Compute manifest, capability, preference, event, MCP-export, and signer diffs.

  5. Prompt for expanded permissions or identity changes.

  6. Run a sandboxed initialization smoke test with no capabilities and no secrets.

  7. Atomically activate when the extension is idle or the user approves interruption.

  8. Retain the last known good version and its compatible data migration checkpoint.

Storage migrations must be transactional and versioned. A package may not destructively migrate shared state before it becomes last known good. Automatic rollback is blocked if the new version explicitly completes an irreversible migration; v1 should avoid supporting such migrations.

13.5 Revocation

Revocation metadata can target a publisher key, package digest, or version range and includes severity and reason. The client:

  • Immediately blocks known-malicious versions.

  • Allows a time-bounded warning override only for non-malicious policy revocations.

  • Disables background and MCP entry points before showing UI.

  • Preserves user data for export or a clean reinstall.

Key rotation is accepted when signed by both old and new keys or authorized through a registry recovery process. Recovery without the old key is a high-trust registry action and must be transparent to users.

14. Compatibility and versioning

Use Semantic Versioning 2.0.0 for the extension package, SDK, host API, and manifest protocol requirement. The selected wire protocol is encoded canonically as major.minor; 1.0 corresponds to manifest compatibility version 1.0.0.

Independent version axes:

  • manifestVersion: integer syntax and semantics of manifest.json.

  • Package version: extension release version.

  • engines.edged: compatible host API version range — not the app's marketing version.

  • Runtime target: JavaScript execution environment contract, initially edged-js-v1.

  • Transport framing: pre-launch runtime contract, currently ndjson-v1; it is not negotiated from bytes on an active stream.

  • Runtime protocol: JSON-RPC method contract range.

  • Runtime sdk: author-facing SDK range.

  • uiSchemaVersion: native UI document contract.

  • Event suffix such as .v1: payload schema.

  • Capability names and scope schemas: versioned by additive schema or a new capability name.

14.1 When the host API version moves

The host API version is the one axis authors write a floor against, so it has to move whenever plugin-observable behaviour changes — a widened island SF Symbol allowlist, a new capability or host operation, a raised limit, a new UI node type, a new wire reason a plugin might branch on. Raise the minor for anything additive and the major only for a removal or a semantic change to something already shipped; a bug fix nobody could have depended on needs nothing. The test is not "did the host change" but "could an author reasonably need to require this?" — and if the answer is yes, bumping is not optional, because there is no other way to say it. Bumping later does not help: a release that shipped without the bump is permanently indistinguishable from one that lacks the behaviour, and the failure is usually silent rather than loud. The version sat at 1.0.0 from the beginning to 1.1.0 for exactly this reason — nobody had written down what would make it move, so it never did, and plugins depending on the widened glyph list had no version to name. One coordinated bump per release train, agreed before the cut, is the right granularity; several concurrent changes share it.

What each version means has to be written down, because a floor an author names is worth only what the host will confirm about it. 1.1.0 is the widened island symbol allowlist — 30 approved glyphs instead of 14 — and the split of invalid-live-activity into eight distinct wire reasons. 1.2.0 adds the point-in-time clipboard pair, clipboard.read and clipboard.write, adds mcp.userSelected.client, and removes activities.publish from the capability vocabulary. The removal is a minor rather than a major because the name was reserved for its whole life: it never had an implementation behind it, no published plugin declares it, and ui.island has always been the surface it would have named. Nothing that ever worked stops working.

mcp.userSelected.client is the exception that proves the rule above, and it is worth reading as one. It was planned for the release after the one carrying the clipboard pair, and would have taken 1.3.0. It moved forward into the same release, and folding it into 1.2.0 rather than bumping again widened a version whose meaning had already been written down — normally the precise mistake this section exists to prevent, because a plugin declaring >=1.2.0 for it would install on a 1.2.0 host that predates it and fail at runtime instead of at install. It was allowed because no build reporting 1.2.0 had been notarized or published when the decision was made, so no manifest anywhere could have named the narrower meaning: the version had no installed base to mislead. That is a one-time argument, not a policy. Once a version has shipped, its meaning is fixed and the next capability takes the next minor.

Compatibility rules:

  • Protocol major versions are incompatible. A minor version may add optional methods or fields only inside method payloads explicitly marked extensible or guarded by negotiated features. The v1 JSON-RPC envelope is closed: changing its keys or NDJSON framing is not a minor-version change.

  • The handshake selects one exact protocol, host API, SDK compatibility, and UI schema version. Every post-handshake envelope uses that protocol exactly; a midstream version change is a protocol violation.

  • Hosts and SDKs advertise only versions they actually implement. Manifest ranges are pre-launch filters, not permission to guess or silently fall back after an incompatible handshake.

  • A stable host that removes a protocol major must first ship at least one release train supporting both old and new majors, publish migration tooling and a documented path, and analyze installed packages without requiring telemetry.

  • The SDK may be newer than the negotiated protocol only when it has an explicit, tested downlevel implementation. Merely ignoring unknown fields is insufficient.

  • Unknown required capabilities, node types, events, or fields fail closed.

  • Deprecated methods remain for at least one protocol major and emit development warnings without changing production output.

  • Store validation tests each package against its minimum and maximum declared host ranges.

  • Protocol fixtures are language-neutral JSON files so alternative runtimes can implement the contract.

  • A framing migration introduces a new runtime/transport contract and a fresh connection. Hosts never parse Content-Length and NDJSON on the same channel, and registry compatibility metadata must identify packages that require the successor before activation.

15. MCP interoperability

MCP is optional at install and runtime. It does not replace the internal protocol because Cirrulet needs lifecycle, UI, grants, cancellation, and resource semantics beyond generic MCP transport.

15.1 Exporting extension functionality

The host can expose declared contributions through its own MCP server:

  • An action with a headlessHandler maps to an MCP tool with JSON Schema input and output.

  • Read-only providers can map to resources or resource templates.

  • UI-only actions are not exported automatically.

  • The host labels every tool with extension, publisher, version, capability summary, and side-effect classification.

  • mcp.export is a separate grant because it makes functionality and potentially data available outside the launcher UI.

  • MCP callers never receive a host user-gesture token. Side-effecting calls use host policy, confirmation, or an explicit pre-authorized automation rule.

15.2 Consuming MCP servers

Shipped for local stdio servers; see section 10.8 for the design and the limits. The plan below is what it was measured against, and it holds except where the entry says otherwise:

  • The manifest declares server IDs and a tool allowlist. Categories are not a thing: an ID names one entry in the user's Settings catalog, so there is no category for a manifest to name.

  • The host owns transport, process startup, and server health. Authentication, OAuth, and secrets do not arise for a local stdio server, whose credentials are whatever environment the user configured — and which the plugin cannot read.

  • Grants are the intersection of the manifest's serverIDs/toolIDs with the user's catalog and their permission decision. MCP server policy and team policy are absent, because neither exists to intersect with.

  • Responses are size-limited (1 MiB) and treated as untrusted data.

  • The extension cannot pass through authentication headers or ask the host to connect to an undeclared server. Stronger than planned in practice: the wire protocol has no field for a command, an environment, or a URL, so an undeclared server is not expressible rather than refused.

Remote HTTP MCP remains unshipped, and section 10.8 says why it waits for a user-maintained catalog rather than a manifest URL.

  • MCP-originated calls carry source: "mcp" and never masquerade as launcher interaction.

  • An exported Cirrulet tool calling another MCP tool is a new privileged hop checked separately.

  • The host detects obvious self-recursion and enforces maximum call depth, fan-out, cost, and duration.

  • Webviews cannot directly access MCP.

The cross-plugin command registry (section 10.7) is the first surface where this stopped being a plan. Its chain bookkeeping — host-side, keyed by the executing plugin, bounding depth, cycles, per-root fan-out, and wall clock — is the shape mcp.client should adopt rather than reinvent, and its rule that an inter-plugin hop never counts as user interaction is the same rule as "never masquerade as launcher interaction", enforced by construction instead of by convention.

mcp.client (section 10.8) adopts the bounds and declines the chain, because the cycle it has to break is a different one. A hop out to an MCP server leaves the host entirely, so there is no chain to carry: the only way back in is Cirrulet's own export endpoint, and the pool refuses to connect to it. What remains is work rather than depth, bounded by four concurrent operations, thirty per minute, and a ten-second deadline each. A plugin's MCP call does not stamp the activation ledger, so it cannot open an application on the strength of a click.

16. Developer workflow

16.1 Command-line tooling

There is no edged command. The tooling is a set of binaries the SDK publishes, so an author who ran npm install @cirrulet/sdk has all of it:

cirrulet-validate-manifest <package.json> [--allow-unpublishable]
cirrulet-bundle-plugin <entrypoint.ts> [out.js]
cirrulet-build-plugin [pluginDir] [--entrypoint <path>] [--out <dir>]
cirrulet-simulate-plugin <pluginDir> [--args <id>=<json>] [--symbols a,b,c]
cirrulet-keygen-publisher --out <key.pem> --key-id <id> [--publisher <name>]
cirrulet-release-plugin [pluginDir]
cirrulet-sign-plugin <packageDir> [--key <key.pem>] [--key-id <id>]
cirrulet-verify-signature [pluginDir...] [--plugins <dir>]
  • validate-manifest checks a manifest against both contracts: the runtime one the host enforces (exit 1) and the distribution one the marketplace enforces (exit 3). Source-only fields such as dependencies are a note rather than a failure, because a build step strips them.

  • bundle-plugin emits the single self-contained JavaScriptCore script the runtime loads. It is byte-reproducible — the same sources bundle to the same digest from the author's checkout, a plain install, and the marketplace's build sandbox — because the bundle's SHA-256 is the package's identity. @cirrulet/sdk/bundler exports the same function in-process, which is what a build script must use: the marketplace's static-analysis gate rejects node:child_process anywhere in a submitted checkout.

  • build-plugin sequences validate → bundle → project the distribution manifest → carry the committed signature into the output, producing the sign-ready package directory. This was a near-identical hand-written scripts/build.mjs in every plugin until it became one function; @cirrulet/sdk/builder exports it in-process for a plugin that still wants its own build script.

  • simulate-plugin runs every contributed handler of a built plugin against the headless host of §16.2 and reports what the host would refuse, with the machine-readable reason. It is a smoke pass over the no-state paths; a plugin's own tests drive the simulator directly for the rest.

  • keygen-publisher produces an Ed25519 publisher key (written 0600) and prints both the enrolment body and the cirrulet-publisher.json to commit.

  • release-plugin builds, signs, and writes the envelope beside the sources where the marketplace's sandbox rebuild will find it. sign-plugin is the signing step alone, for a package directory assembled some other way.

  • verify-signature checks that each plugin's committed envelope verifies under the enrolled key, and names the key that signed when it does not. It belongs in a plugin repository's test script.

16.1.1 The enrolled-key check

An envelope's keyID is a label the signer chose: --key-id acme-2026 writes that string into the envelope whatever key --key pointed at, so a package signed by the wrong key is indistinguishable from a good one by inspection, and the marketplace reports it as a generic overall-gate failure with every individual gate green. Two of the first four first-party plugins shipped signed by a git SSH key and were caught by an audit that happened to run.

The only trustworthy question is the cryptographic one, and answering it needs the public half of the enrolled key somewhere the tooling can reach in the marketplace's build sandbox — which has neither a home directory nor the key. So it goes in cirrulet-publisher.json, committed at the root of the plugin repository:

{
  "schemaVersion": 1,
  "keyId": "acme-2026",
  "publicKey": "<base64 raw 32-byte Ed25519 public key>",
  "privateKeyPath": "~/.config/cirrulet/publisher.pem",
  "publisher": "acme",
  "retiredKeys": [{ "publicKey": "…", "description": "what this key is" }]
}

Every command finds it by walking up from the plugin directory. With it present, signing takes no --key, refuses any key whose public half is not the enrolled one before writing anything, and prints the key that actually signed. retiredKeys exists so that a signature from a key that has actually been mistaken for the enrolled one is named rather than merely rejected; Ed25519 offers no key recovery from a signature, so a key absent from that list can only be reported as unrecognised. Rotation is an edit to this one file plus a re-release of every plugin.

Nothing here produces a .edgedx archive, an SBOM, or a provenance record, and nothing installs or publishes from the command line: a developer-mode package is loaded by pointing the Plugins window at its directory, and submission goes through the marketplace. There is no watch mode — the inner loop is cirrulet-build-plugin and re-pointing the Plugins window.

16.2 SDK shape

The SDK should be small and transport-independent:

import { defineExtension, ui } from "@cirrulet/sdk";

export default defineExtension({
  actions: {
    async showGreeting() {
      return ui.document({
        surfaceID: "greeting",
        root: ui.detail({
          id: "greeting-detail",
          title: "Hello",
          children: [
            ui.action({
              id: "acknowledge",
              title: "Acknowledge",
              event: "greeting.acknowledged.v1",
            }),
          ],
        }),
      });
    },
  },
  async uiEvents(_context, event) {
    return {
      actionID: event.actionID,
      payload: event.payload,
    };
  },
});

The SDK:

  • Generates and validates wire schemas.

  • Validates extension.initialize, selects only exact offered protocol/API/UI/SDK compatibility values, echoes the manifest digest, and reports deterministic handler IDs.

  • Uses AbortSignal for cancellation and deadlines.

  • Carries opaque handles rather than exposing unrestricted paths or secrets.

  • Does not hide permission errors or silently fall back to ambient platform APIs.

  • Keeps imports at module scope and emits one bundled ESM entry.

@cirrulet/sdk/testing is a headless host and a protocol recorder:

import { createHostSimulator } from "@cirrulet/sdk/testing";
import manifest from "../package.json" with { type: "json" };
import extension from "../src/extension.js";

const host = createHostSimulator({ manifest, extension });
await host.invokeAction("start", { seconds: 1500 });
assert.deepEqual(host.refusals, []);
assert.deepEqual(host.island.liveActivityIDs(), ["countdown"]);

It answers host.call the way the host does — the same checks in the same order, and the same machine-readable refusal reasons on the wire — with a clock the test owns, permission decisions the test sets, and no I/O. calls is the recording; refusals is the part almost every assertion wants. It models the island and storage.local in full; every other operation is stubbed by the test, and an un-stubbed call is refused rather than answered with nothing, so a test cannot pass because a handler silently received an empty object.

What it converts from a runtime disappearance into a test failure: an SF Symbol outside the allowlist, an undeclared view or live-activity ID, a document past the island's 64 nodes or 6 levels, a child its parent does not accept, a TTL over the manifest's lease, a progress rate that would outlive the four-hour deadline ceiling, the per-minute update budget, the two-activity quota, storage values and totals, and the 16 KiB / depth-8 webview bridge envelope. It also runs the handshake's handler-to-contribution comparison before any handler is called, so a mistyped handler key is a named failure rather than registeredHandlersMismatch at startup.

What it cannot: anything about rendering, and anything the host decides from state the simulator does not hold — the self-expansion interruption budget, Focus suppression, the user-activation ledger.

Every bound it enforces is declared once, in src/host-limits.ts. A test parses the Swift sources and fails when the table stops matching them; the allowlist drifting unnoticed is what put unapproved symbols into a shipped first-party plugin.

16.3 Developer Mode

Developer Mode is explicit and visible:

  • Trust is bound to project path and package ID.

  • Source changes restart the worker and invalidate the current runtime session.

  • Capability prompts remain real, with a reset command for tests.

  • Inspector access is opt-in and local-only.

  • Production secrets are not copied into test fixtures by default.

  • Developer packages cannot impersonate installed production identity or inherit its grants and secrets.

17. Observability and privacy

Local diagnostics should include:

  • Worker state, start reason, protocol version, and active contribution.

  • Request durations, cancellations, quota usage, and stable error codes.

  • Permission decisions without sensitive payloads.

  • Package verification and update decisions.

  • Crash and quarantine receipts.

Payload logging is off by default. Hosted telemetry is opt-in for individuals and policy-controlled for teams. Provider queries, selected text, file names, URLs, form values, UI text, MCP payloads, secret keys, and network bodies are classified as sensitive.

Extensions get their own redacted logs but cannot read host logs or another extension's logs. Support bundles require preview and selective inclusion.

18. Staged implementation and test plan

No public untrusted package should run until Stage 3 security gates pass.

Implementation checkpoint (2026-08-20): the repository now has the v1 manifest/capability/UI values, strict versioned NDJSON codecs in Swift and TypeScript, a real test/development POSIX subprocess supervisor, compatibility handshake, bounded requests with timeout/cancellation, lifecycle/crash transitions, graceful termination escalation, an atomic permission-decision store, and capability-gated host-operation dispatch seams. Deterministic tests execute temporary mock workers and verify clean environment construction, separate diagnostics, handshake, cancellation, escalation, path rejection, and permission persistence. This is a headless vertical slice, not completion of Stage 1 or Stage 3: package tooling, production runtime integration, native rendering, webviews, Keychain-backed secrets, broker implementations, OS resource enforcement, and a supported macOS sandbox remain.

Stage 0: freeze contracts and threat model

Deliver:

  • Manifest, package, protocol, UI, capability, and registry JSON Schemas.

  • Threat model covering malicious packages, compromised publishers, confused-deputy calls, SSRF, archive attacks, UI spoofing, secret exfiltration, event abuse, webview escape, MCP permission laundering, and denial of service.

  • Golden valid/invalid packages and protocol traces.

  • Decision records for runtime, OS sandbox, signing root, and supported v1 platforms.

Tests and exit gate:

  • Schema round trips and canonicalization vectors pass in TypeScript and host language.

  • ZIP traversal, duplicate path, Unicode normalization, decompression bomb, and signature test corpus passes.

  • Independent security review agrees that the planned sandbox can enforce the declared capability boundary.

Stage 1: package toolchain and headless worker

Deliver:

  • CLI scaffold, lint, deterministic bundle, integrity map, and local install.

  • SDK bootstrap, process supervisor, framing, initialization, deadlines, cancellation, logs, and crash backoff.

  • Headless action invocation with no privileged capabilities.

Tests and exit gate:

  • Byte-by-byte framing, split frames, coalesced frames, invalid UTF-8, duplicate keys, deep JSON, oversized frames, request floods, and crash loops.

  • Property and coverage-guided fuzzing of frame parser and RPC validator.

  • Reproducible builds across clean machines.

  • Killing or hanging a worker never hangs or crashes the host.

Stage 2: native actions and renderer

Deliver:

  • Action search, arguments, navigation, all v1 native nodes, UI events, patches, and accessibility semantics.

  • Mock host, deterministic UI snapshots, and hot reload.

Tests and exit gate:

  • Golden documents for every node and state.

  • Invalid-tree and patch fuzzing, stale revisions, node and depth limits, and update floods.

  • Keyboard-only, screen-reader, high contrast, reduced motion, RTL, localization expansion, and large text tests.

  • Extension text cannot spoof host permission chrome or escape the renderer.

Stage 3: capability broker and sandbox

Deliver:

  • Grant store, install and just-in-time prompts, revocation UI, permission diffs.

  • Brokered network, file handles, clipboard, notifications, storage, and opaque secret use.

  • Restricted runtime and per-platform OS sandbox.

Tests and exit gate:

  • Attempted direct network, filesystem, process, FFI, environment, handle inheritance, debugger, and cross-extension access fails.

  • SSRF suite covers redirects, DNS rebinding, IPv4/IPv6 literals, private ranges, proxy behavior, and mixed encodings.

  • Confused-deputy tests forge invocation IDs, gesture flags, capability IDs, grants versions, and signer identity.

  • Secret tests cover logs, crashes, webviews, errors, updates, signer changes, and network failures.

  • External penetration review finds no route around host capability checks. Unsupported platforms remain developer-only.

Stage 4: providers, events, and supervision

Deliver:

  • Global providers, caching, cancellation, invalidation, schedules, durable delivery, retries, quotas, quarantine, and safe rollback triggers.

Tests and exit gate:

  • Provider latency and cancellation under rapid typing.

  • At-least-once delivery, duplicate events, sleep/wake, clock changes, missed schedules, offline mode, and host restart.

  • CPU, memory, log, storage, RPC, UI churn, and retry-storm limits.

  • A malicious background extension cannot prompt, steal focus, stay resident, or materially degrade launcher latency.

Stage 5: signed distribution and updates

Deliver:

  • Deterministic .edgedx, Ed25519 signing, registry roots and TUF-style metadata, staged activation, permission diff, revocation, key rotation, and rollback.

  • Reference registry and hosted-registry compatibility suite.

Tests and exit gate:

  • Tampering, truncation, signature substitution, key confusion, replay, rollback, freeze, expired metadata, revoked signer, and compromised mirror scenarios.

  • Power loss or host crash at every update step leaves one complete active version.

  • Permission expansion never activates without consent.

  • Registry and sideload installations of the same digest execute identically.

Stage 6: webviews and MCP

Deliver:

  • Isolated local-content webviews, CSP, message bridge, content decoder, and host network proxy.

  • MCP export and client adapters, provenance, confirmation, recursion limits, and policy intersection.

Tests and exit gate:

  • Web platform escape suite: navigation, popup, download, service worker, cache, cookie, iframe, custom scheme, CSP, oversized message, and cross-extension storage tests.

  • MCP tests cover caller spoofing, prompt injection as data, recursive calls, confused deputy behavior, oversized responses, cancellation, and server failure.

  • Security review explicitly approves each feature; either may ship later than core v1.

Stage 7: beta and operational hardening

Deliver:

  • Curated beta registry, review runbook, revocation drill, support bundle, compatibility dashboard, documentation, and migration tooling.

Tests and exit gate:

  • Soak tests with hundreds of installed extensions and mixed active providers.

  • Host upgrade/downgrade matrix across supported protocol, SDK, manifest, and UI versions.

  • Publisher-key compromise and malicious-package incident exercises.

  • Measured launcher startup and query latency remain within product budgets with failed, slow, and noisy extensions installed.

Continuous test suites

  • Unit: schemas, scope normalization, permission diffs, ranking adapters, storage, and redaction.

  • Contract: language-neutral RPC, UI, package, registry, and event fixtures.

  • Integration: real subprocess, sandbox, broker, renderer, scheduler, and updater.

  • End to end: scaffold, develop, build, sign, install, invoke, update, revoke, and uninstall.

  • Fuzz: framing, JSON, manifests, archives, UI trees, patches, URLs, Markdown, images, and MCP payloads.

  • Security: sandbox escapes, capability forgery, SSRF, secret leakage, supply-chain, and webview isolation.

  • Performance: cold start, warm action, provider query, large list, update rate, memory, and battery impact.

19. Security decisions still open

These choices must be resolved before the corresponding implementation gate:

  1. JavaScript and OS sandbox: validate pinned Deno plus platform sandbox, or select a smaller embedded engine. Decide which operating systems can safely run untrusted public extensions at launch.

  2. macOS containment: confirm a supported, reviewable App Sandbox/helper design rather than relying on deprecated sandbox-exec, and prove that inherited entitlements do not create ambient access.

  3. Publisher trust and recovery: choose long-lived Ed25519 keys, Sigstore identity, or both; define transparency, key loss, emergency rotation, and registry-compromise behavior.

  4. Network boundary: finalize DNS resolver, proxy, VPN, private-address, redirect, TLS interception, and domain-wildcard rules. Origin strings alone are not an adequate SSRF defense.

  5. Raw secret access: v1 omits secrets.reveal; decide whether a later trust tier ever adds it or all credentials remain behind opaque broker templates.

  6. Webview remote content: decide whether remote navigation, iframes, persistent storage, or direct connections are ever permitted. The proposed v1 default is no.

  7. MCP consent propagation: define how caller identity and human approval survive nested tool calls, automations, and headless clients without laundering launcher grants.

  8. Sensitive event scope: decide whether clipboard, active-app, selected-text, browser, and broad filesystem events are omitted or placed in a separately reviewed trust tier.

  9. Crash diagnostics and telemetry: define retention, source-map handling, user preview, and whether any payload sampling is acceptable.

  10. Future native/process capability: decide whether native addons, shell commands, or helper binaries are permanently excluded or supported only as separately signed, high-trust integrations.

20. Open-source references

The references below were reviewed for active project and documentation state on 2026-08-20. License notes are implementation due diligence, not legal advice.

Raycast extensions

Useful patterns: a TypeScript/JavaScript package workflow, npm-compatible manifest, declared commands and preferences, typed APIs, native host rendering, and a reviewed store.

License note: the extension catalog is MIT, and Raycast's store guidance requires submitted extensions to use MIT. That repository license does not license the Raycast application or imply that Cirrulet should copy its private host implementation or API surface.

Script Kit

Useful patterns: TypeScript scripts driving a native host, subprocess-style message flow, rapid local authoring, focused prompt primitives, and script metadata.

License note: the SDK repository is MIT, and the current rewrite README also declares MIT. Verify the exact repository and file licenses before reusing code; this design borrows architectural lessons, not implementation.

SwiftBar

Useful patterns: executable plugins, a simple stdout-oriented contract, language independence, packaged assets, and compatibility with an existing plugin ecosystem.

License note: SwiftBar is MIT. Its simplicity is valuable, but executable files with ambient user permissions are not an adequate security model for Cirrulet; Cirrulet retains the package ergonomics while adding a broker, schemas, signing, and sandboxing.

WasmKit

Useful patterns: an embeddable runtime, explicit WASI boundary, resource-bounded execution, and a possible future portable runtime for non-JavaScript or high-risk background providers. WasmKit is not required for the v1 JavaScript subprocess.

License note: core WasmKit runtime modules are MIT. Its GDB Remote Protocol support is separately Apache-2.0 with the Swift Runtime Library Exception, as documented in its README.

Standards

21. v1 acceptance criteria

Cirrulet v1 is ready for public third-party extensions only when:

  • TypeScript and JavaScript extensions can be scaffolded, tested, built reproducibly, signed, installed, invoked, updated, rolled back, revoked, and uninstalled.

  • No extension code executes in the host process.

  • Native UI is fully host-rendered from validated schema, and webviews remain isolated and optional.

  • Every privileged operation maps to a manifest declaration, user/team grant, host-side check, audit receipt, and revocation path.

  • Direct network, external file, process, FFI, secret, and cross-extension access fail in adversarial tests.

  • Slow, crashed, malformed, or malicious extensions cannot crash the host or make global launcher latency unusable.

  • Protocol, manifest, UI, event, and capability compatibility are covered by language-neutral fixtures.

  • Alternate registries and signed sideloading work without the commercial Cirrulet service.

  • The unresolved security decisions relevant to enabled features have written decisions and passing enforcement tests.

22. What ships today

Everything above section 22 is the target design. This section is the shipped behaviour of the code in this repository, and it is the contract a marketplace or third-party packaging tool should build against.

22.1 Packaging contract (authoritative)

ElementValue
Archive extension.edgedplugin (ZIP)
Manifest inside the archivepackage.json
Signature fileedged-package-signature.json
Runtime targetedged-js-v1
Canonical entrypointdist/plugin.js
Also accepteddist/*.mjs, for already-signed v1 packages that cannot be rebuilt
Rejectedany other extension, including .cjs, .json, and extensionless paths
runtime.protocol^1.0.0
runtime.sdk^1.0.0
engines.edgedmust admit the host API version, currently 1.2.0
Platformdarwin-arm64
Bundle formatone classic script, no ESM import/export at top level, that calls defineExtension exactly once

Build the bundle with the SDK's bundler, which writes dist/plugin.js next to the entrypoint's package root and refuses a bundle that never calls defineExtension:

node sdk/typescript/scripts/build-plugin-bundle.mjs path/to/src/extension.ts

New packages should emit dist/plugin.js. The .mjs alias exists only so already-signed packages keep loading; treat it as deprecated and do not document it to new authors.

The bundle is byte-reproducible: the same sources produce the same dist/plugin.js no matter which directory the bundler runs from. The host records the SHA-256 of the packaged content as the installed version's identity, so a publisher and a marketplace that build the same sources must agree on the digest.

Handler names must match the manifest exactly: a contribution with "id": "session-started" needs a handler key of "session-started", not sessionStarted. The handshake compares the registered handler sets against the manifest's action, provider, and event IDs and refuses to reach ready if they differ. examples/plugins/hello-edged is the worked example and is exercised end to end by ExamplePluginEndToEndTests.

Signing is Ed25519 over the canonical JSON of the payload, and the publisher signs the logical package, not the ZIP container. The SDK ships both halves:

# Once per publisher. Writes the private key 0600 and prints only the public
# half, plus the cirrulet-publisher.json to commit (§16.1.1).
cirrulet-keygen-publisher --out ~/.config/cirrulet/publisher.pem \
  --key-id acme-2026a --publisher acme

# Once per release. Builds, signs, and writes the envelope beside the sources.
cirrulet-release-plugin path/to/plugin

Signing walks the package directory, hashes the canonical manifest and the content inventory, signs the payload, and writes edged-package-signature.json into the directory. The private key comes from --key, from EDGED_PUBLISHER_PRIVATE_KEY, or from the trust file's privateKeyPath, and it is never written anywhere else. --publisher asserts the expected publisher, --dry-run prints the digests without writing, and --re-sign replaces an existing signature. generatePublisherKeyPair, signPluginPackage, verifyPluginPackageSignature, and assertSignedByEnrolledKey are the programmatic equivalents, exported from @cirrulet/sdk/publishing — a separate entry point because they use node:crypto and the file system, which a JavaScriptCore plugin bundle cannot resolve.

The envelope has to reach the committed tree, not just build/: the marketplace clones the submitted revision, rebuilds it in its own sandbox, and packs its own output, which must already contain the envelope. That works because the payload covers the canonical distribution manifest and the content inventory of every packaged file except the envelope — the envelope never signs itself — and because the bundle is byte-reproducible, so the sandbox re-derives the same digests. cirrulet-release-plugin places it there and cirrulet-build-plugin copies it back down, refusing a build whose committed envelope was signed against different output.

Because the payload covers the canonical manifest, sign:plugin rewrites package.json in canonical JSON before signing; otherwise the bytes on disk would hash differently from the bytes the marketplace and host hash. The signer enforces the same tree rules as SecurePackageTree and the marketplace packager — no symlinks, no hard links, no special files, no executables, NFC paths that stay unique under case folding, at most 10,000 files, 64 levels of nesting, 64 MiB per file, 1 MiB for package.json and the signature, 512 MiB in total, and a required dist/plugin.js — so a signature it produces cannot be rejected downstream for tree reasons.

22.2 Extension surfaces that are wired end to end

  • Actions, invoked from the Plugins window and from MCP, rendered through the host UI schema.

  • Island contributions, including live activities with quotas, TTLs, rate limits, and revocation on stop.

  • Providers, queried through extension.queryProvider. The Plugins window has a search field per declared provider. The host gates the query on background.provider covering the provider ID, refuses undeclared providers, applies minimumQueryLength, and truncates to maximumResults.

  • Events, delivered through extension.deliverEvent and gated on events.subscribe covering the event type. The host emits exactly one event today, edged.session.started.v1, delivered once per worker session after a successful launch. A manifest may only subscribe to event types the host actually emits.

  • MCP export, described below.

22.3 MCP export

Export is off unless the user turns it on, and turning it on means granting the mcp.export capability for that plugin in the Plugins window.

  • The catalog is derived from the signed manifest, never from anything the plugin says at runtime: an action is exported as a tool of the same name only when its ID appears in both actionIDs and toolIDs of the declared mcp.export scope.

  • Tool calls run the plugin's own action handler through the same supervisor as any other invocation, and the result is returned as MCP text content plus structuredContent.

  • While the grant is live, the bridge is published on a per-plugin Unix domain socket under Application Support/Cirrulet/Plugins/MCP/<plugin-id>.sock, created 0600 inside a 0700 directory. Revoking the grant or stopping the plugin unlinks the socket.

  • Every request is re-checked against the grant, so revoking mid-session stops answers on an already-connected client.

  • Resource and prompt export are not derivable from the current manifest schema, so the host exports neither. MCPBridge still implements both for a host that supplies its own catalog.

22.4 Limits that are actually enforced

  • 256 MiB per worker, enforced twice: the helper exits with status 66 when its own phys_footprint crosses the limit, and the supervisor terminates the child if the helper does not. The Plugins window shows why a plugin stopped.

  • Protocol frame, queue, and in-flight request bounds; handshake, action, and shutdown deadlines; SIGTERM then SIGKILL escalation.

  • Island quotas, TTLs, and update rate limits.

  • A 4 MiB ceiling on the raw HTML a plugin hands to a webview island view, applied identically by the island bridge and by the presentation coordinator. The document reaches the host as a JSON-escaped string inside a single protocol frame, and escaping can expand content up to 6x, so a pathological document under the cap can still encode past the 8 MiB frame bound. The SDK preflights the encoded request size and rejects oversized updates with a clean error before sending; a plugin that bypasses the SDK and emits an oversized frame gets transport teardown (messageTooLarge), which affects only that plugin's own connection.

  • Provider result count and minimum query length.

  • Local storage bounds: keys match ^[a-z0-9][a-z0-9.-]{0,63}$, at most 1,024 keys per plugin, one value's canonical JSON encoding at most 1 MiB, and the whole store within the budget the manifest declared as scope.maximumBytes — itself capped at 10 MiB by the host.

Not enforced yet: CPU-time ceilings, open-handle ceilings, per-plugin disk quotas beyond the storage.local budget, and syscall filtering beyond the sandbox profile.

22.5 Isolation as shipped

  • Plugin code runs only in the EdgedPluginRuntime helper process, never in the host.

  • A production launch requires a code-signed helper carrying com.apple.security.app-sandbox and none of the network, file-access, Apple-events, camera, microphone, location, address-book, or calendar entitlements, with a matching team identifier and expected hash. Anything else refuses to launch.

  • Webviews are optional, get a non-persistent data store, no popups, no JavaScript dialogs, no inspector, an injected CSP, and camera and microphone requests denied without prompting. They reach the plugin only through a schema-validated, size-capped bridge, and the actions they may invoke are limited to the ones the ui.webview grant names.

  • Packages are signature- and trust-verified before install, and unsigned packages require explicit developer mode.

22.6 Trust provisioning: catalog import and publisher onboarding

The Plugins window's Publisher Trust sheet is the shipped path from a fresh install to installing a signed plugin. No hand-crafted trust files are required; the flow is:

  1. Onboard a catalog trust anchor. The user selects a trust anchor file ({"schemaVersion": 1, "keyID", "algorithm": "ed25519", "publicKey"}, base64 raw Ed25519 key). The app shows the key ID and fingerprint — the lowercase hex SHA-256 of the raw public key bytes — and the key is trusted only after the user confirms that fingerprint against one the catalog operator published out of band. Anchors live in Application Support/Cirrulet/Plugins/Catalog/catalog-trust-anchors.json.

  2. Import a signed catalog. The user selects a signed catalog file. The host verifies the Ed25519 signature against the onboarded anchors and the anti-rollback checkpoint before showing a confirmation naming the catalog, version, signing key, and contents. On confirmation the raw signed bytes are stored atomically at Application Support/Cirrulet/Plugins/Catalog/catalog.json and re-verified from scratch on every launch; a tampered or unsigned file is rejected with the concrete reason (signature failure, untrusted key, rollback, equivocation). Removing an anchor immediately invalidates a stored catalog signed with it.

  3. Trust publishers. The verified catalog's embedded publisher keys are listed as candidates, but none are trusted implicitly — not even from a verified catalog. Each key becomes install-trusted only after the user confirms its fingerprint in an explicit dialog; the confirmed fingerprint is re-checked against the key bytes in FileTrustedPublisherStore, so a candidate that changed after display fails closed. A key the catalog itself revokes cannot be trusted at all. Standalone publisher files ({"schemaVersion": 1, "publisher", "keys": [...]}) are supported for publishers distributing without a catalog.

  4. Revoke. Trusted publishers are listed with their fingerprints and can be revoked; the distribution service consults the same store on every install, so revocation takes effect immediately for new installs and updates without touching already-installed plugins. Catalog trust anchors can be removed the same way.

Trust state persists in trusted-publishers.json (same document shape the earlier hand-provisioned file used, so existing installs keep their state) with the plugin host's durable-write discipline: owner-only permissions, symlink and file-type checks on read, and fsync-and-rename atomic writes, so a crash leaves either the old state or the new one.

22.7 Marketplace client, trust, and licensing

The Plugins window has an Installed tab for local management and a Discover tab for marketplace browse, purchase, install, and update. The same Discover surface remains available from the island dashboard's Plugins tab. Browse results are presentation data only; a package is installable only after the native client verifies its exact version and target path through TUF.

  • A Developer ID package carries the expected root version and canonical signed-root SHA-256 in its signed Info.plist. The client fetches that exact root, verifies its self-threshold against the baked digest, walks sequential dual-signed rotations (maximum 32), and persists the latest root and role checkpoints with owner-only atomic storage. An expired baked root may recover through a valid fresh rotation; rollback, equivocation, skipped versions, malformed extra signatures, and over-limit walks fail closed.

  • Timestamp, snapshot, targets, and artifact bindings are checked through the complete TUF chain. Approved/revoked state, internal marketplace UUID, public plugin ID, publisher key, compatibility, package hashes, version, target path, and licenseRequired are all taken from authenticated target metadata. Listing pricing must agree with the signed licenseRequired bit before any package is imported.

  • Publisher-key trust from TUF is scoped to that one import operation and is never written to the manual publisher trust store. Installed marketplace provenance retains all four role checkpoints and the trusted-root digest.

  • Native sign-in uses an ASWebAuthenticationSession authorization-code flow with PKCE S256, exact callback/state validation, POST-only exchange, bounded token expiry, and a non-synchronizing device-local Keychain item.

  • License key sets are authenticated by the trusted TUF targets role. Capabilities are checked for signature, key state and expiry, entitlement version, plugin and installation binding, active expiry, and bounded offline grace. A Keychain-protected digest seal binds each installed TUF receipt; editing registry.json to change its source or paid flag therefore blocks launch instead of bypassing licensing.

  • User-selected trust files are read through a bounded descriptor with no-follow, regular-file, ownership, and no-group-write checks. Durable marketplace roots, checkpoints, credentials, and licenses use stricter owner-only storage.

The focused contract suites are MarketplaceTufClientTests, TufRepositoryVerifierTests, MarketplaceDashboardServiceTests, MarketplaceKeychainPersistenceTests, MarketplaceLicenseTrustTests, MarketplaceLicenseVerifierTests, and MarketplacePublisherTrustTests. Developer ID packaging additionally runs Scripts/verify-marketplace-tuf-anchor.py and its adversarial test suite.

22.8 Known gaps

  • The MCP socket is a bare newline-delimited JSON-RPC endpoint. There is no bundled stdio shim, so a client that only speaks stdio needs its own adapter.

  • MCP consumption reaches local stdio servers only — the ones the user configured in Settings. There is no remote HTTP transport, and no resource reading: mcp.client and mcp.userSelected.client grant tool listing and tool calling. host.mcp.call remains a withdrawn spelling and is refused.

  • Preference contributions are declared in manifests but the host does not read or present them.

  • Only one host event type exists.