plugins: Plugin Alpha (#35470)

* i18n(plugins): drop retired plugin strings from the Portuguese catalog

* i18n: keep the empty project group in the Spanish and French catalogs

* i18n: retire translated entries for plugin strings this change rewords or removes

* plugins: syntax contributions

A plugin can contribute up to 16 syntaxes: a TextMate grammar, optional
language configuration, and the file extensions and names it claims.
Main validates every grammar when the plugin is approved (shape, scope,
size, nesting) and lists only the file associations. A language's
grammars cross IPC only when a matching file opens, and includes must
resolve inside the same plugin. Catalog releases can declare syntax.

* plugins: highlight files with plugin syntaxes

Files Orca shows as plain text use a matching plugin syntax. Its grammar
compiles once in a worker that stays warm between edits and runs under
core's per-line limits; a worker that stops reporting progress or crashes
is terminated, the language stays plain, and the plugin card says so.
Tokens reach Monaco as semantic tokens, so Monaco asks only for models
shown in an editor, keeps one request per model, and keeps painting the
previous colors, shifted by edits, until the new pass lands. Plugin
languages borrow ids from a pool of fixed Monaco languages, counted by
the editors using them; a language no editor uses moves its models back
to plain text, and the least recently used idle slots are reused first.

* plugins: Gleam syntax example pack

An official-style data plugin with the upstream Gleam TextMate grammar
(Apache-2.0, provenance and license alongside) and language
configuration. A test installs it through the production inspection and
loads it through the syntax registry.

* plugins: list themes, icon themes and syntaxes in the marketplace preview

The preview's Includes line named only language packs, commands,
shortcuts, VM recipes, panels and events, so a theme or syntax pack read
as "Manifest metadata only".

* fix(plugins): a syntax failure belongs to one revision, not the syntax id

A grammar that failed in revision A kept the plugin card flagged after revision B
replaced it. Failures are now keyed by id@revision, matching the runtime slots,
and dropped when the installed list no longer contains that revision.

* chore(plugins): format the Gleam example grammar

* plugins: import the Monaco editor API, not the full bundle, from the syntax runtime

* plugins: type the syntax engine against the Monaco API it uses

* plugins: suggest a catalog plugin for files Orca can't open

When a file shows as plain text or as a binary Orca can't display, and a
cached catalog lists a release whose fileTypes include its extension, the
editor offers it: View plugin opens the marketplace preview (never an
install), Not now hides it for the session, and Don't suggest remembers
the extension. Suggestions come only from cached, verified catalogs, skip
kill-listed releases and anything an installed (even disabled) plugin
already handles, and can be turned off in Plugins settings. A release's
fileTypes must match its manifest's syntax extensions.

* fix(plugins): scope the file-type suggestion's Not now to the file type

The editor stays mounted when its file changes, so a single hidden flag
suppressed the suggestion for the next file type too. Track which file
type was hidden instead.

* plugins: type the plugin-owned settings normalizer inputs

* plugins: update the preview-request callback ref after render, not during it

* test(plugins): smoke specs for themes, syntaxes and file-type suggestions

Three end-to-end specs join the demo and marketplace journeys:
- an app theme and icon theme apply from Settings and fall back when the
  plugin is disabled;
- a plugin syntax highlights an already-open file, re-highlights it after a
  grammar update, and lets go when disabled;
- a v2 catalog release with fileTypes is suggested for an unknown file,
  View plugin opens its preview without installing, and Don't suggest is
  remembered.
The marketplace spec's Git fixture helpers move to a shared helper.

* test(plugins): say why the file-type suggestion e2e needs a longer timeout

* test(editor): install Monaco's cancellation guard in the owner view-state test

The test mocks monaco-setup, which skips the guard production installs, so
a disposed editor's pending word highlight surfaced as two unhandled
"Canceled" rejections once plugin syntaxes added a render. The run
passed but reported errors.

* plugins: plugin app themes recolor the code editor

When a plugin app theme is active, every Monaco surface (editor, diffs,
notebook cells, code excerpts, automation prompt) uses a theme derived
from the colors the document is actually painted with: editor background,
text, line numbers, cursor, selection, current line and widgets. Syntax
colors stay the built-in light or dark ones; if text would fall below
4.5:1 or a syntax color below 3:1 on the plugin's editor background, the
editor keeps its built-in theme. Colors are resolved to hex through the
browser's own parser, so color-mix() and other CSS color forms work. The
theme is redefined when the plugin updates or the mode changes, and the
editor returns to the built-in theme when the plugin theme is deselected,
disabled or removed.

* refactor(plugins): one module per contribution point and a single points table

The manifest's contributes object, the execution-profile derivation and the
catalog type list now read one PLUGIN_CONTRIBUTION_POINTS table instead of
three hand-kept lists.

* refactor(plugins): express id and path rules as patterns

Plugin ids and relative paths now validate with a regex first, so a generated
JSON Schema can carry them as `pattern`. The predicate only adds what a
pattern cannot (reserved names, Windows device names), and a bad value
reports one issue instead of two.

* plugins: document every manifest and catalog field in the schema

Each field now carries a description, examples and the Orca release that
first reads it, and each contribution item has a named fragment id. Manifest
v2 accepts an optional $schema key for editor completion, and the catalog
gains an authoring schema built from the same field definitions.

* plugins: generate published JSON Schemas and author references from zod

pnpm generate:plugin-schemas writes schemas/plugins/orca-plugin.schema.json,
schemas/plugins/orca-marketplace.v2.schema.json and the matching reference
pages under docs/reference from the runtime schemas. A test fails when the
committed files drift, when a field lacks a description or since-version,
when a documented example does not validate, and when the schema and the
runtime disagree on bundled manifests or known-bad inputs.

* plugins: report every manifest and catalog mistake with its path

Manifest and catalog validation now lists up to eight distinct problems as
`contributes.themes[0].label: must not be empty` instead of zod's first raw
message, and rewrites parser wording (required, unknown keys, allowed
values, limits) into what the author should change.

* plugins: say which Orca a newer plugin or catalog release needs

A manifest the strict schema rejects is pre-read for engines.orca and
manifestVersion first, so a plugin built for a newer Orca reports
"requires Orca >=X (this is Y)" instead of unknown-key noise. A catalog
release whose minimumOrca is above this build stays listed but is refused
before anything is downloaded.

* plugins: version the published schemas and pin v2 fields to a real release

Schemas move under schemas/plugins/v2/ so a later breaking format can
publish beside them without changing what a pinned URL means, and the
example plugin authors copy carries its own copy for offline completion.
Fields new with manifest v2 cite one constant, PLUGIN_MANIFEST_V2_SINCE
(1.4.232, the release after the latest tag), and a test fails once a tag at
or above it ships without manifest v2. The reference documents format 1 as
legacy.

* plugins: open review when enabling a plugin that was never approved

Dismissing the review dialog records keep-disabled without consent. Turning
the plugin's switch back on then only cleared the disable, so it returned
to "Needs review" with no dialog. Enabling now opens the review whenever
the result is still pending.

* plugins: release details for the plugin detail page

Catalog releases may carry a license, repository and homepage (https only),
an updatedAt date and a short plain-text changelog; an invalid value is
dropped so it never hides the plugin. Manifests may declare license and
homepage. Marketplace listings flatten name, version and those details, and
installed plugins report license, homepage, repository and the version a
rollback would restore.

The JSON lint-staged step no longer fails a commit whose only staged JSON
files are generated schemas the formatter ignores.

* plugins: fold in the schema review

- Parity runs both ways: documented examples also pass their zod field,
  bundled manifests pass both the schema and the runtime, and manifest and
  catalog negatives (traversal, oversize, wrong types, duplicates) fail in
  both. A table lists every rule only the runtime can enforce, and each is
  named in the description of the field it constrains.
- The published files carry the format in their names
  (orca-plugin.v2.schema.json, orca-marketplace.v2.schema.json); $id stays
  on main until the v2 release, and a test demands the release tag once it
  exists.
- The v1 since-value is 1.4.160, the first release that read plugins, and a
  test pins it to the tags. engines.orca documents that format 2 needs at
  least the release that ships it.
- v2 catalog sources strip unknown keys like the rest of the entry, and the
  v2 runtime schema is derived from the one authoring definition, whose
  plugins field documents the duplicate-id rule. Contribution types and
  categories declare uniqueItems; fileTypes items come from the file-type
  schema itself.
- Issue messages no longer depend on zod's English: a missing field is told
  from the input, and every pattern carries its own message. Unknown-key
  lists show five keys plus a count, and each line is capped at 200
  characters.
- A prerelease host (1.5.0-rc.1) now ranks below its release, as in semver.

* plugins: read the rollback version on demand, not while listing

The plugin list is rebuilt at startup and on every plugin change, and each
installed plugin cost a directory read to find the version a rollback would
restore. That moves to plugins:getRollbackVersion, which the detail page
calls when it shows the action, and a test pins the list build to zero
filesystem calls.

* plugins: v1.4.232 shipped without manifest v2, so v2 fields cite 1.4.233

The release-tag check caught it: v1.4.232 was cut from main before this
stack landed.

* plugins: list installed plugins while the plugin system is off

With the system off the service discovers nothing, so plugins:list returned
an empty list and the settings page could not show what is installed. The
client list now reads the install tree on demand in that state and projects
every plugin as disabled; nothing is activated or started, and startup with
the system off still does no plugin I/O. Older clients that hid the list
while off are unaffected.

Also for the plugin page: plugins:getInstalledManifest returns one installed
plugin's parsed manifest on demand, marketplace installs report the catalog
ref beside its URL, and the listing type declares the release record it
already carried.

* plugins: final schema decisions for catalogs and versioning

Catalog
- The catalog is orca-catalog.json with a required catalogVersion: 2; a
  client that sees a higher version refuses with "update Orca to browse
  this catalog". New clients read it first and fall back to the frozen
  orca-marketplace.json, which export-legacy-plugin-catalog.mjs generates.
- Parsing is tolerant at every level: unknown keys are stripped, unreadable
  entries and releases are skipped, over-limit lists are trimmed, a repeated
  id keeps the first copy, invalid display-only fields are dropped.
  pnpm validate:plugin reports each of those as an error for authors and CI.
- Entries list releases newest first; Orca offers the first whose
  minimumOrca it meets, and shows "Needs Orca X" only when none fits.

Versioning
- engines.orca is the only gate and must cover the newest since-version of
  every field and enum value used (capabilities, events and built-in actions
  now carry per-value since tables). The check is part of the v2 schema, so
  validation, catalog checks and installs all enforce it.
- pluginApi is optional in format 2 (defaults to 1) and refused on data and
  instructional plugins. Format 2 rejects unknown keys at every level,
  including engines, author and every contribution item.
- contributes.agents is reserved for coding-agent definitions and must be
  empty in format 2 until their format exists.
- Plugins are gated against the newer of the app version and the newest
  since this build defines, so development and local builds, whose package
  version only moves at release, read the fields they introduce.

The policy is written into the generated references and pinned by tests.

* plugins: one-click install for content-only plugins, and the verified profile on the list

installMarketplacePlugin takes an optional enableIfData. Main then turns the
plugin on in the same step only when the installed, verified manifest is
execution: data, approving exactly the fingerprint it just installed through
the normal consent path; anything else comes back needsReview: true, whatever
the renderer sends. Older hosts ignore the field and leave the plugin pending.

Installed plugins now report their verified execution profile, so the plugin
page never infers "content only" from the absence of a worker.

* plugins: images and contribution summaries for plugin pages

Manifest format 2 gains cover and screenshots (alt text required), and its
icon must be a square PNG or WebP. Images are checked at install from their
headers only: static PNG, JPEG or WebP, per-kind pixel and byte limits, at
most 4096 pixels a side and 8 MB in total, so nothing is ever decoded to
decide trust.

Catalog releases may carry images stored in the catalog repository and
pinned by SHA-256, a summary of what they add, and the capabilities they ask
for, so a listing can show the plugin completely before anything is
downloaded. All three are dropped when unreadable; the summary and
capabilities must match the manifest exactly at install. pnpm validate:plugin
checks each image's hash, size and format against the repository files.

* plugins: serve plugin and catalog images from verified bytes

Images reach the renderer only as orca-plugin-image URLs, which main serves
with nosniff and the type it read from the header:
- catalog images are read from the catalog repository at the fetched
  commit, kept only when their hash, size and format match, and cached by
  SHA-256; a listing references only those;
- preview images are cached by hash before the staging tree is deleted;
- installed plugins' images are named by plugin, install hash and slot and
  re-checked on every read, so listing plugins still reads no files.

Listings, previews and installed plugins carry an images field the plugin
page can render without fetching anything else.

* plugins: catalog theme palettes, and one-click never approves a dev override

Catalog releases may list up to four theme palettes (hex colors only) so a
listing can draw theme cards before install; they are display-only and
dropped when invalid. One-click enable now also refuses when a development
override shadows the installed plugin, since those are not the bytes the
user just chose.

* plugins: one-click approves only the fingerprint the user saw

installMarketplacePlugin takes an optional expectedFingerprint, the
preview's consentFingerprint. With enableIfData, main now approves only
when the installed fingerprint equals it, so bytes that changed between
preview and install stay pending for review instead of being enabled. The
release commit is already pinned by resolvedCommit. Older hosts ignore the
field.

* plugins: gate manifest v2 on 1.4.242 and publish schemas under docs/schemas

v1.4.240 shipped without manifest v2, so the first release that reads it is 1.4.242.
The root-directory guard refuses a new top-level schemas/ folder.

* plugins: read unvalidated schema input through a typed object guard and break the image validation import cycle

* plugins: one place decides an installed plugin's status word

Pull the status ranking, label and enabled/needs-review checks out of the settings row so the
coming list rows and detail page read the same words. No visual change.

* plugins: one list entry shape for installed and catalog plugins

PluginListEntry joins an installed plugin to its catalog listing (or stands for a listing alone),
and matchesPluginSearch is the one search both lists use. No visual change.

* plugins: usePluginCatalog owns catalog loading, refresh and preview

Sources, listings, refresh, the install/update preview and the outside preview request move out
of PluginMarketplaceBrowser into one hook the coming list and detail page share. The browser
keeps only its view state. No visual change.

* plugins: one plugin list with fixed slots, a plugin page, and inline states

- Installed plugins first, then one group per catalog (verified/unverified · owner or URL), capped
  at 6 rows with Show N more; every plugin appears once
- Rows: the name opens the plugin page and covers the row; status, action, switch and ⋯ are
  fixed-width sibling slots in Tab order. Update {version} is the action when a catalog has a newer
  release; Stopped offers Restart with a red mark on the switch; Blocked offers Remove…
- Plugin page in the same pane: what it adds (commands with platform shortcut chips, panels,
  recipes, contribution summary), what it can access, logs, and info. Back link, Alt+← or ⌘[
  return focus to the row
- States inside the list: skeletons, a catalog that fails only affects its own group, no results
  with Clear search, nothing installed. Plugins off keeps the installed list visible, dimmed
- Add plugin ▾ (folder, Git URL) replaces Install plugin; Plugin system stays at the top
- Catalog listings now expose their release (name, version, minimum Orca, types) to the renderer

* plugins: filter by category and catalog, one review for Update all, Needs newer Orca

- Filter popover (category, catalog) next to search; search and filters show every match
- Update all opens one review: updates that ask for new access are expanded with what's new, the
  rest are one line, any can be unticked; installs run one at a time
- A catalog release that needs a newer Orca says so and disables Install, in the row and the page
- Installing from a plugin page shows a review with only access and source, since the page just
  showed what it adds

* plugins: plugin page uses the manifest name; Off status word; official isn't a category

* plugins: catalog sources live in the Add plugin menu

* plugins: search takes its own row when the list is narrow

* plugins: release notes, license and links on the plugin page; roll back names its version

* plugins: fix list review findings

Plugin page owns the pane (back link and plugin header only); Adds to Orca names
every contribution; access never reads as 'nothing' when the check failed; filter
kinds have readable names; Update all is reachable, fetches once per open and
routes updates needing review; installed plugins join their own catalog;
attention-first order; paused list while plugins are off.

* plugins: readable category names on the plugin page

* plugins: Update all keeps its set; reviews queue; updates only from the plugin's own catalog

* plugins: name what an off or unreviewed plugin adds from its manifest; join catalogs by url and ref

* plugins: Needs Orca checks use the version plugins are gated against

Main gates plugins on pluginHostVersion, the newer of the app version and the
newest since-version the build reads. The plugin page compared releases to the
raw app version, so a development or local build showed Needs Orca for plugins
it would install.

* test(plugins): e2e journeys follow the redesigned plugin list

The page offers "Turn on plugins" while the system is off, catalog rows share
the installed rows' data-plugin-key, the review action is "Review", and an
enabled plugin reads "On".

* plugins: describe access from the verified execution profile, never from "no worker"

A commands-and-shortcuts plugin's page said "Content only. It has no code that runs."
because the page treated "no worker" as "no code". A worker plugin without capabilities
read "runs in a sandbox". The page now classifies from the catalog release's execution
profile (install refuses bytes whose manifest differs), the fetched manifest, or the
installed manifest's profile from the plugin list's `execution` field. Unknown stays
"Reading…" or "Couldn't read", never content only. The review dialog uses the same
profile, and an older host that omits it is reviewed as instructional.

* fix(plugins): read revision-scoped syntax failures on detail pages

* plugins: use the Blocks glyph for icon themes in the plugin page

* plugins: update latest-value refs after render in the update dialog and page navigation

* i18n: retire translated entries for plugin strings this change rewords or removes

* plugins: one action to install, inline consent, and a picture-first catalog

Settings > Plugins no longer opens a confirm dialog and then a review dialog.
- Content-only catalog plugins install from their card or page in one click; main
  turns them on only when the verified manifest is data. Undo stays in place for 10s.
- Plugins that run code or instructions show "If you install <Name>, it can:" on the
  host security surface above one Install & enable, which stays off until the access
  rows are read, and approves exactly the fingerprint shown. Review of an installed
  plugin happens on its page too (Enable / Keep off); Remove stays in its menu.
- The catalog is a grid of cards drawn from what each plugin is (its theme colours once
  installed, a code sample, keycaps), with the name as a stretched link and one action.
- The page pins the listing it opened, says "Newer version available" instead of
  swapping rows, and previews are prefetched only on hover intent, capped per visit.
- The plugin system switch, file suggestions, catalog sources and development folders
  move into a header menu; amber text uses a contrast-safe token.

* plugins: give the reworded no-access line its own key so English shows the new copy

* plugins: pin installs to the release shown, warn by what a plugin carries, disclose replaced shortcuts

- The page pins the listing it opened; a preview of any other release (commit, catalog
  commit or execution profile) leaves Install & enable off and shows "Newer version
  available". One-click installs from a card refuse the same mismatch.
- Warnings are the union of the verified profile and the contributions: a panel that
  also adds commands gets the instructions warning, never "bounded" or "own files only";
  unknown contributions over-warn. Access rows follow the same rule.
- Shortcuts that replace one of yours are named in the consent before install, from the
  manifest or the catalog summary; the shortcut list itself stays in Adds to Orca.
- Pages paint from the catalog summaries, capabilities and theme palettes, and show the
  cover (or the card's art) as a hero, with screenshots in a strip and lightbox.
- Installing fills a reserved status line and keeps its action row (Undo, Use now), so
  the page doesn't move; the settings scroller keeps a stable gutter.
- Row art names the plugin type instead of initials, rows drop the "Needs review" word,
  the content hash moves into the menu, and card art is tinted and coloured.

* plugins: catalog covers on cards

* plugins: Review reopens its page after Back; hero art and stable status line follow what an installed plugin carries

* test(plugins): e2e journeys review and install on the plugin page, with no dialogs

* plugins: install exactly the release shown, classify from the full manifest, polish the page

Code review:
- A page or card arms Install only when the preview matches everything the listing showed
  (catalog commit, release commit, content hash, execution); any change, including a
  catalog-only refresh, reads "Newer version available". One-click installs send the
  preview's fingerprint as expectedFingerprint, so main turns on only those bytes.
- Install stays off while a catalog refresh is in flight.
- Trust uses the full verified preview manifest when present; a capped catalog summary
  counts as unknown and gets the stronger warning.
- The installed-manifest read is keyed by plugin and installed revision, so a same-key
  update never pairs old contents with the new fingerprint.
- A failed Undo shows its error on the card or page and keeps Undo to retry.
- The security-surface test now covers the inline consent instead of the removed dialog.

Visual:
- Lightbox: close button clear of the image, "1 of N" with previous/next and arrow keys,
  content scrolls on short windows, focus returns to the opening thumbnail.
- Keycap art shows the plugin's own first shortcut, larger, with the platform's keys.
- The hero fills the content column; a screenshot identical to the cover isn't repeated.
- "Change in Shortcuts" is a link; the chip says "Adds commands" (or "Runs a worker",
  "Adds a panel", "Content only"); the disabled button says "Reading what it can access…".
- A content-only plugin's action row keeps fixed slots, so its note doesn't move on install.

* plugins: keep the decision above the fold, route suggestions to the page, final review fixes

- The page shows the decision (Install, the consent, Enable) before the picture, and the
  picture is capped and cropped from its centre, so the button is in view at 1280x800 and
  900x700. Nothing grows after first paint.
- The consent gives no access verdict while the plugin is still being read.
- A file-type suggestion opens the plugin's page instead of the old install dialog; that
  dialog now serves updates only.
- A theme plugin reserves its "Use now" line from first paint; the action slot and its
  button share one width.
- The file-suggestion toggle reports a failed save; a failed Undo is reported once, on
  the card or page that offered it.
- Cards show a plain trust chip ("Content only", "Adds commands"); the local and bundled
  group reads "Bundled & local".
- Install error copy moves into the protected plugin namespace, and the new consent
  components join the language-pack security pins.
- Dead code goes: the keybinding consent preview (its conflict helper stays), its strings,
  trustRunsNothing and the access-only preview plumbing.

* test(plugins): the file-type suggestion opens the plugin page, not a dialog

* plugins: the install decision sits top-right beside the name; no plugin on/off switch in the UI

- Plugin page: Install (content only), Install & enable (disabled until its access is read),
  Enable / Keep off (installed, waiting for review) and the installed switch share one slot
  top-right of the title. The consent sits directly under the header with no button of its
  own; the "Can't reach anything outside its own files" note sits on the line under the
  publisher, which every state reserves, so nothing moves between states.
- Removed "Turn off plugins" from the ⋯ menu and the "Turn on plugins" button from the off
  state. The setting and all gating stay: off now comes only from policy, and the page says
  so without a switch.
- e2e journeys turn plugins on through the setting and use the page's one button.

* plugins: restore Turn on plugins, drop Undo, add Remove to installed card menu

Plugins stay opt-in, so the off state keeps its Turn on button; only the
menu's Turn off item stays removed. Installed catalog cards get a sibling
more-actions menu with Remove (immediate, inline error) and Turn off/on,
inside a fixed-width action slot so the footer doesn't shift.

* test(plugins): installing a theme plugin never changes the active theme

* fix(plugins): confirm card removal before deleting data

* fix(plugins): pause review queue when leaving plugin page

* fix(plugins): disclose execution changes before updating

* refactor(plugins): share single and bulk update review

* refactor(plugins): share installed card and row actions

* test(plugins): cover confirmed removal and shared update review

* feat(plugins): offer icon and terminal themes after install

* test(plugins): capture settled review dialog screenshots

* fix(plugins): make installed contributions discoverable and actionable

* fix(plugins): label card-install toast Try it and mark terminal-theme Use now buttons

The toast action opens the plugin page rather than applying a theme, so it
always says Try it. Terminal themes often share their app themes' names, so
their Use now buttons carry a terminal icon. Update the update-review e2e for
the Use now accessible names.

* fix(plugins): apply a theme plugin's themes when the user installs or enables it

Installing (card or page) or enabling a plugin that adds themes now applies its
app theme (matching the current light/dark mode), icon theme and terminal theme
at once, with a 'Now using' toast whose Switch back restores the previous
selections. Updates, startup discovery and catalog refreshes never apply.

Co-Authored-By: Claude <noreply@anthropic.com>

* plugins: call toast actions through a typed button in tests; key card art by stable names

* i18n(plugins): drop retired plugin strings from the Portuguese catalog

* test(plugins): say why the update review e2e needs a longer timeout

* i18n: retire translated entries for plugin strings this change rewords or removes

* refactor(plugins): organize main plugin files by responsibility

* refactor(plugins): group shared plugin schemas and contracts

* refactor(plugins): organize renderer plugin surfaces

* plugins: keep the plugin service under the line limit after the folder moves

* refactor(plugins): move the JSON object guard beside the manifest schemas

* test(plugins): move the flaky-test baseline entries with the worker tests they count

* test(plugins): point the panel lifetime test at the reorganized plugin folders

* plugins: reserve skills, mcpServers, taskSources, connections and settings contribution keys

Format 2 accepts each key only as an empty list, so the points can ship later as additive
minors without a new manifest format.

* plugins: freeze host API v0 with a generated reference and refuse unknown pluginApi majors

The method table now carries a summary and an optional deprecatedSince, and renders
docs/reference/plugin-host-api.md with the stability and deprecation policy. A manifest
naming a newer host API major fails with "update Orca" instead of "must be 1".

* plugins: name the bad release in catalog validation and re-ask on any execution profile change

validate:plugin now reports each unreadable release with its reason (for example a tag
instead of a full commit SHA) rather than a count. The consent fingerprint includes a
declared non-data execution profile, so a data pack that becomes instructional no longer
keeps its one-click approval. Tests pin that catalogs drop non-SHA releases, that a
repository cannot load or approve a plugin it carries, and that added capabilities or a
changed profile always need re-approval.

* plugins: add the author guide, theme token list, v2 examples and a printed catalog release entry

docs/reference/plugin-authoring.md walks write, load a development folder, validate and
publish. The theme token table is generated from the public token set and its fallbacks.
hello-orca and hostile-panel move to manifest format 2. validate:plugin on a plugin
repository root materializes HEAD the way install does and prints the release entry to
paste into orca-catalog.json.

* plugins: name the host API type renderer and reserved contribution fields for their role

* plugins: gate the bundled example plugins on 1.4.240

* plugins: reserve orca, orca-*, official, anthropic, openai and github publishers

* plugins: rename storage/secrets to storage:own/secrets:own and gate events by domain capability

events:subscribe and the events.subscribe host method are gone; workspace events need
workspace:read and agent status needs the new agents:read. Format 1 manifests keep their old
names, rewritten on parse.

* plugins: move samples and fixtures off the now-reserved orca-samples publisher

* plugins: name the public surface workspace, not worktree

Events become workspace.created/removed with workspaceId and a kind field, agent status
carries workspaceId, and command context/keybinding when use workspace. Format 1 names are
rewritten on parse, v1 workers still receive worktree.* handlers, and clients treat any
non-global context from older hosts as workspace-scoped.

* plugins: rename the instructional execution profile to declarative

* plugins: move agents into the reserved contribution keys

Format 2 keeps contributes.agents empty like every reserved point; format 1 entries, which
Orca never read, are dropped on parse so they stay inert and bind no content.

* plugins: skip unknown theme tokens and icon slots at runtime, refuse them in validate:plugin

A token or slot added in a later release no longer fails the whole theme on older Orca.
validate:plugin now parses each theme, terminal theme and icon theme strictly. App themes
accept an optional schemaVersion 1.

* plugins: inject panel design tokens as --orca-* custom properties

* plugins: accept a closed, documented list of panel icons

Format 1 panels keep any icon name: a known one in either Lucide spelling is canonicalized,
an unknown one is dropped and shows plug, as before.

* plugins: make catalog release types optional and derive them

Every required release field is permanent for old clients, and types repeat what contributes
and execution already say. Printed release entries no longer include it.

* plugins: strip unknown kill-list and consent fields instead of refusing them

A strict kill list froze old clients on their cached list as soon as a later list added a
field, silently stopping revocation; a strict consent request would refuse a newer client's
optional review field.

* plugins: check only the shape of a panel action in the renderer

The host's capability gate already refuses methods panels may not call. Filtering against the
client's own method list blocked every panel method a newer host added on older clients,
including remote and SSH ones.

* plugins: read a leading >=x.y.z from engines.orca leniently in the compatibility pre-read

* plugins: derive content-bound approval from the contribution policy table

Each point now declares bindsContent, replacing the hand-kept list in the consent
fingerprint, so a new point such as mcpServers cannot swap bytes under an old approval by
being forgotten.

* plugins: name the host API "Host API 1" and add a stability section to the author guide

The guide now lists what is stable and what is experimental: the agent status payload, the
worker process model, language-pack keys, validate:plugin output beyond its exit codes,
settings:own and manifest format 1. Secrets copy now says encrypted file, not OS keychain.

* plugins: make the hello-orca sample speak in plain words

The worker command returns a sentence instead of a JSON object, and the panel labels
terminals by position instead of their opaque ids.

* plugins: give plugins short branch names, not refs/heads/...

* plugins: panels can no longer type into terminals; workers only (D3)

terminal.sendText with Enter runs shell commands locally or over SSH, which
the sandboxed-panel tier's "limited" consent copy did not convey. The
capability copy now says so, and the sample panel no longer types.

* plugins: put full-access worker plugins behind a second setting, off by default (D2)

Trusted workers are unsandboxed Node, so the plugin system switch alone no
longer lets them run. With "Allow plugins that run code with full access"
off, main refuses to install a full-access catalog release, approve or enable
one, and keeps installed workers stopped with an error naming the setting.
The catalog card says "Full access · turned off in settings" and links to
the switch; the worker chip now reads "Full access".

* plugins: bind dev-folder approval to the folder it was given for

A dev plugin's consent fingerprint now includes the folder's canonical path,
so approving one dev folder no longer approves another folder whose
manifest claims the same key and capabilities. The development dialog now
says edits inside an approved folder run without another review.

* plugins: close WebRTC as an outbound channel from panels

CSP has no WebRTC directive, so a panel could reach a STUN/TURN server it
controls despite connect-src 'none'. The panel shell now locks
RTCPeerConnection to undefined before plugin code runs, and the main window
that hosts panels gets the same WebRTC IP-handling policy as doc preview
guests. DNS-prefetch hints remain the residual doc preview already accepts.

* plugins: stop language packs relabelling trust copy outside plugin settings

Protection covered only plugin copy under settings, so a content-only pack
could rename the catalog's Official badge, the file-type suggestion banner,
or swap Allow and Deny on agent approvals. Those components are now
protected whole, the same way the plugin security dialogs are.

* plugins: refuse Git URLs with a dash-led user or host, or a '..' host

ssh reads a leading '-' as an option, and the scp form is not normalized, so
'stablyai/../evil' read as the official owner. The shared URL check used by
the catalog schema, validate:plugin and every install path now refuses these,
and provenance parsing refuses '..' path segments.

* plugins: flag an update whose catalog moved it to another repository

An update whose catalog now points at a different repository showed the new
URL without saying it changed. The update review now names the old and new
repository and leaves that update unticked, so it installs only once the
user opts in.

* plugins: sample worker returns a readable toast; sample panel shows no internal ids

Hello Orca's ping returned raw JSON, which the command toast printed as-is,
and its panel showed the full ref name. It now returns a sentence and shows
the plain branch name.

* plugins: move runtime policy checks and catalog types out of files over the line limit

* plugins: refresh the plugin safety list every 6 hours while plugins are on

Revocations previously reached a session only at launch or when plugins were
turned on, so a long-running Orca kept running a revoked plugin.

* plugins: main sends an update from a moved repository to review

The update dialog left such updates unticked, but main still accepted a
one-click approval for them. Main now compares the installed and new
repository itself and always answers needsReview.

* plugins: label the plugin system Alpha instead of Experimental

Settings badge, nav entry badge, search/description copy in all locales,
and plugin docs' stability sections. Setting keys are unchanged.

* plugins: trim the Plugin settings menu to titles and a short full-access hint

* plugins: reserve statusBarItems and sidebarCards and list planned contribution points

Both keys are rejected when non-empty, like the other reserved points. The
manifest and authoring references gain a Planned contribution points list
built from one table. Also moves the Alpha note on the host API page into its
generator so the schema/doc parity test passes again.

* plugins: an update that asks for nothing new keeps its approval

Before, any catalog update to a declarative, sandboxed-ui or trusted-worker
plugin changed its consent fingerprint (it binds the package bytes), so the
plugin turned off and waited for review after every Update. Only content-only
(data) plugins kept running.

Main now reads the approved version before installing and, when the update
comes from the same repository and adds no capability, no higher execution
profile, and no new command, shortcut, panel, event or VM recipe, records the
new fingerprint so the plugin stays on. Anything that grows access, a moved
repository, a development override or a plugin that was never approved still
waits for review, and the full-access switch still gates trusted workers.
The update preview now carries what grew, so the Update dialog lists new
shortcuts, commands, panels and events as well as capabilities. The consent
copy says updates that don't ask for more access install without asking.

* plugins: match the full-access test to the shorter setting name

* plugins: update catalog plugins automatically when an update asks for nothing new

Adds "Update plugins automatically" to Plugin settings, on by default. The
setting is pluginUpdatePolicy ('auto' | 'manual') so later policies can be
added without a migration.

At startup (after a short delay) and every 12 hours, packaged Orca refreshes
the catalogs and, for each enabled, approved catalog plugin with a newer
release, installs it in place when it comes from the same repository and
grows no access; the approval carries over and the worker reloads. A release
from a non-official catalog installs only after it has been listed for 12
hours, timed from when this Orca first saw it (stored locally because
catalogs carry no listed-at time), so takedowns can land first. An update
that grows access or moved repository is not installed: the current version
keeps running and the plugin shows "Update needs review", whose Review
update button opens the existing update review with what is new. Plugins
updated this way show a quiet "Updated to vX" chip, and each pass shows at
most one toast. Manual Update and Update all are unchanged.

* plugins: clear code-quality findings in the update approval and auto-update code

* plugins: give an app theme that leaves --sidebar unset a visible sidebar edge

Orca draws no line between the left sidebar and the main area; the sidebar's
slightly different surface is the edge. A theme that omitted --sidebar (like
the Dusk sample) had it filled from --background, so the sidebar matched the
main area and the edge vanished. An unset --sidebar is now mixed 8% toward
the theme's foreground. The in-repo sample theme sets --sidebar explicitly
and the theme smoke test checks it reaches the sidebar.

* plugins: treat an unknown update policy as manual, not auto

* plugins: auto-update only pinned releases, and only the release that was held

* plugins: start the auto-update hold clock before the catalog refresh, and check every 6 hours

* plugins: refresh the kill list right before auto-installing, and skip the install if that fails

* plugins: refuse an unpinned full-access install while full access is off

* plugins: an automatic update re-checks inside the install lock that the plugin is still installed

* plugins: compare the full repository path, so a GitLab subgroup move counts as moved

* plugins: time out the kill-list fetch and retry 15 minutes after a failure

* plugins: drop one unreadable kill-list entry instead of the whole list

* plugins: keep language packs off the orca.yaml and notebook trust prompts, certificate warning and site permission prompt

* plugins: compare whole capability declarations on update, and cover swapped command actions and shortcuts

* plugins: log automatic updates so headless and remote hosts record them

* plugins: say that automatic updates can change what a full-access plugin does

* plugins: pin and document the nested-frame WebRTC residual in plugin panels

* plugins: delete the unused PluginReviewBody

* plugins: drop the dead repositoryMoved parameter and the predecessor wrapper

* plugins: declare the plugin policy settings type once

* plugins: show what main found an update changes instead of recomputing it in the renderer

* plugins: move update approval and update policy into the catalog folders

* test(plugins): expect the workspace.removed event name in the lifecycle test

* plugins: rename structural helpers for their role, type the update identity read, and give plugin system settings their own group

* i18n(plugins): drop retired plugin strings from the Portuguese catalog

* plugins: build the hello-orca status line with a template string

* plugins: install catalog plugins from a directory of a shared repository

A catalog source may name a 'path' inside its repository. Orca downloads the
pinned commit as before but materializes only that subtree, so contentHash
covers the plugin's own files and sibling commits never change it. A changed
path counts as a moved repository and needs review. validate:plugin accepts a
subdirectory and prints its source path; the frozen v1 export skips such
entries.

* plugins: offer the full access setting on a full-access plugin's page

The page offered Install & enable even with full access off, then failed with a generic error.
It now shows the card's 'Turn on in settings' action, names the real reason when main refuses,
and drops a stale install error once the full access setting changes.

* plugins: say a content-only plugin couldn't load its files, not that it crashed

Language packs and other data plugins have no code or logs, so a load failure now reads
"Couldn't load its files" on the page and the row instead of "Its code kept crashing".

* plugins: leave Settings before running a built-in action from a plugin page

Built-in actions such as Open Board and Go Back are disabled while Settings covers the app,
so Run on the plugin page always failed. Run now closes Settings, commits that synchronously
so the dispatcher sees the app view, then runs the action.

* plugins: refuse terminal:send on panel plugins and never list it on panel approvals

Panels get no host method that types into a terminal, yet a panel's approval listed
"Type and run commands in your terminals". Format 2 manifests now fail validation when a
profile below trusted-worker asks for terminal:send, and approvals, the access section and
update review drop that line for format 1 panels that still declare it.

* plugins: say "catalog" in user-facing copy, not "marketplace"

The menu said "Catalog sources…" but the dialog it opens said "Marketplace sources" and
asked for orca-marketplace.json. Plugin copy now says catalog in every locale, names
orca-catalog.json (still accepting the older file), and the author guide matches the UI.
Internal identifiers and settings keys are unchanged.

* test(plugins): run a built-in action from its plugin page end to end

* plugins: concise copy pass across plugin UI

Update review shows one line per plugin and only what's new for updates
that need approval, with the rest behind a See all access disclosure.
Consent, Can access, dialogs, settings menu and empty states drop restated
titles and filler; safety facts (new access, full access = files/network)
stay visible where the user decides.

* plugins: keep See all access on Button and Collapsible primitives' own spacing

* plugins: separate light and dark theme slots so packs pair and mix

Plugin app themes now fill a Light theme or Dark theme slot by their base. Orca paints the
slot for its current mode, so under System it swaps live with the OS. Appearance shows both
pickers grouped by plugin; install fills the matching slot(s) and Switch back restores them.
A legacy single selection migrates into its base's slot.

* plugins: installed carousel and card grid, category badges, In use state; load language packs that cover protected copy

* fix(theme): let plugin themes colour menus and dialogs

Menus, popovers, selects, hover cards, sheets and dialogs painted a fixed
glass tint (dialogs rgb 23,23,23 in dark mode), so a plugin theme's
--popover was ignored and a true-black theme still showed grey dialogs.
The tint now lives in --floating-surface / --dialog-surface, which a
plugin theme replaces with its --popover.

* plugins: name the app-theme slot a pack fills, not its terminal colours

* plugins: page the installed row by whole cards; e2e follows the card menu

* plugins: hide the installed row's scrollbar and draw kind art instead of initials

The installed carousel pages with its arrows and snap points; the native
scrollbar under it only added a stray bar. Format 1 catalog listings (the
official catalog until catalog v2 publishes) carry no release types or
images, so their cards fell back to a two-letter monogram. Art now reads
the kind from catalog categories, and the last fallback is a plugin glyph.

* plugins: reserve official publishers to Orca's catalog and pin installs to their catalog

Reserved publishers (stablyai, official, anthropic, openai, github, orca*; case-insensitive)
and orca-* ids now install only from Orca's official catalog: a Git URL, local folder or any
other catalog is refused, and a third-party catalog listing one is refused whole. The rule lives
in one import-free module the directory indexer can vendor; validate:plugin uses it too and
warns when publisher differs from the origin remote's owner.

A marketplace install never replaces one recorded from a different catalog; switching means
remove and reinstall. Docs state the publisher == repository owner policy (enforced by the
official and community catalog CI, not a new runtime field).

* plugins: label trust by catalog source and note other catalogs listing an install

The chip beside a plugin now says where trust comes from: the Official mark for Orca's catalog,
otherwise the catalog name and the owner parsed from its Git URL (github.com/acme,
gitlab.com/acme/tools), never manifest text; Dev · unverified for development folders and
Unverified for Git URL installs. An installed plugin's page says which other catalogs list it.

* test(plugins): give update review's Git-backed steps a loaded-runner timeout

Install and update review each fetch a plugin over Git. On a loaded machine that
took 25-30s with or without the publisher checks, past the 10s expect default,
so the test read as stalled on Installing…

* test(plugins): give the row-art fixture the entry's catalog source fields

* test(plugins): say why the panel WebRTC test needs a longer timeout

* feat(plugins): dismissible Alpha notice on the Plugins settings page

Shows "Alpha: The plugin interface may change. Expect nuances." under the
Plugins title until dismissed; dismissal persists in UI state
(pluginsAlphaNoticeDismissed). Localized in all locales, with unit and e2e
coverage for show-by-default and dismiss-across-reload.

Co-Authored-By: Claude <noreply@anthropic.com>

* plugins: stop shipping Orca Navigation Shortcuts and retire stale bundled installs

Removes the bundled copy and its fallback-catalog listing. On startup, bundled
bootstrap now uninstalls any previously bundled plugin this release no longer
ships, so existing users don't keep a protected entry they cannot remove.
Removal code moves to plugin-removal.ts to keep plugin-install.ts under the
line limit.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(plugins): concurrent saves of one image no longer fail the update review

Each save now writes its own uniquely named temp file and treats a rename
failure as success when a concurrent writer already produced the same
content-addressed file. A cache failure now only drops the image from the
preview or catalog refresh instead of failing it.

* fix(plugins): an update keeps unchanged machine recipes off the review's New list

Recipes always counted as new because their commands live in files the
manifest diff cannot see. Main now reads what each recipe can run (id,
checkout mode, commands) from the installed tree and the preview's staging
tree, and only a recipe that runs something new asks for review. An
unreadable file still counts as new.

* plugins: app themes color code in the editor via VS Code-shaped tokenColors

Theme files gain optional tokenColors (TextMate scope -> foreground/fontStyle).
The derived Monaco theme maps them onto Monaco's token names, the plugin-syntax
legend and raw TextMate scopes; unreadable colors are dropped.

* test(plugins): theme smoke checks tokenColors color the editor and revert

* i18n: retire translated entries for plugin strings this change rewords or removes

* plugins: add the custom editors contribution point

A plugin can now declare contributes.customEditors to open files of given
types in the editor tab, beside Source. The editor runs in the same
sandboxed, no-network panel frame and only exchanges the document's text
over a scoped postMessage channel; Orca keeps the buffer, dirty state,
saving, external-change banner, read-only tabs and SSH files.

The Editor | Source toggle and the 'first scene after load is the
baseline' approach follow community PR #27413.

Co-authored-by: Roughriver74 <115543510+Roughriver74@users.noreply.github.com>

* plugins: list custom-editor plugins with panels in the plugin list

* plugins: name an editor-only plugin's access 'Adds an editor'

* plugins: keep the Editors category label for custom-editor plugins

* plugins: restyle plugin frames in place and keep file paths out of editor frame names

A theme change used to remount panel and custom editor frames, which reloaded
the plugin and lost its state (Excalidraw undo, unsent edits). The host now posts
orca-panel-theme and the shell updates the --orca-* tokens and the html class in
place. Custom editor frames are named with a random id: window.name is readable
inside the frame, and the old name carried the file's absolute path.

* plugins: let custom editors open binary files as bytes (contentKind)

A custom editor can now declare "contentKind": "bytes" (with "readonly": true)
to receive the file's raw bytes instead of its text, for formats such as
spreadsheets. Orca pages the bytes from the host that loaded the tab (local,
SSH or a paired runtime, through the existing stat + readChunk calls, so no
wire change), refuses files over 16 MiB with a "too large to open" message,
re-sends on outside changes, and never applies a bytes editor's edits. The frame
gets a transferred ArrayBuffer and the matched suffix, never a path.

Documents are now versioned: { version, readOnly, content: { kind, ... } }.
Text edits may name a baseVersion; an edit against a replaced document is
refused with an orca-editor-edit-rejected conflict. Text documents keep the
legacy text field and unversioned edits still apply, so existing editors work.

"readonly": true on any editor makes the install review say "Opens .x files in
Orca" instead of "Opens and edits".

* plugins: suggest catalog custom editors for files Orca already opens

The file-type suggestion only offered a plugin when a file opened as plain text
or as the binary placeholder. It now also offers a catalog custom editor for a
file Orca shows with a language (an .excalidraw file opens as JSON): the editor
asks main with the `editor` purpose, which matches only release file types the
listing's contributions name as custom editors, and only when no installed
plugin's editor handles the type. Binary files such as .xlsx keep the existing
path, since release fileTypes already include custom-editor types. The banner
reads "A plugin adds an editor for .x files: Name"; Not now and Don't suggest
work per type as before.

* fix(plugins): an update that lets a custom editor edit, take over opening, or change content kind asks again

* fix(plugins): one unreadable catalog summary item no longer hides the whole summary

* fix(plugins): plugin editors never open Orca-core file types by default, and join Markdown, CSV and notebook view toggles as options

* fix(plugins): catalog releases list editor file types as data instead of parsing the display summary

* fix(plugins): custom editor edits must name their base version; drop the pre-release text field

* fix(plugins): a custom editor edit Orca refuses keeps the tab dirty and blocks the save instead of vanishing

Rate-limited edits are held and applied before the next save; oversized ones are reported to
the frame and the user. Every save, from any trigger, first flushes plugin editor frames with a
bounded wait and refuses to write while a frame holds edits the buffer lacks.

* fix(plugins): run custom editors as network-isolated guests and save only on real key presses

Editors see file contents, so they now run as <webview> guests in their own in-memory partition
instead of iframes of Orca's renderer. The session routes everything through a dead SOCKS proxy
(loopback included), refuses every non-editor request and permission, and each guest gets
disable_non_proxied_udp, no popups and no navigation. An Electron test proves fetch, WebSocket,
images and TURN-over-TCP all fail from the editor and from its srcdoc and javascript: child
frames, while the same page in a plain session reaches every listener. Electron 43 still resolves
<link rel=dns-prefetch> names outside the session; the test pins that residual and the docs say so.

Save chords now come from main's before-input-event on the guest, which page code cannot
synthesize; the shell's keydown forwarder and the orca-editor-keydown message are gone. Guests
also run in their own renderer process.

* fix(plugins): enforce a read-only custom editor in main, not just the tab

Main reads readonly from the installed manifest (bytes editors always count) and drops every
edit such a guest posts before it reaches the renderer. The tab host also refuses them and tells
the editor its document is read-only, even on a writable tab.

* test(plugins): the editor-suggestion catalog fixture lists editorFileTypes

* plugins: custom editors edit and save binary files; drop readonly and the isolated guest

Bytes editors now post `{ baseVersion, content: { kind: 'bytes', bytes } }` full
replacements (a transferable ArrayBuffer). The tab keeps the unsaved bytes beside
the text drafts, marks itself dirty, and the normal save pipeline (frame flush,
Cmd+S, close prompt) writes them: locally through a new `fs:writeFileBytes`
atomic replace guarded by the loaded bytes' SHA-256, and on SSH-served or paired
hosts by uploading chunks with `files.writeBase64Chunk` and swapping them in with
the new capability-negotiated `files.replaceWithUpload`. Older hosts are refused
before anything is written. A stale baseline is a conflict: the edit stays
unsaved and the external-change banner offers Reload or Overwrite. Source stays
the binary placeholder.

Per the product decision, every custom editor may edit: the `readonly` manifest
field, its main-side enforcement and re-approval rule are gone, and an editor
that only views simply never posts edits. Editors run in the normal sandboxed
plugin frame again instead of a network-isolated webview guest; the frame's save
chord is honored only while it carries real user activation. Consent now reads
"Opens and edits .ext files in Orca. It can read the files it opens."

* plugins: reserve agentHarnesses and hooks instead of agents; rename agents:read to agent:read

* plugins: syntaxes and custom editors share one file matcher (extensions with the dot, plus filenames)

* plugins: title for themes and syntaxes, when for commands (format 1 names rewritten on parse)

* plugins: add the editor catalog type for custom editors

* plugins: official means listed by the official catalog, not an orca- id

* plugins: one envelope protocol and window.orca for workers, panels and editors

Workers, panels and custom editors now share one internal envelope
({orca: 1, kind: req|res|evt|cancel}) instead of three message sets.
Frames get a frozen window.orca from the shell (api, host.call with
AbortSignal cancel, host.has, on, and document helpers in editors);
ready/hello carries the api, the surface's methods and the theme.
Editors gain host calls through their own session.

The host API table is keyed by method with typed params/results (no
handler casts), per-method surfaces and a numeric since minor; every
params schema is strict. Errors are {code, message, retryable,
retryAfterMs?} with cancelled/timeout/conflict/not_permitted/too_large
reserved and unknown codes read as action_failed. Event payloads are
typed with a closed agent-status enum, and the list entry type is
shared by main and preload.

* plugins: orca.document.onDidChange hands a late subscriber the current document

Editors that subscribe after their bundle loads (Excalidraw) missed the
document that arrived first and stayed blank.

* plugins: custom editor documents up to 64 MiB, with a large-file note past 16 MiB

The cap covers text and bytes on every route; reads and SSH or paired-host
writes are already chunked, so only the shared cap moves. Past 16 MiB the
tab shows a small, non-blocking note that the editor may take a moment.

* plugins: delete dead props and branches left by removed rows, logs toggle and access retry

* plugins: one status word per state (a crashed plugin reads Stopped everywhere) and drop unused plugin locale keys

* plugins: contribution rows use the manifest point names, so the policy table is the one source of catalog types

* test(plugins): assert the Use now line and page behavior, not Tailwind classes

* i18n: add the custom editor large-file note to the English catalog

* plugins: fold the loose shared plugin modules into contributions, frames, images, capabilities, events, languages, installation and catalog folders

* plugins: custom editor tab icons and contributes.newFiles templates

A custom editor may declare an icon; the tab and its hover card show it and the
editor title instead of "Editor", falling back to the plugin icon. contributes.newFiles
declares untitled file types (title, extension, optional UTF-8 template and icon) offered
in the tab bar + menu, its search, the command palette and the plugin page, created at the
workspace root through the Markdown untitled-file flow. The install toast's Try it creates
one for plugins that declare them.

* test(plugins): hidden e2e for a plugin new file from the + menu and its editor tab icon

* plugins: turn the plugin system on by default with a one-time migration

Saved false from the old default flips on once (pluginSystemDefaultedOn guard);
a later deliberate false is respected. The Turn on plugins off-state is gone,
and the official catalog seed is deferred off the startup path (and skipped
offline), seeding on demand when the marketplace is opened.

* plugins: move the card menu Disable/Enable directly above Remove, without an icon

* plugins: show the Alpha notice with the warning banner tokens

* plugins: show each category's icon in the filter dropdown

* plugins: seed drawn fallback art per plugin so two listings never look identical

* plugins: show manifest covers for catalog releases published without images

The official catalog's newest Midnight, Nord, OLED and Dusk & Ember releases
were regenerated without an images block (release-entry only kept images the
author pasted), so their cards fell back to drawn art. A catalog that keeps its
plugins in its own repository now borrows the manifest's images from the
catalog commit, checked like declared images, and the validator's printed
release entry includes them so regenerated catalogs keep their covers.

* plugins: drop the unused Turn on plugins strings

* settings: find the Plugins page by plugin, mod and extension searches

* plugins: remove the Allow full-access plugins setting

Full-access (trusted-worker) plugins now need only their own per-plugin
approval: main no longer gates install, consent, enablement or runtime on a
global switch, and the settings menu item, the Turn on in settings card and
page states, and the stored key are gone. The consent now says plainly what
full access means. Kill list, official review and publisher checks are kept;
docs note an enterprise block policy may come later.

* i18n: retire translated entries for plugin strings this change rewords or removes

* test(plugins): prune low-value plugin tests

Drop tests that asserted CSS classes, DOM structure, internal call counts,
copy, or duplicated coverage; merge overlapping e2e file-type suggestion
specs. Keep security, consent, data-integrity, compatibility and main
journey coverage.

* test(plugins): stop git auto-maintenance racing marketplace fetch teardown

* test(plugins): import the panel session token schema from the frame bridge

* feat(plugins): open orca://plugins install links at the plugin's page

orca://plugins/<publisher.id>?catalog=<official|community|git URL> opens
Settings -> Plugins at that plugin; it never installs. Plugins off shows
Turn on plugins first, an unknown catalog asks before it is fetched, and a
malformed link only toasts. Reuses the skill-share deep-link path (open-url,
second-instance argv, sidecar handoff) with repeat/burst dedupe, and
registers orca:// on packaged Windows, which NSIS leaves unregistered.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(plugins): show the linked catalog URL on its own line

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(plugins): follow main's moved plugin files and language-pack coverage field

* fix(plugins): keep a plugin disabled through its update and give format 1 event handlers worktreeId

* fix(plugins): keep held editor edits across view switches, ignore stale editor entry reads, share bytes across split panes, and meet the low-evidence lint

* refactor(editor): keep the file content loader under the line limit

* fix(plugins): keep a bytes draft's baseline across reloads, cover plugin new-file menu rows, and move render-time ref writes into layout effects

* refactor(tab-bar): keep the create-menu controller under the line limit

* refactor(tab-bar): keep the create-menu controller under the line limit with the plugin new-file case

* test(plugins): turn plugins back on through settings in the install-link journey

* fix(wsl): import the atomic plugin file writer from its new folder

* fix(plugins): save bytes custom-editor edits from Save-and-close and the overwrite banner

* test(plugins): park the Linux-only bytes editor e2e failure as a follow-up

* fix(plugins): follow main's new language-pack tests to the moved artifact module

* test(i18n): give main's new plugin language-pack fixture its coverage

* plugins: manifest v2 ships in 1.4.245, since 1.4.244 released without it

* fix(i18n): import the plugin language-pack artifact from its folder in the shared i18n instance

* fix(plugins): drop syntax loads that land after dispose and restore terminal overrides on Switch back

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Roughriver74 <115543510+Roughriver74@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-28 20:58:25 -07:00
committed by GitHub
co-authored by Claude Roughriver74
parent d8eef80bf4
commit edc0373fcd
768 changed files with 47275 additions and 10214 deletions
+7 -1
View File
@@ -10,6 +10,12 @@
".github/actions/cloud-sql-rollout-lease/**",
".anti-slop-plugin/**",
"src/main/runtime/__fixtures__/*.timing.json",
"resources/skills/released-digests.json"
"resources/skills/released-digests.json",
"docs/schemas/plugins/**/*.schema.json",
"examples/plugins/*/orca-plugin.v2.schema.json",
"docs/reference/plugin-manifest.md",
"docs/reference/plugin-catalog.md",
"docs/reference/plugin-host-api.md",
"docs/reference/plugin-theme-tokens.md"
]
}
@@ -13,7 +13,7 @@ export const MAIN_BUILD_ENTRY_POINTS: Record<string, string> = {
'src/main/browser/extensions/browser-extension-native-host-supervisor-entry.ts'
),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/workers/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/computer-sidecar.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
+3 -3
View File
@@ -952,7 +952,7 @@
2 test-no-wall-clock:src/main/persistence/tracking-repos/persistence-deregistered-repo-residue.test.ts
2 test-no-wall-clock:src/main/persistence/tracking-repos/persistence-workspace-session-scrollback.test.ts
1 test-no-wall-clock:src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts
1 test-no-wall-clock:src/main/plugins/plugin-host-methods.test.ts
1 test-no-wall-clock:src/main/plugins/workers/plugin-host-methods.test.ts
2 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.integration.test.ts
1 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.test.ts
18 test-no-wall-clock:src/main/provider-process/provider-process-supervisor.integration.test.ts
@@ -1203,7 +1203,7 @@
2 test-no-wall-clock:src/shared/fish-query-reply-child-stdin.node-pty.test.ts
1 test-no-wall-clock:src/shared/git-binary-compatibility.test.ts
1 test-no-wall-clock:src/shared/mobile-push-contract.test.ts
1 test-no-wall-clock:src/shared/plugins/plugin-kill-list.test.ts
1 test-no-wall-clock:src/shared/plugins/catalog/plugin-kill-list.test.ts
2 test-no-wall-clock:src/shared/pty-reply-echo-shapes.node-pty.test.ts
4 test-no-wall-clock:src/shared/sidebar-list/smart-sort.test.ts
2 test-no-wall-clock:src/shared/startup-command-staging.node-pty.test.ts
@@ -1333,7 +1333,7 @@
2 test-timeout-needs-reason:src/main/persistence/persistence-initial-load.test.ts
1 test-timeout-needs-reason:src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts
1 test-timeout-needs-reason:src/main/persistence/terminal-topology/terminal-topology-model.test.ts
1 test-timeout-needs-reason:src/main/plugins/plugin-worker-supervision.integration.test.ts
1 test-timeout-needs-reason:src/main/plugins/workers/plugin-worker-supervision.integration.test.ts
1 test-timeout-needs-reason:src/main/ports/port-scan-command-client.test.ts
1 test-timeout-needs-reason:src/main/ports/port-scan-command-execution.test.ts
2 test-timeout-needs-reason:src/main/providers/agent-foreground-process-git-bash.win32.test.ts
+1 -1
View File
@@ -5,7 +5,7 @@
"src/preload/index.ts",
"src/preload/browser-window-close.ts",
"src/main/daemon/daemon-entry.ts",
"src/main/plugins/plugin-host-entry.ts",
"src/main/plugins/workers/plugin-host-entry.ts",
"src/main/computer/computer-sidecar.ts",
"src/main/speech/stt-worker.ts",
"src/main/warp-themes/warp-theme-parser-worker.ts",
+1 -1
View File
@@ -35,7 +35,7 @@
1 src/main/orcad/orcad-data-root-privacy.ts
2 src/main/persistence/profile-state/profile-state-access-identity.ts
1 src/main/persistence/profile-state/profile-state-recovery-copy.ts
1 src/main/plugins/plugin-private-marketplace-ssh-shim.cjs
1 src/main/plugins/catalog/plugin-private-marketplace-ssh-shim.cjs
1 src/main/pty/posix-pty-process-table.ts
1 src/main/pwsh.ts
1 src/main/runtime/runtime-skill-install-authority.ts
@@ -82,8 +82,8 @@ src/main/native-chat/wsl-transcript-fs-process-slot.ts
src/main/native-chat/wsl-transcript-fs-process-spawn.ts
src/main/network/macos-system-resolver-health.ts
src/main/network/macos-tailscale-dns-diagnostic.ts
src/main/plugins/plugin-host-process.ts
src/main/plugins/plugin-private-marketplace-ssh-shim.cjs
src/main/plugins/workers/plugin-host-process.ts
src/main/plugins/catalog/plugin-private-marketplace-ssh-shim.cjs
src/main/ports/port-scan-command-execution.ts
src/main/providers/macos-login-session-pty-probe.ts
src/main/providers/process-cwd.ts
+4 -4
View File
@@ -19060,12 +19060,12 @@
"invariant": "terminal.sendText accepts only an explicit provider-owned terminal id present in one bounded inventory of the worktree resolved immediately before the send; an absent id causes zero send calls, and relay callers cannot supply their own capability grants or transport classification.",
"oracle": "Resolve the active worktree once, list that worktree with the v0 terminal cap once, and assert zero sendTerminal calls for a mismatched opaque id versus exactly one send for matching local- and SSH-shaped ids; then run the same permission and schema cases through desktop-main and registered relay panel/worker adapters and compare error codes.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/workers/plugin-host-methods.test.ts"
],
"testFiles": ["src/main/plugins/plugin-host-methods.test.ts"],
"testFiles": ["src/main/plugins/workers/plugin-host-methods.test.ts"],
"assertionRefs": [
{
"file": "src/main/plugins/plugin-host-methods.test.ts",
"file": "src/main/plugins/workers/plugin-host-methods.test.ts",
"assertions": [
"a terminal outside the freshly resolved worktree performs one capped list and zero sends",
"matching local- and SSH-shaped opaque ids each perform one capped list and one exact send",
@@ -19078,7 +19078,7 @@
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/workers/plugin-host-methods.test.ts",
"result": "passed",
"durationSeconds": 0.18,
"summary": "2 files and 17 tests passed, covering the 13-method main/relay conformance matrix and exact terminal routing call counts."
+9 -9
View File
@@ -12621,18 +12621,18 @@
"src/shared/plugins/hello-orca-panel-messages.test.ts": 45,
"src/shared/plugins/plugin-consent-fingerprint.test.ts": 122,
"src/shared/plugins/plugin-consent-request.test.ts": 68,
"src/shared/plugins/plugin-content-pack-contributions.test.ts": 115,
"src/shared/plugins/plugin-install-lockfile.test.ts": 151,
"src/shared/plugins/plugin-kill-list.test.ts": 241,
"src/shared/plugins/contributions/plugin-content-pack-contributions.test.ts": 115,
"src/shared/plugins/installation/plugin-install-lockfile.test.ts": 151,
"src/shared/plugins/catalog/plugin-kill-list.test.ts": 241,
"src/shared/plugins/plugin-language-pack-artifact.test.ts": 109,
"src/shared/plugins/plugin-manifest.test.ts": 136,
"src/shared/plugins/plugin-marketplace.test.ts": 205,
"src/shared/plugins/plugin-panel-call-admission.test.ts": 131,
"src/shared/plugins/plugin-panel-message-budget-traversal.test.ts": 180,
"src/shared/plugins/plugin-panel-message-budget.test.ts": 165,
"src/shared/plugins/plugin-panel-pong-frame.test.ts": 173,
"src/shared/plugins/plugin-panel-shell.test.ts": 216,
"src/shared/plugins/plugin-path-safety.test.ts": 94,
"src/shared/plugins/frames/plugin-panel-call-admission.test.ts": 131,
"src/shared/plugins/frames/plugin-panel-message-budget-traversal.test.ts": 180,
"src/shared/plugins/frames/plugin-panel-message-budget.test.ts": 165,
"src/shared/plugins/frames/plugin-panel-pong-frame.test.ts": 173,
"src/shared/plugins/frames/plugin-panel-shell.test.ts": 216,
"src/shared/plugins/installation/plugin-path-safety.test.ts": 94,
"src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 132,
"src/shared/portuguese-ui-language-migration.test.ts": 114,
"src/shared/posix-wait-status.test.ts": 31,
@@ -0,0 +1,31 @@
// Writes the frozen orca-marketplace.json that Orca builds before catalog format 2 read, from
// orca-catalog.json. Why generated: those builds reject any key they do not know, so the file
// carries only the original fields and authors should never edit it by hand.
// Usage: node export-legacy-plugin-catalog.mjs [orca-catalog.json] [orca-marketplace.json]
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
/** Projects a format 2 catalog onto the frozen format 1 shape. */
export function legacyPluginCatalog(catalog) {
return {
name: catalog.name,
owner: catalog.owner,
// Why skipped: format 1 has no `path`, so those builds would install the repository root.
plugins: catalog.plugins
.filter((entry) => !entry.source.path)
.map((entry) => ({
id: entry.id,
source: { kind: entry.source.kind, url: entry.source.url, ref: entry.source.ref },
...(entry.description ? { description: entry.description } : {}),
categories: entry.categories ?? []
}))
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const input = process.argv[2] ?? 'orca-catalog.json'
const output = process.argv[3] ?? 'orca-marketplace.json'
const catalog = JSON.parse(readFileSync(input, 'utf8'))
writeFileSync(output, `${JSON.stringify(legacyPluginCatalog(catalog), null, 2)}\n`)
console.log(`wrote ${output} with ${catalog.plugins.length} plugins`)
}
@@ -0,0 +1,14 @@
// Why: the zod schemas in src/shared/plugins are the only definition; this writes the published
// JSON Schemas and author references from them. The drift test in plugin-json-schema.test.ts
// fails when these files fall behind.
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { loadPluginSchemaModule, REPO_ROOT } from './plugin-schema-module-loader.mjs'
const files = loadPluginSchemaModule('manifest/plugin-json-schema.ts').pluginSchemaGeneratedFiles()
for (const [relative, content] of Object.entries(files)) {
const target = path.join(REPO_ROOT, ...relative.split('/'))
mkdirSync(path.dirname(target), { recursive: true })
writeFileSync(target, content)
console.log(`wrote ${relative}`)
}
+2 -2
View File
@@ -470,8 +470,8 @@ const JA_AUDIT_OVERRIDES = {
'ダークモードでのペイン間の分割線を制御します。',
'Countdown timer showing time until prompt cache expires (Claude agents).':
'プロンプトキャッシュが期限切れになるまでの時間を示すカウントダウンタイマー(Claude Agent )。',
'Install and manage experimental Orca plugins.':
'実験的機能の Orca プラグインをインストール・管理します。',
'Install and manage Orca plugins (Alpha).':
'Orca プラグインをインストール・管理します(アルファ版)。',
"Resident memory held by Orca plus the processes under each worktree's terminals.":
'Orca が保持する常駐メモリと、各ワークツリーのターミナル配下で実行中のプロセス。',
'No local Claude, Codex, or OpenCode usage found yet. The overview will populate after the next agent session writes token logs.':
@@ -7,7 +7,7 @@ import { build } from 'esbuild'
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
const baseline = process.argv[2] ?? '20ab9950654'
const file = 'src/shared/plugins/plugin-panel-message-budget.ts'
const file = 'src/shared/plugins/frames/plugin-panel-message-budget.ts'
async function load(contents) {
const result = await build({
stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) },
@@ -0,0 +1,29 @@
// Loads a src/shared/plugins TypeScript module from a plain Node script, so the generator and the
// validator run the exact runtime schemas without a TypeScript loader.
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import * as esbuild from 'esbuild'
export const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
export function loadPluginSchemaModule(fileName) {
// Why under out/: the bundle keeps zod external, so it must resolve from the repo's node_modules.
mkdirSync(path.join(REPO_ROOT, 'out'), { recursive: true })
const buildDir = mkdtempSync(path.join(REPO_ROOT, 'out', 'plugin-schemas-'))
try {
const outfile = path.join(buildDir, 'bundle.cjs')
esbuild.buildSync({
entryPoints: [path.join(REPO_ROOT, 'src', 'shared', 'plugins', fileName)],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
logLevel: 'error',
packages: 'external'
})
return createRequire(import.meta.url)(outfile)
} finally {
rmSync(buildDir, { recursive: true, force: true })
}
}
+216
View File
@@ -0,0 +1,216 @@
// Strict check of plugin manifests and catalogs, for authors and catalog CI.
// Usage: node config/scripts/validate-plugin-files.mjs <plugin dir | orca-plugin.json | orca-catalog.json>...
// For a plugin directory in a Git repository it also prints the catalog release entry for the
// committed HEAD, with the `source.path` to list when the plugin is not the repository root.
import { createHash } from 'node:crypto'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { loadPluginSchemaModule } from './plugin-schema-module-loader.mjs'
const { validatePluginFile } = loadPluginSchemaModule('installation/plugin-file-validation.ts')
const { parsePluginManifest } = loadPluginSchemaModule('manifest/plugin-manifest.ts')
const { pluginCatalogReleaseEntry } = loadPluginSchemaModule('catalog/plugin-catalog-release.ts')
// Why the install code itself: the printed contentHash must be what Orca computes at install.
const { materializePluginGitTree } = loadPluginSchemaModule(
'../../main/plugins/installation/plugin-git-tree.ts'
)
const { hashPluginTree } = loadPluginSchemaModule('../../main/plugins/plugin-content-hash.ts')
const { gitExecFileAsync } = loadPluginSchemaModule('../../main/git/runner.ts')
const { formatPluginSchemaIssues } = loadPluginSchemaModule('manifest/plugin-schema-issues.ts')
const { isPluginRepositoryPath } = loadPluginSchemaModule('installation/plugin-path-safety.ts')
const { parseGitRepositoryIdentity } = loadPluginSchemaModule('catalog/plugin-marketplace.ts')
const { catalogImagesFromManifest } = loadPluginSchemaModule(
'images/plugin-manifest-catalog-images.ts'
)
const ARTIFACT_PARSERS = {
themes: loadPluginSchemaModule('artifacts/plugin-theme-artifact.ts').parsePluginAppThemeArtifact,
terminalThemes: loadPluginSchemaModule('artifacts/plugin-terminal-theme-artifact.ts')
.parsePluginTerminalThemeArtifact,
iconThemes: loadPluginSchemaModule('artifacts/plugin-icon-theme-artifact.ts')
.parsePluginIconThemeArtifact
}
const { parsePluginLanguagePackArtifact } = loadPluginSchemaModule(
'artifacts/plugin-language-pack-artifact.ts'
)
// Why strict here: Orca skips unknown theme tokens and icon slots at runtime, so authors only
// learn about a typo from this check.
function artifactErrors(pluginDir, manifest) {
return Object.entries(ARTIFACT_PARSERS).flatMap(([point, parse]) =>
manifest.contributes[point].flatMap((contribution) => {
let text = ''
try {
text = readFileSync(path.join(pluginDir, ...contribution.path.split('/')), 'utf8')
parse(text, 'strict')
return []
} catch (error) {
const detail = error.issues
? formatPluginSchemaIssues(error.issues, JSON.parse(text))
: error.message
return [`contributes.${point} "${contribution.id}" (${contribution.path}): ${detail}`]
}
})
)
}
// Why: Orca drops protected security copy from a pack at load, which authors would never notice.
function languagePackErrors(pluginDir, manifest) {
return manifest.contributes.languagePacks.flatMap((pack) => {
const file = path.join(pluginDir, ...pack.path.split('/'))
const parsed = existsSync(file)
? parsePluginLanguagePackArtifact(readFileSync(file, 'utf8'), 'strict')
: { ok: false, error: 'file not found' }
return parsed.ok
? []
: [`contributes.languagePacks "${pack.locale}" (${pack.path}): ${parsed.error}`]
})
}
async function git(cwd, args) {
return (await gitExecFileAsync(args, { cwd })).stdout.trim()
}
/** Release entry for HEAD of the repository holding `pluginDir`; a note when that is not possible. */
async function catalogReleaseEntry(pluginDir) {
// Why --show-prefix: it is the plugin's directory in the repository, always with `/`.
const prefix = await git(pluginDir, ['rev-parse', '--show-prefix']).catch(() => null)
if (prefix === null) {
return { note: 'no catalog release entry: the plugin is not in a Git repository' }
}
const sourcePath = prefix.replace(/\/$/, '') || undefined
if (sourcePath !== undefined && !isPluginRepositoryPath(sourcePath)) {
return { note: `no catalog release entry: ${sourcePath} cannot be a catalog source path` }
}
const commit = await git(pluginDir, ['rev-parse', 'HEAD'])
const dirty = (await git(pluginDir, ['status', '--porcelain', '--', '.'])) !== ''
const tree = mkdtempSync(path.join(tmpdir(), 'orca-plugin-release-'))
try {
await materializePluginGitTree({
repository: pluginDir,
commit,
destination: tree,
...(sourcePath ? { path: sourcePath } : {})
})
const hashed = await hashPluginTree(tree)
const rawManifest = JSON.parse(readFileSync(path.join(tree, 'orca-plugin.json'), 'utf8'))
const parsed = parsePluginManifest(rawManifest)
const release =
hashed.ok && parsed.ok
? pluginCatalogReleaseEntry(parsed.manifest, { commit, contentHash: hashed.hash })
: null
// Why here: a new release entry must carry the manifest's images, or listings lose their covers.
const images = release
? await catalogImagesFromManifest({
manifest: rawManifest,
prefix: sourcePath,
read: async (repositoryPath, maxBytes) => {
const file = path.join(
tree,
repositoryPath.slice(sourcePath ? sourcePath.length + 1 : 0)
)
return existsSync(file) && statSync(file).size <= maxBytes ? readFileSync(file) : null
},
sha256: (bytes) => createHash('sha256').update(bytes).digest('hex')
})
: null
const entry = release && images ? { ...release, images } : release
if (!entry) {
return {
note: `no catalog release entry: committed plugin is not installable (${hashed.ok ? (parsed.ok ? 'catalogs need manifest format 2' : parsed.error) : hashed.error})`
}
}
return {
entry,
sourcePath,
...(dirty ? { note: 'uncommitted changes are not in this release; commit them first' } : {})
}
} finally {
rmSync(tree, { recursive: true, force: true })
}
}
// Why a warning: only the official and community catalogs require it; a private catalog may not.
async function publisherOwnerWarning(pluginDir) {
const remote = await git(pluginDir, ['remote', 'get-url', 'origin']).catch(() => '')
const owner = parseGitRepositoryIdentity(remote)?.owner
const { publisher } = JSON.parse(readFileSync(path.join(pluginDir, 'orca-plugin.json'), 'utf8'))
return owner && owner.toLowerCase() !== publisher.toLowerCase()
? `warning: the official and community catalogs require publisher to match the repository owner "${owner}" (publisher is "${publisher}")`
: null
}
const targets = process.argv.slice(2)
if (targets.length === 0) {
console.error(
'usage: validate-plugin-files <plugin dir | orca-plugin.json | orca-catalog.json>...'
)
process.exit(2)
}
let failed = false
for (const target of targets) {
const files = statSync(target).isDirectory()
? ['orca-plugin.json', 'orca-catalog.json']
.map((name) => path.join(target, name))
.filter((file) => existsSync(file))
: [target]
if (files.length === 0) {
console.error(`${target}: no orca-plugin.json or orca-catalog.json`)
failed = true
}
let manifestOk = false
for (const file of files) {
let raw
try {
raw = JSON.parse(readFileSync(file, 'utf8'))
} catch (error) {
console.error(`${file}: invalid JSON: ${error.message}`)
failed = true
continue
}
const root = path.dirname(file)
// Why contained: an image path must stay inside the catalog repository.
const readCatalogFile = (relative) => {
const target = path.resolve(root, relative)
if (!target.startsWith(`${path.resolve(root)}${path.sep}`) || !existsSync(target)) {
return null
}
const bytes = readFileSync(target)
return { bytes, sha256: createHash('sha256').update(bytes).digest('hex') }
}
const errors = validatePluginFile(path.basename(file), raw, readCatalogFile)
if (errors.length === 0 && path.basename(file) === 'orca-plugin.json') {
const { manifest } = parsePluginManifest(raw)
errors.push(...artifactErrors(root, manifest), ...languagePackErrors(root, manifest))
}
for (const error of errors) {
console.error(`${file}: ${error}`)
}
failed ||= errors.length > 0
if (errors.length === 0) {
console.log(`${file}: ok`)
manifestOk ||= path.basename(file) === 'orca-plugin.json'
}
}
if (manifestOk && statSync(target).isDirectory()) {
const { entry, sourcePath, note } = await catalogReleaseEntry(target)
if (sourcePath) {
console.log(
`\nCatalog source path (set "path" in this plugin's "source"): ${JSON.stringify(sourcePath)}`
)
}
if (entry) {
console.log(
`\nCatalog release entry for HEAD (newest first in "releases"):\n${JSON.stringify(entry, null, 2)}`
)
}
if (note) {
console.log(`${target}: ${note}`)
}
const ownerWarning = await publisherOwnerWarning(target)
if (ownerWarning) {
console.warn(`${target}: ${ownerWarning}`)
}
}
}
process.exit(failed ? 1 : 0)
@@ -74,8 +74,9 @@ function verifyPackagedPluginResources(resourcesDir) {
}
const index = readJsonFile(join(launchRoot, 'bundled-plugins.json'), 'bundled plugin index')
readJsonFile(join(launchRoot, 'orca-marketplace.json'), 'marketplace index')
if (index?.version !== 1 || !Array.isArray(index.plugins) || index.plugins.length === 0) {
throw new Error('[verify-packaged-plugin-resources] bundled plugin index is empty or invalid')
// An empty index is valid: a release may ship no bundled plugins.
if (index?.version !== 1 || !Array.isArray(index.plugins)) {
throw new Error('[verify-packaged-plugin-resources] bundled plugin index is invalid')
}
const resolvedRoot = resolve(launchRoot)
for (const entry of index.plugins) {
@@ -108,4 +109,4 @@ function verifyPackagedPluginResources(resourcesDir) {
)
}
module.exports = { verifyPackagedPluginResources }
module.exports = { hashPackagedPluginTree, verifyPackagedPluginResources }
@@ -5,7 +5,32 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { verifyPackagedPluginResources } = require('./verify-packaged-plugin-resources.cjs')
const {
hashPackagedPluginTree,
verifyPackagedPluginResources
} = require('./verify-packaged-plugin-resources.cjs')
const FIXTURE_KEY = 'stablyai.orca-multipass-recipes'
/** Copies the launch tree and indexes one shipped pack so byte checks have something to verify. */
async function copyIndexedLaunchTree(resourcesDir) {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, { recursive: true })
await writeFile(
join(launchRoot, 'bundled-plugins.json'),
JSON.stringify({
version: 1,
plugins: [
{
pluginKey: FIXTURE_KEY,
path: FIXTURE_KEY,
contentHash: hashPackagedPluginTree(join(launchRoot, FIXTURE_KEY))
}
]
})
)
return launchRoot
}
describe('verify packaged plugin resources', () => {
it('accepts exact launch bytes copied into a packaged resources directory', async () => {
@@ -26,17 +51,12 @@ describe('verify packaged plugin resources', () => {
it('rejects mutated bytes in the packaged output', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
recursive: true
})
await writeFile(
join(launchRoot, 'stablyai.orca-navigation-shortcuts', 'extra.json'),
'{"mutated":true}\n'
)
const launchRoot = await copyIndexedLaunchTree(resourcesDir)
expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow()
await writeFile(join(launchRoot, FIXTURE_KEY, 'extra.json'), '{"mutated":true}\n')
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
'packaged bytes do not match stablyai.orca-navigation-shortcuts'
`packaged bytes do not match ${FIXTURE_KEY}`
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
@@ -46,10 +66,7 @@ describe('verify packaged plugin resources', () => {
it('rejects a CRLF checkout of the launch tree', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
recursive: true
})
const launchRoot = await copyIndexedLaunchTree(resourcesDir)
for (const entry of await readdir(launchRoot, { recursive: true })) {
const path = join(launchRoot, entry)
if (!(await stat(path)).isFile()) {
+1 -1
View File
@@ -117,7 +117,7 @@ const NODE_RUNTIME_INCLUDE = [
// Times V8 string flattening, which is what Electron main runs.
'src/main/runtime/terminal-tail-redraw-wall-time.test.ts',
'src/main/agent-hooks/server-transport-interference.test.ts',
'src/main/plugins/plugin-worker-supervision.integration.test.ts',
'src/main/plugins/workers/plugin-worker-supervision.integration.test.ts',
// Reconciliation loads the real native Parcel watcher; Bun can abort on Linux ARM.
'src/main/plugins/plugin-service-reconciliation.test.ts',
'src/main/usage/usage-scan-worker-event-loop.test.ts',
+249
View File
@@ -0,0 +1,249 @@
# Writing an Orca plugin
From an empty folder to a plugin anyone can install from a catalog. The field-by-field rules
live in the generated references: [manifest](plugin-manifest.md), [catalog](plugin-catalog.md),
[host API](plugin-host-api.md), [themes](plugin-themes.md) and [theme tokens](plugin-theme-tokens.md),
[syntaxes](plugin-syntax.md).
## 1. Write
A plugin is a folder (in practice its own Git repository) with `orca-plugin.json` at the root.
Start from `examples/plugins/orca-gleam-syntax`, which carries the JSON Schema for editor
completion, or from one of:
| Example | Execution | Shows |
| ------------------------------------ | ---------------- | ------------------------------------ |
| `examples/plugins/orca-gleam-syntax` | `data` | A syntax, no code |
| `examples/plugins/hello-orca` | `trusted-worker` | A panel, a worker command and events |
Set `publisher` to the GitHub or GitLab user or organization that owns your repository (`acme`
for `github.com/acme/gleam-syntax`), or the official and community catalogs reject the listing.
When copying the Gleam example, also drop its `stablyai` publisher and `orca-` id prefix: both are
reserved for Orca's own plugins.
Pick the lowest `execution` profile that covers what you contribute; it decides what the user is
asked to approve:
- `data`: themes, terminal themes, icon themes, syntaxes, translations. Installs in one click.
- `declarative`: commands that run Orca actions, shortcuts, VM recipes. Reviewed before they
are enabled.
- `sandboxed-ui`: panels and custom editors, in a sandboxed frame that talks to Orca only
through `window.orca` (see [Frames](#frames-windoworca)) and can call only the host methods
whose surfaces list `panel` or `editor`.
Orca's colors reach frame CSS as `--orca-background`, `--orca-foreground`, `--orca-primary`
and the rest of its panel token set, so they never collide with a frame's own variables.
- `trusted-worker`: `main` runs in a separate Node process and can call every host method its
`capabilities` allow. There is no global switch for these full-access plugins: each one runs
only after the user approves it on install. An enterprise policy to block full-access plugins
may come later.
Set `engines.orca` to the newest `Since` of any field you use, and `pluginApi` (currently `1`)
only when a panel or worker calls the host API.
### Frames: `window.orca`
Before any plugin code runs, the frame shell defines a frozen `window.orca`. It is the only
supported way to talk to Orca; the messages underneath are internal and change without notice.
```js
orca.api // { major: 1, minor: 0 }: the host API this Orca implements
if (orca.host.has('workspace.readContext')) {
// Synchronous: is the method offered to this surface by this Orca?
const controller = new AbortController()
try {
const context = await orca.host.call('workspace.readContext', {}, { signal: controller.signal })
} catch (error) {
// error.code, error.message, error.retryable, error.retryAfterMs (rate_limited only)
}
}
const subscription = orca.on('theme', ({ colorScheme }) => {})
subscription.dispose()
```
Methods, their surfaces and their errors are in the [host API reference](plugin-host-api.md).
Aborting the `signal` rejects the call with `cancelled`. Treat an error `code` you do not know as
`action_failed`. Params are strict: a field the running Orca does not know is refused with
`invalid_params`, so check `orca.host.has` or `orca.api.minor` before using a newer one.
Workers get the same calls on the `orca` object passed to `activate(orca)`:
`orca.api`, `orca.host.call(method, params, { signal })`, `orca.host.has(method)`, plus
`orca.commands.register(id, (args, { signal }) => …)` and `orca.events.on(event, handler)`, which
both return `{ dispose() }`. A command's `signal` aborts when Orca stops waiting for it. In
`agent.status.changed`, `state` and `mainAgent.state` are one of `working`, `blocked`, `waiting`
and `done`.
### Custom editors
A custom editor opens files that end in one of its `extensions` (with the dot, such as
`.excalidraw`) or carry one of its exact `filenames` in the editor tab, beside Source. Syntaxes
match files the same way. It runs in the same sandboxed frame as a panel (no Node, no file system), and Orca keeps
the buffer, dirty state, saving, the external-change banner, read-only tabs and remote (SSH or
paired host) files; the frame only gets the document, never a path. Editor and panel plugins are
trusted code, and an editor can read every file it opens, so install ones you trust.
An editor's `window.orca` adds `document`:
```js
const doc = await orca.document.get() // { version, readOnly, content, fileType? }
// The current document right away, then each one Orca sends when the file changes outside the editor.
orca.document.onDidChange((doc) => render(doc))
await orca.document.applyEdit({ baseVersion: doc.version, content: { kind: 'text', text } })
orca.on('editor.flush', () => sendPendingEdit()) // before a save; Orca waits for the promise
```
`content` is `{ kind: 'text', text }` for a text editor (the default `contentKind`). An edit is
the full new content, made against document `baseVersion`; send one only when the user changes
the document, never on load. `applyEdit` rejects with `conflict` when the file changed outside
the editor since that version (edit the newer document instead), `too_large` past the size cap,
and `not_permitted` when `readOnly` is true (an agent transcript, say). `version` rises with each
document Orca sends, not with your own edits.
Set `"contentKind": "bytes"` for a binary format such as a spreadsheet. Orca reads the file
itself (locally, over SSH or from a paired host) and the document's `content` is
`{ kind: 'bytes', bytes }`, where `bytes` is an `ArrayBuffer`, plus the matched suffix as
`fileType`. An edit is the whole new file the same way: `content: { kind: 'bytes', bytes }`.
Orca marks the tab dirty and saves the bytes with the tab's normal save, replacing the file
atomically; a save is refused if the file changed on disk since the document's version, and the
banner offers Reload or Overwrite. Saving to a remote host needs an Orca server new enough to
replace binary files. Source shows the binary placeholder, unsaved bytes are not kept across
restarts, and bytes tabs don't autosave.
Documents and edits are capped at 64 MiB, text or bytes, on every route (local, SSH and paired
hosts): a larger file shows "too large to open", and a larger edit is rejected with `too_large`.
Past 16 MiB the tab notes that the file may take a moment. Orca saves on its own save shortcut pressed
inside the frame, never on a call alone.
Theme changes do not reload the frame: the shell updates the `--orca-*` variables and the
`light`/`dark` class on `<html>` in place and emits `theme`. Restyle from the variables, or
listen with `orca.on('theme', …)`, to follow it.
## 2. Load it while you work
1. Settings → Plugins: click **Turn on plugins**.
2. **Development folders…** → add the plugin folder. A development folder overrides an installed
copy with the same `<publisher>.<id>`.
3. Review and enable it. Nothing runs before that.
Orca watches development folders and reloads the plugin when its manifest or files change.
Theme, syntax and translation edits show up as you save. Panels and workers are not bound to
their files in development, so saving does not ask for approval again; adding a capability or
moving to a higher execution profile does. Shortcuts and recipes are tied to their files, so
each change to them is reviewed again. Use **Restart** on the plugin row after changing a
worker, and the plugin's logs to see what it printed.
## 3. Validate
```sh
pnpm validate:plugin path/to/plugin
```
This runs the manifest checks Orca runs, strictly: unknown keys, limits, contributions beyond the
execution profile, and an `engines.orca` too low for the fields used. On a catalog it also
checks that listed images exist and fit. In a Git repository it warns when `publisher` is not the
owner of the `origin` remote. Run it in CI for the plugin and catalog repositories.
## 4. Publish to a catalog
A catalog is a Git repository with `orca-catalog.json` at its root that lists plugins and pins
each release to an exact commit and file hash, so Orca refuses bytes the catalog did not vouch
for.
1. Commit the plugin and run `pnpm validate:plugin` on its directory. It prints the
release entry for `HEAD`: version, full commit SHA, `contentHash`, execution profile, minimum
Orca, contribution summary, capabilities and file types. Every field must match
what Orca finds at install, so paste it rather than writing it by hand.
2. Add or update the plugin's entry in the catalog's `orca-catalog.json`, newest release first:
```json
{
"id": "acme.gleam-syntax",
"source": {
"kind": "git",
"url": "https://github.com/acme/orca-gleam-syntax.git",
"ref": "main"
},
"categories": ["languages"],
"releases": [{ "...": "the printed entry" }]
}
```
`commit` must be a full 40-character SHA. A tag or branch name is refused by validation and
skipped by Orca. Keep the previous release listed when you raise `engines.orca`, so older
Orca still has something to install.
**One repository can hold many plugins.** Put each in its own directory, such as
`plugins/gleam-syntax/orca-plugin.json`, and add `"path": "plugins/gleam-syntax"` to its
`source`; validating that directory prints the `path` to use. Orca installs only that
directory, and its `contentHash` covers only its files, so commits to sibling plugins never
change it. Moving a plugin to another directory counts as a different repository.
3. Run `pnpm validate:plugin path/to/catalog` and regenerate the older
`orca-marketplace.json` as described in the [catalog reference](plugin-catalog.md#clients-before-format-2).
4. Users add the catalog under Settings → Plugins → ⋯ → **Catalog sources…** with its Git URL and ref.
Users approve a plugin once per level of access. An update from the same repository that asks
for nothing new, code changes included, installs without asking again and stays enabled. Orca
asks again, listing what is new, when an update adds a capability, raises the execution profile,
adds a command, shortcut, panel or event subscription, ships virtual machine recipes, or comes from
a different repository.
With **Update plugins automatically** on (the default), Orca installs such updates for enabled
catalog plugins by itself, checking at startup and every 6 hours. A release installs only once it
has been listed for 12 hours, or at once from the official catalog, so a takedown has time to land.
Only pinned catalog releases (a `releases` entry with its commit and content hash) install by
themselves; a listing without one updates only when the user clicks Update. This includes
full-access plugins: an update that asks for nothing new runs its new code with the access the user
already granted, the same way an editor's extensions update. Turn the setting off to review every
update. An update that needs approval is not installed: the current version keeps running and the
plugin shows **Update needs review**.
## Planned contribution points
These `contributes` keys are reserved: a manifest may list them only as empty arrays until
their format ships. Each is planned as declared data that Orca renders and enforces, not
arbitrary plugin UI:
- `taskSources`: issue and task providers such as Asana, Linear or Jira, filled by a worker.
- `connections`: account providers with a host-run sign-in.
- `agentHarnesses`: new coding-agent harnesses described as data.
- `statusBarItems`: declarative status bar widgets whose values a worker updates.
- `sidebarCards`: declarative left-sidebar cards whose values a worker updates.
- `mcpServers`: MCP server definitions offered to the agents Orca launches.
- `skills`: agent skills shipped as files.
- `hooks`: agent lifecycle hooks for the agents Orca launches.
- `settings`: typed plugin settings shown in Orca's settings UI.
`skills`, `mcpServers` and `hooks` will mean what they mean in agent-plugin formats such as
Claude Code's. Orca does not reserve `agents`, because those formats use it for subagent files.
See [the manifest reference](plugin-manifest.md#planned-contribution-points) for details.
## Stability
Plugins are in Alpha: the manifest v2 format and catalog format are stable contracts; items
listed as experimental may change. These parts are stable and change only additively:
- manifest format 2, capability names and execution profiles;
- host API 1 (`"pluginApi": 1`): method names, params, results, surfaces and error codes;
- the frame API `window.orca` (`api`, `host.call`, `host.has`, `on`, and `document` in custom
editors) and the document `version`/`content` shape;
- theme token names, and theme ids once published (users' settings store them);
- catalog format 2 (`orca-catalog.json`).
Experimental; these may still change without a new format:
- the `agent.status.changed` payload: `paneKey` is not yet a terminal id `terminal.sendText`
accepts;
- the worker process model: Node version, lifecycle and the channel to Orca. Only
`activate(orca)` and `orca.api`, `orca.host`, `orca.commands`, `orca.events` and `orca.log` are
promised,
and workers may be stopped at any time;
- language-pack keys, which change when Orca's source moves or its English copy changes; packs
get best-effort coverage per release;
- `pnpm validate:plugin` output; only its exit codes (`0` valid, `1` invalid, `2` usage) are
stable;
- `settings:own`, which stores untyped values until plugins can declare settings;
- manifest format 1, which is deprecated. Catalogs need format 2.
Data folders, `orca-plugin-image://` URLs, tab keys and the `pluginRoot` a worker receives are
internal.
+160
View File
@@ -0,0 +1,160 @@
# Orca plugin catalog reference
<!-- Generated by `pnpm generate:plugin-schemas` from src/shared/plugins. Do not edit. -->
The orca-catalog.json file that lists installable plugins and their releases.
Add `"$schema": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schemas/plugins/orca-catalog.v2.schema.json"` to `orca-catalog.json` for completion and inline errors in your editor.
`Since` is the first Orca release that reads the field. Older releases ignore fields they do not know.
## Fields
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `$schema` | string | no | 1.4.245 | JSON Schema URL for editor completion. Ignored by Orca. |
| `catalogVersion` | `2` | yes | 1.4.245 | Catalog format. Raised only for a change older Orca cannot ignore; a newer Orca reads this one. |
| `name` | string | yes | 1.4.245 | Catalog display name. Example: `"Orca Plugins"` |
| `owner` | string | yes | 1.4.245 | Account that publishes the catalog. Example: `"stablyai"` |
| `plugins` | [CatalogEntry](#catalogentry)[] | yes | 1.4.245 | Listed plugins, at most 2048. Each `id` should appear once; Orca keeps the first. An entry this Orca cannot read is skipped, never fatal. |
## CatalogEntry
One installable plugin.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `id` | string | yes | 1.4.245 | `<publisher>.<id>`, matching the plugin's manifest. Each half is at most 64 characters, and the reserved names `constructor` and `prototype` are refused. Example: `"acme.gleam-syntax"` |
| `source` | [CatalogGitSource](#cataloggitsource) | yes | | Where the plugin source lives. |
| `description` | string | no | 1.4.245 | Listing text shown in the catalog. |
| `categories` | string[] | no | 1.4.245 | Lowercase slugs used to filter the catalog, each listed once. At most 16; extras are ignored. |
| `releases` | [CatalogRelease](#catalogrelease)[] | yes | 1.4.245 | Installable releases, newest first. Orca installs the first one whose `minimumOrca` it meets, so keep older releases listed when you raise `engines.orca`. At most 32. |
## CatalogGitSource
Where the plugin source lives.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `kind` | `"git"` | yes | 1.4.245 | Source kind. Only `git` is supported. |
| `url` | string | yes | 1.4.245 | HTTPS or SSH URL of the plugin repository. Example: `"https://github.com/acme/orca-gleam-syntax.git"` |
| `ref` | string | yes | 1.4.245 | Branch or tag the author publishes from. Each release pins an exact commit. Example: `"main"` |
| `path` | string | no | 1.4.245 | Directory inside the repository that holds `orca-plugin.json`, so one repository can publish many plugins. Use `/` separators, with no leading `/`, `.` or `..`. Omit when the plugin is the repository root. Example: `"plugins/gleam-syntax"` |
## CatalogRelease
The exact release the catalog vouches for. Orca refuses bytes that differ.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `version` | string | yes | 1.4.245 | The manifest's `version` for this release. Example: `"1.2.0"` |
| `commit` | string | yes | 1.4.245 | Full SHA-1 commit Orca checks out for this release. |
| `contentHash` | string | yes | 1.4.245 | SHA-256 of the plugin's installed files, as Orca computes it at install. |
| `execution` | `"data"` \| `"declarative"` \| `"sandboxed-ui"` \| `"trusted-worker"` | yes | 1.4.245 | The manifest's `execution` profile. Example: `"data"` |
| `minimumOrca` | string | yes | 1.4.245 | The manifest's `engines.orca` without `>=`. Older Orca lists the plugin but will not install it. Example: `"1.4.245"` |
| `types` | (`"interface-language"` \| `"app-theme"` \| `"terminal-theme"` \| `"icon-theme"` \| `"syntax"` \| `"instructions"` \| `"panel"` \| `"editor"` \| `"worker"`)[] | no | 1.4.245 | Optional; Orca derives it from `contributes` and `execution`. When present, every kind of contribution the release carries, matching its manifest exactly. `instructions` needs the `declarative` profile or higher, `panel` and `editor` need `sandboxed-ui`, and `worker` needs `trusted-worker`. Example: `["syntax"]` |
| `name` | string | no | 1.4.245 | The manifest's `name`, so Orca can suggest the plugin before downloading it. Example: `"Gleam"` |
| `license` | string | no | 1.4.245 | SPDX license expression. Example: `"MIT"` |
| `repository` | string | no | 1.4.245 | Source repository page, https only. Example: `"https://github.com/acme/orca-gleam-syntax"` |
| `homepage` | string | no | 1.4.245 | Project homepage, https only. Example: `"https://example.com/gleam"` |
| `updatedAt` | string | no | 1.4.245 | When this release was published, as an ISO 8601 date or date-time. Example: `"2026-10-20"` |
| `changelog` | string | no | 1.4.245 | What changed in this release, as plain text. At most 2000 characters. Example: `"Adds highlighting for use expressions."` |
| `images` | [CatalogImages](#catalogimages) | no | 1.4.245 | Images extracted from the release by catalog CI, which re-encodes them and strips metadata. Same rules as the manifest: icon PNG, WEBP, static, at least 128×128, square, at most 256 KB. Cover PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Screenshots PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Images must not imitate Orca's trust badges. |
| `themePreview` | [CatalogThemePreview](#catalogthemepreview)[] | no | 1.4.245 | Up to 4 theme palettes for listing art. Display only; the installed theme files decide the real colors. |
| `contributes` | [CatalogContribution](#catalogcontribution)[] | no | 1.4.245 | What the release adds, as the listing shows it. Generate it with the catalog tooling; Orca refuses an install whose manifest says otherwise. |
| `capabilities` | (`"workspace:read"` \| `"agent:read"` \| `"terminal:send"` \| `"notifications:show"` \| `"storage:own"` \| `"secrets:own"` \| `"settings:own"`)[] | no | 1.4.245 | Every capability the release asks for. Orca refuses an install whose manifest asks for different ones. Example: `["notifications:show"]` |
| `fileTypes` | string[] | no | 1.4.245 | Extensions, without the dot, that this release opens. Orca suggests the plugin when such a file has no viewer. |
| `editorFileTypes` | string[] | no | 1.4.245 | The `fileTypes` this release opens in a custom editor. Orca suggests the plugin for such a file even when it already opens it as text. |
## CatalogImages
Images extracted from the release by catalog CI, which re-encodes them and strips metadata. Same rules as the manifest: icon PNG, WEBP, static, at least 128×128, square, at most 256 KB. Cover PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Screenshots PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Images must not imitate Orca's trust badges.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `icon` | [CatalogImage](#catalogimage) | no | | An image stored in the catalog repository. |
| `cover` | [CatalogImage](#catalogimage) | no | | An image stored in the catalog repository. |
| `screenshots` | [CatalogImage](#catalogimage)[] | no | 1.4.245 | Screenshots, at most 6. |
## CatalogImage
An image stored in the catalog repository.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `path` | string | yes | 1.4.245 | File in the catalog repository, relative to its root. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"assets/acme.gleam/cover.png"` |
| `alt` | string | yes | 1.4.245 | What the image shows, for screen readers. Required. Example: `"The Dusk theme on a terminal"` |
| `sha256` | string | yes | 1.4.245 | SHA-256 of the file's bytes; Orca refuses bytes that differ. |
| `width` | integer | yes | 1.4.245 | Width in pixels. Example: `1600` |
| `height` | integer | yes | 1.4.245 | Height in pixels. Example: `1000` |
## CatalogThemePreview
Colors to draw a theme card before install.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `label` | string | yes | 1.4.245 | Theme name. Example: `"Dusk"` |
| `base` | `"light"` \| `"dark"` | yes | 1.4.245 | Light or dark theme. |
| `background` | string | yes | 1.4.245 | Window background. Example: `"#1b1430"` |
| `foreground` | string | yes | 1.4.245 | Main text. Example: `"#1b1430"` |
| `accent` | string | yes | 1.4.245 | Accent, such as buttons. Example: `"#1b1430"` |
| `surface` | string | yes | 1.4.245 | Cards and sidebars. Example: `"#1b1430"` |
## CatalogContribution
One thing the release adds to Orca.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `kind` | `"panels"` \| `"commands"` \| `"events"` \| `"languagePacks"` \| `"keybindings"` \| `"vmRecipes"` \| `"themes"` \| `"terminalThemes"` \| `"iconThemes"` \| `"syntaxes"` \| `"customEditors"` \| `"newFiles"` | yes | 1.4.245 | Contribution point. Example: `"themes"` |
| `name` | string | yes | 1.4.245 | What the item is called in Orca. Example: `"Dusk"` |
| `detail` | string | no | 1.4.245 | Extra context, such as file extensions. |
## Versions and compatibility
- **What only lists is tolerant.** A catalog never decides what runs (installing re-checks the exact bytes), so Orca keeps whatever it can read: unknown keys are ignored at every level, an entry it cannot read is skipped, an invalid display-only field is dropped, a list over its limit is trimmed, and a repeated `id` keeps the first entry. Validation in CI is where these become errors.
- **The file name never changes.** `catalogVersion` (now 2) rises only for a change older clients cannot ignore; an Orca that sees a higher number refuses the catalog with "update Orca to browse this catalog" rather than misreading it.
- **Releases are a list, newest first.** Orca offers the first release whose `minimumOrca` it meets, so keep the previous release listed when a new one raises `engines.orca`; Orca shows "Needs Orca X" only when none fits.
## Publisher names and trust
- **Publisher is the repository owner.** The official and community catalogs list a plugin only when its `publisher` equals the GitHub or GitLab user or organization that owns its repository (`acme` for `github.com/acme/gleam-syntax`). Their CI enforces this; Orca does not check it for catalogs you add yourself.
- **Reserved names install only from Orca's official catalog.** The publishers `stablyai`, `official`, `anthropic`, `openai`, `github` and any starting `orca` (any case), and ids starting `orca-`, are refused from any other catalog, a Git URL or a local folder. A catalog that lists one is refused whole. `pnpm validate:plugin` rejects these publishers except `stablyai`.
- **Trust comes from the catalog's address.** Orca labels a plugin with where it came from: the Official mark for Orca's catalog, otherwise the catalog's name and the owner in its Git URL (`github.com/acme`, `gitlab.com/acme/tools`), never the manifest's text. Development folders show `Dev · unverified`, Git URL installs `Unverified`.
- **An install stays with its catalog.** Orca records the catalog each plugin was installed from and updates it only from there. When another catalog lists the same id, the plugin page says so, and switching means removing the plugin and installing it again.
## One repository can hold many plugins
Give each plugin its own directory and set `source.path` to it. Orca downloads the pinned commit but installs only that directory, and `contentHash` covers only its files, so a commit that changes a sibling plugin leaves this plugin's hash, and its users, untouched. Moving a plugin to another directory counts as moving repositories: users review the update before it installs.
```json
{ "id": "acme.gleam-syntax", "source": { "kind": "git", "url": "https://github.com/acme/orca-plugins.git", "ref": "main", "path": "plugins/gleam-syntax" } }
```
Run `pnpm validate:plugin plugins/gleam-syntax` in that repository to print the directory's `path` and its release entry. Listing images stay relative to the catalog repository.
## Install links
An install link opens Orca at a plugin's page in Settings → Plugins. It never installs: the user reads the page and presses Install, and the usual review applies. If plugins are off, the page waits behind **Turn on plugins**.
```text
orca://plugins/<publisher>.<id>?catalog=<official | community | catalog Git URL>
```
- `catalog` defaults to `official`. `community` is Orca's community catalog. Any other value must be an HTTPS or SSH Git URL of at most 512 characters, read at its `main` branch, without a password or token. Orca asks **Add catalog …?** before fetching a catalog the user hasn't added, and that includes `community`.
- Reserved publishers (above) are accepted only with the official catalog. A link longer than 2048 characters, with another query parameter, or with an id that isn't `<publisher>.<id>` shows a short error and does nothing else. Repeats of the same link within a few seconds are ignored.
- Browsers and README viewers often block custom schemes, so link to the directory page instead. It opens the `orca://` link and shows install steps when Orca is missing: `https://onorca.dev/plugins/<publisher>.<id>?catalog=<same value>`.
For a README:
```md
[Open in Orca](https://onorca.dev/plugins/acme.gleam-syntax?catalog=community)
```
## Clients before format 2
Orca reads `orca-catalog.json` first and falls back to `orca-marketplace.json`. That older file is frozen: shipped clients reject any key they do not know, so it carries only `name`, `owner` and each plugin's `id`, `source`, `description` and `categories`, and leaves out plugins with a `source.path`, which those clients would install from the repository root. Generate it from `orca-catalog.json` rather than editing two files:
```sh
node config/scripts/export-legacy-plugin-catalog.mjs orca-catalog.json orca-marketplace.json
```
+147
View File
@@ -0,0 +1,147 @@
# Orca plugin host API reference
<!-- Generated by `pnpm generate:plugin-schemas` from src/shared/plugins. Do not edit. -->
Host API 1.0 (`"pluginApi": 1`). Workers call a method with `await orca.host.call(name, params)`; panels and custom editors call the same methods through `window.orca.host.call(name, params)`. Each method lists the surfaces allowed to call it; any other surface gets `not_permitted`. `orca.host.has(name)` answers synchronously whether this Orca offers a method on the current surface, and `orca.api` is `{ major, minor }`. See [Plugin authoring](./plugin-authoring.md) for the frame API.
Every call is checked against the capabilities the user approved, and params are validated before Orca runs anything: params are strict, so a field this Orca does not know is refused with `invalid_params` instead of being dropped. Mutations are recorded in the audit log as `plugin:<publisher>.<id>`.
## Methods
| Method | Capability | Surfaces | Mutation | Since | Summary |
| --- | --- | --- | --- | --- | --- |
| [`workspace.readContext`](#workspacereadcontext) | `workspace:read` | worker, panel, editor | no | 1.0 | Branch, name and terminal ids of the focused workspace, or null when none is focused. |
| [`terminal.sendText`](#terminalsendtext) | `terminal:send` | worker | yes | 1.0 | Types text into one terminal named by id, optionally pressing Enter. |
| [`notifications.show`](#notificationsshow) | `notifications:show` | worker, panel, editor | yes | 1.0 | Shows a desktop notification. |
| [`storage.get`](#storageget) | `storage:own` | worker | no | 1.0 | Reads a JSON value from the plugin's private store; null when absent. |
| [`storage.set`](#storageset) | `storage:own` | worker | yes | 1.0 | Writes a JSON value to the plugin's private store. |
| [`storage.delete`](#storagedelete) | `storage:own` | worker | yes | 1.0 | Removes a key from the plugin's private store. |
| [`storage.keys`](#storagekeys) | `storage:own` | worker | no | 1.0 | Lists the keys in the plugin's private store. |
| [`secrets.get`](#secretsget) | `secrets:own` | worker | no | 1.0 | Reads a secret from the plugin's encrypted secrets file; null when absent. |
| [`secrets.set`](#secretsset) | `secrets:own` | worker | yes | 1.0 | Stores a secret in the plugin's encrypted secrets file. |
| [`secrets.delete`](#secretsdelete) | `secrets:own` | worker | yes | 1.0 | Removes a secret from the plugin's encrypted secrets file. |
| [`settings.get`](#settingsget) | `settings:own` | worker | no | 1.0 | Reads every setting the plugin owns. |
| [`settings.set`](#settingsset) | `settings:own` | worker | yes | 1.0 | Writes one setting the plugin owns. |
### `workspace.readContext`
Branch, name and terminal ids of the focused workspace, or null when none is focused.
```ts
(params: {}) => { branch: string; displayName: string; terminals: { id: string }[] } | null
```
### `terminal.sendText`
Types text into one terminal named by id, optionally pressing Enter.
```ts
(params: { terminalId: string; text: string; enter?: boolean }) => { accepted: boolean }
```
### `notifications.show`
Shows a desktop notification.
```ts
(params: { title: string; body?: string }) => { delivered: boolean }
```
### `storage.get`
Reads a JSON value from the plugin's private store; null when absent.
```ts
(params: { key: string }) => { value: JSON }
```
### `storage.set`
Writes a JSON value to the plugin's private store.
```ts
(params: { key: string; value: JSON }) => { ok: true }
```
### `storage.delete`
Removes a key from the plugin's private store.
```ts
(params: { key: string }) => { ok: true }
```
### `storage.keys`
Lists the keys in the plugin's private store.
```ts
(params: {}) => { keys: string[] }
```
### `secrets.get`
Reads a secret from the plugin's encrypted secrets file; null when absent.
```ts
(params: { key: string }) => { value: string | null }
```
### `secrets.set`
Stores a secret in the plugin's encrypted secrets file.
```ts
(params: { key: string; value: string }) => { ok: true }
```
### `secrets.delete`
Removes a secret from the plugin's encrypted secrets file.
```ts
(params: { key: string }) => { ok: true }
```
### `settings.get`
Reads every setting the plugin owns.
```ts
(params: {}) => { settings: Record<string, JSON> }
```
### `settings.set`
Writes one setting the plugin owns.
```ts
(params: { key: string; value: JSON }) => { ok: true }
```
## Errors
A failed call rejects with an `Error` carrying `code`, `message`, `retryable` and, for `rate_limited`, `retryAfterMs`. Treat a code not listed here as `action_failed`:
- `invalid_request`: the call itself is malformed.
- `invalid_params`: params do not match the method, including a field this Orca does not know; the message names it.
- `unknown_method`: the method does not exist in this Orca.
- `capability_denied`: the manifest does not declare the capability.
- `consent_required`: the plugin is disabled or the user has not approved this version.
- `not_permitted`: the method or document is not available here, e.g. a worker-only method from a panel.
- `rate_limited`: calls arrive faster than the budget; `retryAfterMs` says when to try again.
- `unavailable`: the service behind the method is not running yet.
- `action_failed`: the method ran and failed.
- `cancelled`: the caller aborted the call through its `signal`.
- `timeout`: Orca gave up waiting.
- `conflict`: the call was made against a stale version, e.g. an editor edit after an outside change.
- `too_large`: the message is larger than Orca accepts.
## Stability and deprecation
Plugins are in Alpha: the manifest v2 format and catalog format are stable contracts; items listed as experimental in [Plugin authoring](./plugin-authoring.md#stability) may change.
- **Host API 1 is frozen.** Within `pluginApi` 1, methods, params and results only grow: a new method or a new optional param arrives with a higher `Since` minor, and nothing listed here is removed, renamed or narrowed.
- **Deprecation.** A method to be retired is marked deprecated in this table and keeps working for the rest of the major.
- **New majors.** A breaking change ships as a new `pluginApi` major beside the old one. Orca refuses a manifest whose `pluginApi` names a major it does not implement, with "update Orca", instead of loading it against the wrong API.
- **Detect, do not assume.** Check `orca.host.has(name)` or `orca.api.minor` before calling a method newer than the oldest Orca you support; a call that slips through fails with `unknown_method`.
+219
View File
@@ -0,0 +1,219 @@
# Orca plugin manifest reference
<!-- Generated by `pnpm generate:plugin-schemas` from src/shared/plugins. Do not edit. -->
The orca-plugin.json file at the root of an Orca plugin (manifest format 2).
Add `"$schema": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schemas/plugins/orca-plugin.v2.schema.json"` to `orca-plugin.json` for completion and inline errors in your editor.
`Since` is the first Orca release that reads the field; raise `engines.orca` when you use a newer one.
## Fields
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `$schema` | string | no | 1.4.245 | JSON Schema URL for editor completion. Ignored by Orca. |
| `manifestVersion` | `2` | yes | 1.4.245 | Manifest format. Use 2 for new plugins. |
| `execution` | `"data"` \| `"declarative"` \| `"sandboxed-ui"` \| `"trusted-worker"` | yes | 1.4.245 | Most authority the plugin needs, least first: `data` (themes, syntaxes, translations), `declarative` (commands, shortcuts, recipes), `sandboxed-ui` (panels), `trusted-worker` (`main` and events). Contributions beyond it fail validation. Example: `"data"` |
| `id` | string | yes | 1.4.160 | Plugin id, kebab-case. The install identity is `<publisher>.<id>`. The reserved names `constructor` and `prototype` are refused. Example: `"gleam-syntax"` |
| `publisher` | string | yes | 1.4.160 | Publisher slug, kebab-case. Must be the GitHub or GitLab user or organization that owns the repository (`acme` for `github.com/acme/gleam-syntax`), or the official and community catalogs reject the listing. The reserved names `constructor` and `prototype` are refused. The publishers `stablyai`, `official`, `anthropic`, `openai`, `github` and any starting `orca` (any case), and ids starting `orca-`, install only from Orca's official catalog. Example: `"acme"` |
| `name` | string | yes | 1.4.160 | Display name. Example: `"Gleam"` |
| `version` | string | yes | 1.4.160 | Plugin version, semver. Example: `"1.2.0"` |
| `description` | string | no | 1.4.160 | One or two sentences shown in the plugin list. |
| `author` | object | no | 1.4.160 | Who maintains the plugin. |
| `author.name` | string | yes | 1.4.160 | Author or organization name. Example: `"Acme"` |
| `author.url` | string | no | 1.4.160 | Author homepage. Example: `"https://example.com"` |
| `repository` | string | no | 1.4.160 | Source repository URL. Example: `"https://github.com/acme/orca-gleam-syntax"` |
| `license` | string | no | 1.4.245 | SPDX license expression. Example: `"MIT"` |
| `homepage` | string | no | 1.4.245 | Project homepage, https only. Example: `"https://example.com/gleam"` |
| `icon` | string | no | 1.4.160 | Square image shown beside the plugin name. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"assets/icon.png"` |
| `engines` | object | yes | 1.4.160 | Host versions this plugin supports. Older hosts refuse to load it. |
| `engines.orca` | string | yes | 1.4.160 | Oldest Orca release this plugin runs on, as `>=x.y.z`. Raise it to the newest "Since" of any field you use; a format 2 manifest needs at least `>=1.4.245`, because older Orca cannot read format 2. Example: `">=1.4.245"` |
| `main` | string | no | 1.4.160 | Node entry run in an out-of-process plugin worker. Requires the `trusted-worker` execution profile, and is required by it, by `contributes.events` and by any command without an `action`. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"dist/main.mjs"` |
| `contributes` | object | no | 1.4.160 | What the plugin adds to Orca. Every point defaults to empty. |
| `contributes.panels` | [PanelContribution](#panelcontribution)[] | no | 1.4.160 | Sidebar panels. At most 64. |
| `contributes.commands` | [CommandContribution](#commandcontribution)[] | no | 1.4.160 | Command palette entries. At most 256. |
| `contributes.events` | [EventContribution](#eventcontribution)[] | no | 1.4.160 | Events delivered to the plugin worker. At most 3. |
| `contributes.languagePacks` | [LanguagePackContribution](#languagepackcontribution)[] | no | 1.4.160 | Interface translations. At most 16. |
| `contributes.keybindings` | [KeybindingContribution](#keybindingcontribution)[] | no | 1.4.160 | Keyboard shortcuts for contributed commands. At most 256. |
| `contributes.vmRecipes` | [VmRecipeContribution](#vmrecipecontribution)[] | no | 1.4.160 | Virtual machine environment recipes. At most 64. |
| `contributes.themes` | [ThemeContribution](#themecontribution)[] | no | 1.4.245 | App color themes. At most 64. |
| `contributes.terminalThemes` | [ThemeContribution](#themecontribution)[] | no | 1.4.245 | Terminal ANSI palettes. At most 64. |
| `contributes.iconThemes` | [ThemeContribution](#themecontribution)[] | no | 1.4.245 | File icon themes. At most 32. |
| `contributes.syntaxes` | [SyntaxContribution](#syntaxcontribution)[] | no | 1.4.245 | Syntax highlighting for editor languages. At most 16. |
| `contributes.customEditors` | [CustomEditorContribution](#customeditorcontribution)[] | no | 1.4.245 | Editors that open files of given types in the editor tab. At most 16. |
| `contributes.newFiles` | [NewFileContribution](#newfilecontribution)[] | no | 1.4.245 | Untitled files the user can create from the + menu and the command palette. At most 16. |
| `contributes.agentHarnesses` | any[] | no | 1.4.245 | Reserved for coding-agent harness definitions; must be empty until their format is defined. |
| `contributes.skills` | any[] | no | 1.4.245 | Reserved for agent skills; must be empty until their format is defined. |
| `contributes.mcpServers` | any[] | no | 1.4.245 | Reserved for MCP server definitions; must be empty until their format is defined. |
| `contributes.hooks` | any[] | no | 1.4.245 | Reserved for agent hooks; must be empty until their format is defined. |
| `contributes.taskSources` | any[] | no | 1.4.245 | Reserved for task sources; must be empty until their format is defined. |
| `contributes.connections` | any[] | no | 1.4.245 | Reserved for account connections; must be empty until their format is defined. |
| `contributes.settings` | any[] | no | 1.4.245 | Reserved for plugin settings; must be empty until their format is defined. |
| `contributes.statusBarItems` | any[] | no | 1.4.245 | Reserved for status bar items; must be empty until their format is defined. |
| `contributes.sidebarCards` | any[] | no | 1.4.245 | Reserved for sidebar cards; must be empty until their format is defined. |
| `capabilities` | [Capability](#capability)[] | no | 1.4.160 | Host APIs the plugin asks the user to grant. At most 32. |
| `cover` | object | no | 1.4.245 | Wide image at the top of the plugin page. |
| `cover.path` | string | yes | 1.4.245 | Cover image shown on the plugin page, 16:10 recommended (1600×1000). PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/cover.png"` |
| `cover.alt` | string | yes | 1.4.245 | What the image shows, for screen readers. Required. Example: `"The Dusk theme on a terminal"` |
| `screenshots` | [Screenshot](#screenshot)[] | no | 1.4.245 | Screenshots for the plugin page, at most 6. All images together may be at most 8 MB. |
| `pluginApi` | `1` | no | 1.4.160 | Major version of the plugin host API this plugin targets. Optional (defaults to 1), and refused on `data` and `declarative` plugins, which never call it. |
## PanelContribution
A sidebar panel. Requires the `sandboxed-ui` execution profile or higher.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `id` | string | yes | 1.4.160 | Panel id, unique among this plugin's panels. The reserved names `constructor` and `prototype` are refused. Example: `"dashboard"` |
| `title` | string | yes | 1.4.160 | Title shown on the panel tab and its tooltip. Example: `"Dashboard"` |
| `icon` | `"activity"` \| `"bar-chart-3"` \| `"bell"` \| `"blocks"` \| `"book"` \| `"bot"` \| `"bug"` \| `"calendar"` \| `"cloud"` \| `"code"` \| `"database"` \| `"file-text"` \| `"flag"` \| `"folder"` \| `"gauge"` \| `"globe"` \| `"hammer"` \| `"layers"` \| `"lightbulb"` \| `"package"` \| `"plug"` \| `"puzzle"` \| `"rocket"` \| `"star"` \| `"terminal"` \| `"wrench"` \| `"zap"` | no | 1.4.160 | Icon shown in the right-sidebar activity bar. Defaults to `plug`. Example: `"database"` |
| `entry` | string | yes | 1.4.160 | HTML file, relative to the plugin root, rendered inside a sandboxed panel frame. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"dist/panel.html"` |
## CommandContribution
A command palette entry. Requires the `declarative` execution profile or higher.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `id` | string | yes | 1.4.160 | Command id, unique among this plugin's commands. Dotted namespaces are allowed. Example: `"jupyter.restartKernel"` |
| `title` | string | yes | 1.4.160 | Title shown in the command palette. Example: `"Restart kernel"` |
| `when` | `"global"` \| `"workspace"` | no | 1.4.160 | Where the command is available. `workspace` hides it when no workspace is active. Defaults to `global`. |
| `action` | `"worktree.history.back"` \| `"worktree.history.forward"` \| `"sidebar.left.toggle"` \| `"sidebar.sleepingWorkspaces.toggle"` \| `"floatingWorkspace.maximize"` \| `"tab.rename"` \| `"workspace.rename"` \| `"workspace.openBoard"` \| `"view.tasks"` \| `"sidebar.right.toggle"` \| `"sidebar.explorer.toggle"` \| `"sidebar.search.toggle"` \| `"sidebar.sourceControl.toggle"` \| `"sidebar.checks.toggle"` \| `"sidebar.ports.toggle"` | no | 1.4.160 | Built-in Orca action this command runs. A command with an action is declarative and never starts the plugin worker; one without needs `main`. Example: `"view.tasks"` |
## EventContribution
An event subscription. Requires `main`, the `trusted-worker` execution profile and the capability of the event's domain: `workspace:read` for workspace events, `agent:read` for agent events.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `on` | `"workspace.created"` \| `"workspace.removed"` \| `"agent.status.changed"` | yes | 1.4.160 | Event delivered to the plugin worker. |
## LanguagePackContribution
An interface translation.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `locale` | string | yes | 1.4.160 | BCP 47 locale the pack translates Orca into. Each locale appears once, ignoring case. Example: `"pt-BR"` |
| `path` | string | yes | 1.4.160 | JSON file of translated interface strings. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"locales/pt-BR.json"` |
| `displayName` | string | no | 1.4.245 | Native-language name for the Settings picker. Omitted packs show `{locale} — {pluginKey}`. Example: `"Português (Brasil)"` |
## KeybindingContribution
A keyboard shortcut for a command.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `command` | string | yes | 1.4.160 | Id of a command this plugin contributes in `contributes.commands`. Example: `"jupyter.restartKernel"` |
| `key` | string | yes | 1.4.160 | Key chord Orca recognizes, such as `Mod+Shift+K`. `Mod` is Cmd on macOS and Ctrl elsewhere. Each chord may be bound once on every platform. Example: `"Mod+Shift+K"` |
| `when` | `"global"` \| `"workspace"` | no | 1.4.160 | Where the shortcut is active; must match its command's `when`. |
## VmRecipeContribution
A virtual machine environment recipe.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `path` | string | yes | 1.4.160 | JSON environment recipe file, listed once. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"recipes/ubuntu.json"` |
## ThemeContribution
A titled theme file.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `id` | string | yes | 1.4.245 | Theme id, unique within its family in this plugin. The reserved names `constructor` and `prototype` are refused. Example: `"dusk"` |
| `title` | string | yes | 1.4.245 | Name shown in the theme picker. Example: `"Dusk"` |
| `path` | string | yes | 1.4.245 | JSON theme file, at most 16 KiB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"themes/dusk.json"` |
## SyntaxContribution
Syntax highlighting for one language.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `id` | string | yes | 1.4.245 | Language id, unique among this plugin's syntaxes. The reserved names `constructor` and `prototype` are refused. Example: `"gleam"` |
| `title` | string | yes | 1.4.245 | Language name shown in the editor. Example: `"Gleam"` |
| `scopeName` | string | yes | 1.4.245 | The grammar's root TextMate scope, unique among this plugin's syntaxes. Example: `"source.gleam"` |
| `path` | string | yes | 1.4.245 | TextMate grammar JSON file. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"syntaxes/gleam.tmLanguage.json"` |
| `configuration` | string | no | 1.4.245 | Language configuration JSON file: comments, brackets and auto-closing pairs. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"syntaxes/language-configuration.json"` |
| `extensions` | string[] | no | 1.4.245 | File-name suffixes, with the dot, this language opens, ignoring case. A longer suffix such as `.excalidraw.json` wins over a shorter one, and a file named only the suffix never matches. At most 32. Example: `[".gleam"]` |
| `filenames` | string[] | no | 1.4.245 | Exact file names this language opens, ignoring case; a name beats any extension. Names must be portable: no path separators, `.`, `..` or characters Windows forbids. At most 32. Example: `["gleam.toml"]` |
## CustomEditorContribution
An editor for the listed files, shown in the editor tab beside Source. It must list at least one extension or file name. Requires the `sandboxed-ui` execution profile or higher.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `id` | string | yes | 1.4.245 | Editor id, unique among this plugin's custom editors. The reserved names `constructor` and `prototype` are refused. Example: `"excalidraw"` |
| `title` | string | yes | 1.4.245 | Name shown in the editor's view toggle beside Source, and on the tab of a file it opens. Example: `"Excalidraw"` |
| `icon` | string | no | 1.4.245 | Square image shown on the tab of a file this editor opens. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/editor-icon.png"` |
| `extensions` | string[] | no | 1.4.245 | File-name suffixes, with the dot, this editor opens, ignoring case. A longer suffix such as `.excalidraw.json` wins over a shorter one, and a file named only the suffix never matches. At most 32. Example: `[".excalidraw",".excalidraw.json"]` |
| `filenames` | string[] | no | 1.4.245 | Exact file names this editor opens, ignoring case; a name beats any extension. Names must be portable: no path separators, `.`, `..` or characters Windows forbids. At most 32. Example: `[]` |
| `entry` | string | yes | 1.4.245 | HTML file rendered inside a sandboxed editor frame, like a panel. It receives the open document and posts edits back; it never gets a file path or file system. It is trusted code: install editors you trust. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"editor/index.html"` |
| `priority` | `"default"` \| `"option"` | no | 1.4.245 | `default` (the default) opens matching files in this editor; `option` only offers it in the view toggle, so files open in Source. Files Orca opens itself (such as `.json`, `.ts`, `.md`, `.csv`, `.yaml`, lockfiles and `.env` files) always behave as `option`. Example: `"default"` |
| `contentKind` | `"text"` \| `"bytes"` | no | 1.4.245 | `text` (the default) sends the file as text and accepts full-text edits. `bytes` sends the file's raw bytes, for binary formats such as spreadsheets, and accepts full-byte edits. Example: `"bytes"` |
## NewFileContribution
A kind of untitled file the user can create from the tab bar + menu or the command palette, in the active workspace.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `id` | string | yes | 1.4.245 | New-file id, unique among this plugin's new files. The reserved names `constructor` and `prototype` are refused. Example: `"drawing"` |
| `title` | string | yes | 1.4.245 | Menu item shown in the tab bar + menu and the command palette. Example: `"New Excalidraw drawing"` |
| `extension` | string | yes | 1.4.245 | Suffix, with the dot, of the untitled file it creates. The file opens in whatever editor takes that suffix, such as a custom editor from this plugin. Example: `".excalidraw"` |
| `template` | string | no | 1.4.245 | UTF-8 file copied into the new file, at most 256 KB. Without one the file starts empty. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"templates/empty.excalidraw"` |
| `icon` | string | no | 1.4.245 | Square image shown beside the menu item. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/editor-icon.png"` |
## Capability
A host API the plugin may call once the user consents.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `kind` | `"workspace:read"` \| `"agent:read"` \| `"terminal:send"` \| `"notifications:show"` \| `"storage:own"` \| `"secrets:own"` \| `"settings:own"` | yes | 1.4.160 | Capability granted on user consent. |
## Screenshot
One screenshot of the plugin in use.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `path` | string | yes | 1.4.245 | Screenshot, 16:10 recommended (up to 2560×1600). PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/editor.png"` |
| `alt` | string | yes | 1.4.245 | What the image shows, for screen readers. Required. Example: `"The Dusk theme on a terminal"` |
| `caption` | string | no | 1.4.245 | Short caption under the screenshot. Example: `"Gleam in the editor"` |
| `theme` | `"light"` \| `"dark"` | no | 1.4.245 | Show this screenshot only with a light or dark Orca theme. |
## Versions and compatibility
- **What runs is strict.** Orca refuses a manifest with any key it does not know, at every level, so an older Orca never silently ignores something a plugin relies on.
- **`engines.orca` is the only version gate.** It must be at least the newest `Since` of every field and every value (capability, event, built-in action) the manifest uses. Orca checks this when it validates, lists or installs a plugin, so an older Orca can always say "requires Orca >=X (this is Y)" instead of listing unknown fields.
- **`pluginApi` is optional.** It names the major version of the host API ([reference](plugin-host-api.md)), which only grows within major 1; Orca refuses a major it does not implement. Only `sandboxed-ui` and `trusted-worker` plugins call that API, so `data` and `declarative` plugins must leave it out.
- **`terminal:send` needs `trusted-worker`.** Panels get no host method that types into a terminal, so Orca refuses the capability on any other profile instead of asking the user to grant something the plugin can never use.
- **New fields, contribution points and values are additive.** They arrive with a `Since` version; nothing else changes. `manifestVersion` rises only if an existing field changes meaning.
- **Deprecation.** A field to be removed is marked deprecated here and keeps working for at least one release; it is removed only together with a higher `engines.orca` requirement, and its name is never reused.
- **Reserved contribution points.** `contributes.agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `settings`, `statusBarItems` and `sidebarCards` are held for planned features and must stay empty until their format is defined. Each will arrive as an additive point with its own `Since`, without a new manifest format. `skills`, `mcpServers` and `hooks` keep the meaning agent-plugin formats such as Claude Code's give them; `agents` stays unclaimed because those formats use it for subagent files.
## Planned contribution points
Each reserved point is planned as declared data that Orca renders and enforces, never arbitrary plugin UI. The intended shapes, which may still change:
- `taskSources`: Issue and task providers such as Asana, Linear or Jira, declared as data that a worker fills with tasks.
- `connections`: Account providers with a host-run sign-in, so a plugin never handles credentials itself.
- `agentHarnesses`: New coding-agent harnesses described as data: how to launch them and read their status.
- `statusBarItems`: Declarative status bar widgets (label, icon, tooltip) whose values a worker updates.
- `sidebarCards`: Declarative left-sidebar cards with fixed layouts whose values a worker updates.
- `mcpServers`: MCP server definitions that Orca offers to the agents it launches.
- `skills`: Agent skills shipped as files that Orca installs for the agents it launches.
- `hooks`: Agent lifecycle hooks, such as before and after a tool call, for the agents Orca launches.
- `settings`: Typed plugin settings that Orca renders in its own settings UI.
## Legacy format 1
Orca still loads `"manifestVersion": 1` manifests (read since 1.4.160), but new plugins should use format 2. Format 1 differs in three ways:
- It has no `execution` field. Orca derives the least profile that can run what the manifest declares.
- Unknown top-level keys are ignored instead of rejected.
- The published JSON Schema does not describe it, so editors offer no completion for it.
+42
View File
@@ -0,0 +1,42 @@
# Plugin syntax highlighting
A plugin adds highlighting for a language through `contributes.syntaxes` in `orca-plugin.json`:
```json
{
"id": "typst",
"label": "Typst",
"scopeName": "source.typst",
"path": "syntaxes/typst.tmLanguage.json",
"configuration": "syntaxes/language-configuration.json",
"extensions": [".typ"],
"filenames": []
}
```
- `path` is a TextMate grammar in JSON (at most 1 MiB) whose `scopeName` matches the entry.
Keys the TextMate engine does not use are dropped.
- `include` may name `#repository` entries, `$self`, `$base`, or another `syntaxes` scope in the
same plugin; those must resolve when the plugin is approved, or its card shows the error. An
include of a scope outside the plugin (for example an embedded `source.js`) is allowed but
resolves to nothing, so that region stays plain.
- `configuration` (optional, at most 64 KiB) sets comment markers, brackets, auto-closing and
surrounding pairs.
- A plugin can contribute up to 16 syntaxes. Scope names must be unique within the plugin.
## How Orca uses it
- A plugin syntax applies only to files Orca would otherwise show as plain text. An exact file
name wins over an extension, and a longer extension wins over a shorter one. There is no
per-file override yet, so a plugin that claims a broad extension such as `.txt` highlights every
such file while it is enabled.
- Grammars load when a matching file opens, compile once in a worker, and stay warm while you
type. Each line has the same limits as Orca's own grammars (1,000 characters, 50 ms); from a
line that exceeds them, the rest of the file stays plain.
- Highlighting is applied as Monaco semantic tokens, so while a new pass runs the previous colors
stay in place, shifted by your edits.
- A worker that stops reporting progress for 2 seconds or crashes is terminated, the language stays
plain, and the plugin card says highlighting stopped.
- Files over 500,000 characters or 20,000 lines stay plain.
- Highlighting follows the plugin live: enabling, updating or disabling it re-highlights open
files without a reload.
+45
View File
@@ -0,0 +1,45 @@
# Orca app theme tokens
<!-- Generated by `pnpm generate:plugin-schemas` from src/shared/plugins. Do not edit. -->
Every key an app theme may set in `tokens` (see [plugin themes](plugin-themes.md)). Any other key is refused. An omitted token falls back as listed; when that source is omitted too, it falls back the same way in turn.
| Token | Required | When omitted |
| --- | --- | --- |
| `--background` | yes | |
| `--editor-surface` | no | same as `--background` |
| `--foreground` | yes | |
| `--card` | no | same as `--background` |
| `--card-foreground` | no | same as `--foreground` |
| `--popover` | no | same as `--card` |
| `--popover-foreground` | no | same as `--card-foreground` |
| `--primary` | no | same as `--foreground` |
| `--primary-foreground` | no | same as `--background` |
| `--secondary` | no | same as `--muted` |
| `--secondary-foreground` | no | same as `--foreground` |
| `--muted` | no | same as `--secondary` |
| `--muted-foreground` | no | `--foreground` mixed 70% into `--background` |
| `--accent` | no | `--foreground` mixed 12% into `--background` |
| `--accent-foreground` | no | same as `--foreground` |
| `--border` | no | `--foreground` mixed 16% into `--background` |
| `--input` | no | same as `--border` |
| `--ring` | no | same as `--primary` |
| `--chart-1` | no | same as `--primary` |
| `--chart-2` | no | Orca's built-in color |
| `--chart-3` | no | Orca's built-in color |
| `--chart-4` | no | Orca's built-in color |
| `--chart-5` | no | Orca's built-in color |
| `--sidebar` | no | `--foreground` mixed 8% into `--background` |
| `--sidebar-foreground` | no | same as `--foreground` |
| `--sidebar-primary` | no | same as `--primary` |
| `--sidebar-primary-foreground` | no | same as `--primary-foreground` |
| `--sidebar-accent` | no | same as `--accent` |
| `--sidebar-accent-foreground` | no | same as `--accent-foreground` |
| `--sidebar-border` | no | same as `--border` |
| `--sidebar-ring` | no | same as `--ring` |
| `--worktree-sidebar` | no | same as `--sidebar` |
| `--worktree-sidebar-foreground` | no | same as `--sidebar-foreground` |
| `--worktree-sidebar-accent` | no | same as `--sidebar-accent` |
| `--worktree-sidebar-accent-foreground` | no | same as `--sidebar-accent-foreground` |
| `--worktree-sidebar-border` | no | same as `--sidebar-border` |
| `--worktree-sidebar-ring` | no | same as `--sidebar-ring` |
+51 -7
View File
@@ -7,16 +7,24 @@ Each entry is `{ "id", "label", "path" }` pointing at a JSON file of at most 16
## App themes
```json
{ "base": "dark", "tokens": { "--background": "#1b1430", "--foreground": "#e8e0ff" } }
{
"schemaVersion": 1,
"base": "dark",
"tokens": { "--background": "#1b1430", "--foreground": "#e8e0ff" }
}
```
- `base` (`light` or `dark`) becomes the app's light/dark mode while the theme is selected.
- `schemaVersion` is optional for app themes and must be `1` when present.
- `base` (`light` or `dark`) picks the slot the theme fills: users choose a Light theme and a
Dark theme separately, and Orca paints the one for its current mode (following the OS under
System). Contribute one theme of each base to ship a pair; installing it fills both slots.
- `--background` and `--foreground` are required. Every other public token is optional; an
omitted token is derived from the ones you set (cards and sidebars from `--background`, text
from `--foreground`, sidebar accents from `--accent`, rings from `--primary`, and so on), so a
two-token theme still colors the whole app.
- The public tokens are `PLUGIN_APP_THEME_TOKENS` in
`src/shared/plugins/plugin-theme-artifact.ts`. Other variables cannot be set.
omitted token is derived from the ones you set (cards from `--background`, sidebars lifted a
step off `--background` so the left sidebar keeps a visible edge, text from `--foreground`,
sidebar accents from `--accent`, rings from `--primary`, and so on), so a two-token theme still
colors the whole app.
- [Theme tokens](plugin-theme-tokens.md) lists every public token and what it falls back to
when omitted. Other variables cannot be set.
- Values must be colors: hex, a color function (`rgb`, `hsl`, `oklch`, …) or a color name.
`transparent`, `currentColor`, CSS-wide keywords, lengths, `calc()`, `var()` and `url()` are
refused.
@@ -24,6 +32,42 @@ Each entry is `{ "id", "label", "path" }` pointing at a JSON file of at most 16
`--primary`/`--primary-foreground`) must be opaque with at least 4.5:1 contrast; a pair that
fails is dropped and keeps Orca's built-in colors.
- Trust and approval dialogs always use Orca's own colors, whatever theme is active.
- `pnpm validate:plugin` refuses unknown keys, tokens and slots in every theme file. Orca itself
skips them, so a theme that sets a token added in a newer release still loads on older ones.
### Code colors
An app theme also colors the code editor: its background, text, line numbers, cursor and
selection follow the theme's tokens. To color code itself, add `tokenColors` (Orca 1.4.245 and
later) in the same shape as a VS Code theme's, so most VS Code theme lists paste in unchanged (`validate:plugin` flags entries without a `scope` and any setting other than these two):
```json
{
"base": "dark",
"tokens": { "--background": "#1b1430", "--foreground": "#e8e0ff" },
"tokenColors": [
{ "scope": "comment", "settings": { "foreground": "#8f84ad", "fontStyle": "italic" } },
{ "scope": ["keyword", "storage"], "settings": { "foreground": "#ff7ad9" } },
{ "scope": "string", "settings": { "foreground": "#a8e6a1" } },
{ "scope": "constant.numeric", "settings": { "foreground": "#ffb86c" } },
{ "scope": "entity.name.function", "settings": { "foreground": "#82cfff" } },
{ "scope": "markup.heading", "settings": { "foreground": "#ff7ad9", "fontStyle": "bold" } }
]
}
```
- `scope` is a TextMate scope or a list of them (a comma-separated string works too). Scopes
follow the [TextMate naming convention](https://macromates.com/manual/en/language_grammars#naming_conventions):
`comment`, `string`, `constant.numeric`, `keyword.control`, `keyword.operator`,
`entity.name.function`, `entity.name.type`, `variable`, `punctuation`, `markup.heading`,
`markup.underline.link`, `invalid`, and so on. A scope also colors everything beneath it
(`keyword` covers `keyword.control.flow`); the longest match wins, then the later rule.
- `settings.foreground` is a hex color; `settings.fontStyle` is `italic`, `bold`, `underline`,
`strikethrough`, a space-separated combination, or `""` for none. `name` is allowed and ignored.
- Orca applies them to its built-in languages and to plugin syntaxes alike. Selectors with
spaces (descendant scopes) and `semanticTokenColors` are not read.
- A color under 3:1 contrast with the editor background is dropped, and anything a theme leaves
out keeps the built-in light or dark code colors.
## Terminal themes
@@ -0,0 +1,579 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schemas/plugins/orca-catalog.v2.schema.json",
"title": "Orca plugin catalog",
"description": "The orca-catalog.json file that lists installable plugins and their releases.",
"type": "object",
"properties": {
"$schema": {
"description": "JSON Schema URL for editor completion. Ignored by Orca.",
"x-orca-since": "1.4.245",
"type": "string",
"maxLength": 2048
},
"catalogVersion": {
"type": "number",
"const": 2,
"description": "Catalog format. Raised only for a change older Orca cannot ignore; a newer Orca reads this one.",
"x-orca-since": "1.4.245"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "Catalog display name.",
"x-orca-since": "1.4.245",
"examples": [
"Orca Plugins"
]
},
"owner": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$",
"description": "Account that publishes the catalog.",
"x-orca-since": "1.4.245",
"examples": [
"stablyai"
]
},
"plugins": {
"maxItems": 2048,
"type": "array",
"items": {
"$ref": "#/$defs/CatalogEntry"
},
"description": "Listed plugins, at most 2048. Each `id` should appear once; Orca keeps the first. An entry this Orca cannot read is skipped, never fatal.",
"x-orca-since": "1.4.245"
}
},
"required": [
"catalogVersion",
"name",
"owner",
"plugins"
],
"$defs": {
"CatalogEntry": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*\\.[a-z0-9]+(?:-[a-z0-9]+)*$",
"description": "`<publisher>.<id>`, matching the plugin's manifest. Each half is at most 64 characters, and the reserved names `constructor` and `prototype` are refused.",
"x-orca-since": "1.4.245",
"examples": [
"acme.gleam-syntax"
]
},
"source": {
"$ref": "#/$defs/CatalogGitSource"
},
"description": {
"description": "Listing text shown in the catalog.",
"x-orca-since": "1.4.245",
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"categories": {
"description": "Lowercase slugs used to filter the catalog, each listed once. At most 16; extras are ignored.",
"x-orca-since": "1.4.245",
"uniqueItems": true,
"maxItems": 16,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
}
},
"releases": {
"description": "Installable releases, newest first. Orca installs the first one whose `minimumOrca` it meets, so keep older releases listed when you raise `engines.orca`. At most 32.",
"x-orca-since": "1.4.245",
"minItems": 1,
"maxItems": 32,
"type": "array",
"items": {
"$ref": "#/$defs/CatalogRelease"
}
}
},
"required": [
"id",
"source",
"releases"
],
"description": "One installable plugin."
},
"CatalogGitSource": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "git",
"description": "Source kind. Only `git` is supported.",
"x-orca-since": "1.4.245"
},
"url": {
"type": "string",
"minLength": 1,
"maxLength": 32768,
"description": "HTTPS or SSH URL of the plugin repository.",
"x-orca-since": "1.4.245",
"examples": [
"https://github.com/acme/orca-gleam-syntax.git"
]
},
"ref": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Branch or tag the author publishes from. Each release pins an exact commit.",
"x-orca-since": "1.4.245",
"examples": [
"main"
]
},
"path": {
"description": "Directory inside the repository that holds `orca-plugin.json`, so one repository can publish many plugins. Use `/` separators, with no leading `/`, `.` or `..`. Omit when the plugin is the repository root.",
"x-orca-since": "1.4.245",
"examples": [
"plugins/gleam-syntax"
],
"type": "string",
"maxLength": 1024,
"pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:\\/[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$"
}
},
"required": [
"kind",
"url",
"ref"
],
"description": "Where the plugin source lives."
},
"CatalogRelease": {
"type": "object",
"properties": {
"version": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "The manifest's `version` for this release.",
"x-orca-since": "1.4.245",
"examples": [
"1.2.0"
]
},
"commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$",
"description": "Full SHA-1 commit Orca checks out for this release.",
"x-orca-since": "1.4.245"
},
"contentHash": {
"type": "string",
"pattern": "^[0-9a-f]{64}$",
"description": "SHA-256 of the plugin's installed files, as Orca computes it at install.",
"x-orca-since": "1.4.245"
},
"execution": {
"type": "string",
"enum": [
"data",
"declarative",
"sandboxed-ui",
"trusted-worker"
],
"description": "The manifest's `execution` profile.",
"x-orca-since": "1.4.245",
"examples": [
"data"
]
},
"minimumOrca": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "The manifest's `engines.orca` without `>=`. Older Orca lists the plugin but will not install it.",
"x-orca-since": "1.4.245",
"examples": [
"1.4.245"
]
},
"types": {
"description": "Optional; Orca derives it from `contributes` and `execution`. When present, every kind of contribution the release carries, matching its manifest exactly. `instructions` needs the `declarative` profile or higher, `panel` and `editor` need `sandboxed-ui`, and `worker` needs `trusted-worker`.",
"x-orca-since": "1.4.245",
"examples": [
[
"syntax"
]
],
"uniqueItems": true,
"minItems": 1,
"maxItems": 9,
"type": "array",
"items": {
"type": "string",
"enum": [
"interface-language",
"app-theme",
"terminal-theme",
"icon-theme",
"syntax",
"instructions",
"panel",
"editor",
"worker"
]
}
},
"name": {
"description": "The manifest's `name`, so Orca can suggest the plugin before downloading it.",
"x-orca-since": "1.4.245",
"examples": [
"Gleam"
],
"type": "string",
"minLength": 1,
"maxLength": 128
},
"license": {
"description": "SPDX license expression.",
"x-orca-since": "1.4.245",
"examples": [
"MIT"
],
"type": "string",
"maxLength": 128,
"pattern": "^\\(?[A-Za-z0-9][A-Za-z0-9.+-]*(?: (?:AND|OR|WITH) \\(?[A-Za-z0-9][A-Za-z0-9.+-]*\\)?)*\\)?$"
},
"repository": {
"description": "Source repository page, https only.",
"x-orca-since": "1.4.245",
"examples": [
"https://github.com/acme/orca-gleam-syntax"
],
"type": "string",
"maxLength": 2048,
"pattern": "^https:\\/\\/\\S+$"
},
"homepage": {
"description": "Project homepage, https only.",
"x-orca-since": "1.4.245",
"examples": [
"https://example.com/gleam"
],
"type": "string",
"maxLength": 2048,
"pattern": "^https:\\/\\/\\S+$"
},
"updatedAt": {
"description": "When this release was published, as an ISO 8601 date or date-time.",
"x-orca-since": "1.4.245",
"examples": [
"2026-10-20"
],
"anyOf": [
{
"type": "string",
"format": "date",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$"
},
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
}
]
},
"changelog": {
"description": "What changed in this release, as plain text. At most 2000 characters.",
"x-orca-since": "1.4.245",
"examples": [
"Adds highlighting for use expressions."
],
"type": "string",
"minLength": 1,
"maxLength": 2000
},
"images": {
"description": "Icon, cover and screenshots for the listing, shown before anything is downloaded.",
"x-orca-since": "1.4.245",
"$ref": "#/$defs/CatalogImages"
},
"themePreview": {
"description": "Up to 4 theme palettes for listing art. Display only; the installed theme files decide the real colors.",
"x-orca-since": "1.4.245",
"maxItems": 4,
"type": "array",
"items": {
"$ref": "#/$defs/CatalogThemePreview"
}
},
"contributes": {
"description": "What the release adds, as the listing shows it. Generate it with the catalog tooling; Orca refuses an install whose manifest says otherwise.",
"x-orca-since": "1.4.245",
"maxItems": 64,
"type": "array",
"items": {
"$ref": "#/$defs/CatalogContribution"
}
},
"capabilities": {
"description": "Every capability the release asks for. Orca refuses an install whose manifest asks for different ones.",
"x-orca-since": "1.4.245",
"examples": [
[
"notifications:show"
]
],
"maxItems": 7,
"type": "array",
"items": {
"type": "string",
"enum": [
"workspace:read",
"agent:read",
"terminal:send",
"notifications:show",
"storage:own",
"secrets:own",
"settings:own"
]
}
},
"fileTypes": {
"description": "Extensions, without the dot, that this release opens. Orca suggests the plugin when such a file has no viewer.",
"x-orca-since": "1.4.245",
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9+_-]{0,15}$"
}
},
"editorFileTypes": {
"description": "The `fileTypes` this release opens in a custom editor. Orca suggests the plugin for such a file even when it already opens it as text.",
"x-orca-since": "1.4.245",
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9+_-]{0,15}$"
}
}
},
"required": [
"version",
"commit",
"contentHash",
"execution",
"minimumOrca"
],
"description": "The exact release the catalog vouches for. Orca refuses bytes that differ."
},
"CatalogImages": {
"type": "object",
"properties": {
"icon": {
"$ref": "#/$defs/CatalogImage"
},
"cover": {
"$ref": "#/$defs/CatalogImage"
},
"screenshots": {
"description": "Screenshots, at most 6.",
"x-orca-since": "1.4.245",
"maxItems": 6,
"type": "array",
"items": {
"$ref": "#/$defs/CatalogImage"
}
}
},
"additionalProperties": false,
"description": "Images extracted from the release by catalog CI, which re-encodes them and strips metadata. Same rules as the manifest: icon PNG, WEBP, static, at least 128×128, square, at most 256 KB. Cover PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Screenshots PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Images must not imitate Orca's trust badges."
},
"CatalogImage": {
"type": "object",
"properties": {
"path": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$",
"description": "File in the catalog repository, relative to its root. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.",
"x-orca-since": "1.4.245",
"examples": [
"assets/acme.gleam/cover.png"
]
},
"alt": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "What the image shows, for screen readers. Required.",
"x-orca-since": "1.4.245",
"examples": [
"The Dusk theme on a terminal"
]
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$",
"description": "SHA-256 of the file's bytes; Orca refuses bytes that differ.",
"x-orca-since": "1.4.245"
},
"width": {
"type": "integer",
"minimum": 1,
"maximum": 4096,
"description": "Width in pixels.",
"x-orca-since": "1.4.245",
"examples": [
1600
]
},
"height": {
"type": "integer",
"minimum": 1,
"maximum": 4096,
"description": "Height in pixels.",
"x-orca-since": "1.4.245",
"examples": [
1000
]
}
},
"required": [
"path",
"alt",
"sha256",
"width",
"height"
],
"additionalProperties": false,
"description": "An image stored in the catalog repository."
},
"CatalogThemePreview": {
"type": "object",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "Theme name.",
"x-orca-since": "1.4.245",
"examples": [
"Dusk"
]
},
"base": {
"type": "string",
"enum": [
"light",
"dark"
],
"description": "Light or dark theme.",
"x-orca-since": "1.4.245"
},
"background": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
"description": "Window background.",
"x-orca-since": "1.4.245",
"examples": [
"#1b1430"
]
},
"foreground": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
"description": "Main text.",
"x-orca-since": "1.4.245",
"examples": [
"#1b1430"
]
},
"accent": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
"description": "Accent, such as buttons.",
"x-orca-since": "1.4.245",
"examples": [
"#1b1430"
]
},
"surface": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
"description": "Cards and sidebars.",
"x-orca-since": "1.4.245",
"examples": [
"#1b1430"
]
}
},
"required": [
"label",
"base",
"background",
"foreground",
"accent",
"surface"
],
"additionalProperties": false,
"description": "Colors to draw a theme card before install."
},
"CatalogContribution": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"panels",
"commands",
"events",
"languagePacks",
"keybindings",
"vmRecipes",
"themes",
"terminalThemes",
"iconThemes",
"syntaxes",
"customEditors",
"newFiles"
],
"description": "Contribution point.",
"x-orca-since": "1.4.245",
"examples": [
"themes"
]
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "What the item is called in Orca.",
"x-orca-since": "1.4.245",
"examples": [
"Dusk"
]
},
"detail": {
"description": "Extra context, such as file extensions.",
"x-orca-since": "1.4.245",
"type": "string",
"minLength": 1,
"maxLength": 160
}
},
"required": [
"kind",
"name"
],
"description": "One thing the release adds to Orca."
}
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -156,7 +156,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then
- **Terminal Directory** — start directory for new floating terminal tabs (`~` = home).
- **Toggle Button Location** — where the floating-workspace toggle appears; the keyboard shortcut works regardless of button placement.
## Plugins (Experimental)
## Plugins (Alpha)
- **Plugin system** — Settings → Plugins. Turn the system on, then review and enable each plugin individually. Nothing runs until you consent.
- **Marketplaces** — add a git marketplace source, browse plugins, preview capabilities (panels, commands, language packs, VM recipes), install, update, or roll back.
+1
View File
@@ -20,6 +20,7 @@ Use this rubric when reviewing any Orca IDE screen, screenshot, or prototype, an
- **Shortcut labels:** show shortcut chips only for shortcuts that are actually implemented and useful at that location. Labels must match the platform binding. If a shortcut strategy is undecided, do not expose a placeholder label in product UI.
- **Alignment:** rows and columns must line up to a visible grid. Left-align text and labels for scanability; right-align numbers, counts, shortcuts, and trailing metadata when comparison matters; center-align only compact icon controls, empty states, and table cells where symmetry is the clearest read.
- **Copy quality:** displayed text must be typo-free, concise, and specific. Prefer direct verbs and concrete nouns. Remove filler like "please", "simply", "just", "you can", and generic success language that is not backed by state.
- **Settings subtext:** at most one line per section, row or card (aim for 80 characters). Say what the thing is, not how it works: no storage, fallback or lifecycle mechanics, and no reassurance outside security copy. Omit subtext the title already says. Errors name what failed and one action; details belong in logs.
- **Dialogs and overlays:** choose a dialog size that matches the amount of input. Short confirmations stay compact; forms with multi-line text, path pickers, provider setup, or review content need a larger dialog or sheet. Floating surfaces must use the documented shadow/elevation and background treatment so they read as above the page.
- **Empty and error states:** when data is missing, show a direct action to acquire or configure that data. Use toasts for transient failures or confirmations; persist errors inline when the user needs to read, retry, copy, or act on the message.
- **External links:** add direct links when the user may need provider docs, token settings, billing/setup pages, Git provider resources, or troubleshooting context. Put links near the relevant empty state, error, helper text, or setup step instead of burying them in a generic menu.
+7 -6
View File
@@ -1,21 +1,22 @@
// Sample plugin runs in a lazily forked plain-Node worker; its default export receives commands, events and capability-gated host API.
export default function activate(orca) {
orca.commands.register('hello-ping', async (args) => {
orca.commands.register('hello-ping', async () => {
const stored = await orca.host.call('storage.get', { key: 'pings' })
const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1
await orca.host.call('storage.set', { key: 'pings', value: count })
return { pong: true, count, args: args ?? null }
// A string result is shown as-is in Orca; objects are shown as JSON.
return count === 1 ? 'Pong! First ping.' : `Pong! ${count} pings so far.`
})
orca.events.on('worktree.created', async (payload) => {
orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`)
orca.events.on('workspace.created', async (payload) => {
orca.log(`${payload.kind} created: ${payload.workspaceId} at ${payload.path}`)
await orca.host.call('notifications.show', {
title: 'Worktree created',
title: 'Workspace created',
body: payload.path
})
})
orca.events.on('agent.status.changed', (payload) => {
orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`)
orca.log(`agent status: ${payload.state} in ${payload.workspaceId ?? 'unknown workspace'}`)
})
}
+7 -7
View File
@@ -1,23 +1,23 @@
{
"manifestVersion": 1,
"manifestVersion": 2,
"execution": "trusted-worker",
"id": "hello-orca",
"publisher": "orca-samples",
"publisher": "your-github-user",
"name": "Hello Orca",
"version": "1.0.0",
"description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.",
"engines": { "orca": ">=1.4.0" },
"engines": { "orca": ">=1.4.245" },
"pluginApi": 1,
"main": "main.mjs",
"contributes": {
"panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }],
"commands": [{ "id": "hello-ping", "title": "Hello: Ping" }],
"events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }]
"events": [{ "on": "workspace.created" }, { "on": "agent.status.changed" }]
},
"capabilities": [
{ "kind": "workspace:read" },
{ "kind": "terminal:send" },
{ "kind": "notifications:show" },
{ "kind": "storage" },
{ "kind": "events:subscribe" }
{ "kind": "storage:own" },
{ "kind": "agent:read" }
]
}
+30 -75
View File
@@ -9,8 +9,8 @@
padding: 12px;
font-family: system-ui, sans-serif;
font-size: 13px;
color: var(--foreground, #ddd);
background: var(--background, transparent);
color: var(--orca-foreground, #ddd);
background: var(--orca-background, transparent);
}
h1 {
font-size: 14px;
@@ -20,21 +20,17 @@
display: block;
margin: 6px 0;
padding: 6px 10px;
border: 1px solid var(--border, #555);
border: 1px solid var(--orca-border, #555);
border-radius: 6px;
background: var(--secondary, #2a2a2a);
color: var(--foreground, #ddd);
background: var(--orca-secondary, #2a2a2a);
color: var(--orca-foreground, #ddd);
cursor: pointer;
font: inherit;
}
select {
margin: 6px 0;
max-width: 100%;
}
.status {
margin-top: 10px;
min-height: 1.4em;
color: var(--muted-foreground, #999);
color: var(--orca-muted-foreground, #999);
white-space: pre-wrap;
}
</style>
@@ -43,82 +39,41 @@
<h1>Hello Orca 👋</h1>
<p>Panel + worker command + events, gated by consent.</p>
<button id="ctx">Read workspace context</button>
<select id="terms" hidden></select>
<button id="send" hidden>Type "echo hi from plugin" into selected terminal</button>
<button id="notify">Show a notification</button>
<p class="status" id="status"></p>
<script>
'use strict'
var seq = 0
var pending = new Map()
// Orca injects a frozen window.orca before this script runs.
var statusEl = document.getElementById('status')
var termsEl = document.getElementById('terms')
function call(action, params) {
return new Promise(function (resolve) {
var requestId = 'req-' + ++seq
pending.set(requestId, resolve)
// Opaque-origin frames require '*'; the host verifies the sending window.
window.parent.postMessage(
{ type: 'orca-panel-action', requestId: requestId, action: action, params: params },
'*'
)
})
}
window.addEventListener('message', function (event) {
var data = event.data
if (event.source !== window.parent || !data || data.type !== 'orca-panel-action-result') {
return
}
var resolve = pending.get(data.requestId)
if (!resolve) return
pending.delete(data.requestId)
resolve(data)
})
document.getElementById('ctx').addEventListener('click', function () {
call('workspace.readContext').then(function (result) {
if (!result.ok) {
statusEl.textContent = 'context failed: ' + (result.error || result.errorCode)
return
orca.host.call('workspace.readContext').then(
function (context) {
if (!context) {
statusEl.textContent = 'No workspace is focused.'
return
}
var count = context.terminals.length
var terminals = count === 1 ? 'terminal' : 'terminals'
statusEl.textContent = `${context.displayName} on branch ${context.branch} · ${count} ${terminals}`
},
function (error) {
statusEl.textContent = `context failed: ${error.code}: ${error.message}`
}
if (!result.value) {
statusEl.textContent = 'no focused worktree'
return
}
statusEl.textContent = result.value.displayName + ' on branch ' + result.value.branch
termsEl.innerHTML = ''
result.value.terminals.forEach(function (terminal) {
var option = document.createElement('option')
option.value = terminal.id
option.textContent = terminal.id
termsEl.appendChild(option)
})
termsEl.hidden = result.value.terminals.length === 0
document.getElementById('send').hidden = result.value.terminals.length === 0
})
})
document.getElementById('send').addEventListener('click', function () {
// Explicit terminal id — the API has no "active terminal" target.
call('terminal.sendText', {
terminalId: termsEl.value,
text: 'echo hi from plugin',
enter: true
}).then(function (result) {
statusEl.textContent = result.ok
? 'typed (accepted=' + result.value.accepted + ')'
: 'send failed: ' + (result.error || result.errorCode)
})
)
})
document.getElementById('notify').addEventListener('click', function () {
call('notifications.show', { title: 'Hello from the panel' }).then(function (result) {
statusEl.textContent = result.ok
? 'notification delivered=' + result.value.delivered
: 'notify failed: ' + (result.error || result.errorCode)
})
orca.host.call('notifications.show', { title: 'Hello from the panel' }).then(
function (result) {
statusEl.textContent = result.delivered
? 'Notification shown.'
: 'Notifications are turned off.'
},
function (error) {
statusEl.textContent = `notify failed: ${error.code}: ${error.message}`
}
)
})
</script>
</body>
@@ -1,11 +1,12 @@
{
"manifestVersion": 1,
"manifestVersion": 2,
"execution": "sandboxed-ui",
"id": "hostile-panel",
"publisher": "orca-samples",
"publisher": "samples",
"name": "Hostile Panel (security fixture)",
"version": "1.0.0",
"description": "Deliberately hostile panel used by the plugin containment tests: exfiltration, navigation, message floods, busy loops. Never grant it anything.",
"engines": { "orca": ">=1.4.0" },
"engines": { "orca": ">=1.4.245" },
"pluginApi": 1,
"contributes": {
"panels": [
+26 -42
View File
@@ -127,64 +127,48 @@
})
// Probe 7: an oversized valid-looking call must receive a real refusal.
window.addEventListener('message', function (event) {
var data = event.data
if (event.source !== window.parent || !data || data.type !== 'orca-panel-action-result') {
return
}
if (data.requestId === 'oversized-probe') {
report(
'oversized-message',
!data.ok && data.errorCode === 'invalid_request',
data.errorCode || 'unexpected success'
)
}
if (data.requestId === 'flood-result') {
report(
'message-flood',
!data.ok && data.errorCode === 'rate_limited',
data.errorCode || 'unexpected success'
)
}
})
document.getElementById('bridge').addEventListener('click', function () {
window.parent.postMessage(
{
type: 'orca-panel-action',
requestId: 'oversized-probe',
action: 'workspace.readContext',
params: { padding: 'x'.repeat(128 * 1024) }
function expectRefusal(name, call, code) {
var settled = false
call.then(
function () {
settled = true
report(name, false, 'unexpected success')
},
'*'
function (error) {
settled = true
report(name, error.code === code, error.code)
}
)
setTimeout(function () {
report('oversized-message', false, 'host sent no refusal')
if (!settled) {
report(name, false, 'host sent no refusal')
}
}, 2000)
}
document.getElementById('bridge').addEventListener('click', function () {
expectRefusal(
'oversized-message',
window.orca.host.call('workspace.readContext', { padding: 'x'.repeat(128 * 1024) }),
'too_large'
)
// Invalid, pong and binary floods must spend rate budget before parsing; the final valid call proves it.
setTimeout(function () {
for (var i = 0; i < 40; i++) {
var payload =
i % 3 === 0
? { type: 'orca-panel-pong', pingId: i }
? { orca: 1, kind: 'evt', event: 'pong', payload: i }
: i % 3 === 1
? { type: 'invalid-hostile-message', sequence: i }
: new Uint8Array(2048)
window.parent.postMessage(payload, '*')
}
window.parent.postMessage(
{
type: 'orca-panel-action',
requestId: 'flood-result',
action: 'workspace.readContext',
params: {}
},
'*'
expectRefusal(
'message-flood',
window.orca.host.call('workspace.readContext', {}),
'rate_limited'
)
setTimeout(function () {
report('message-flood', false, 'host sent no rate-limit refusal')
}, 2000)
}, 150)
})
@@ -0,0 +1,5 @@
# Gleam syntax (example)
An official-style data plugin: one `syntaxes` contribution with a vendored TextMate grammar
(Apache-2.0, see `syntaxes/gleam-LICENSE.txt`). It runs no code. A catalog release for it would
list `"types": ["syntax"]` and `"fileTypes": ["gleam"]`, so opening a `.gleam` file suggests it.
@@ -0,0 +1,23 @@
{
"$schema": "./orca-plugin.v2.schema.json",
"manifestVersion": 2,
"execution": "data",
"id": "orca-gleam-syntax",
"publisher": "stablyai",
"name": "Gleam",
"version": "1.0.0",
"description": "Syntax highlighting for Gleam files.",
"engines": { "orca": ">=1.4.245" },
"contributes": {
"syntaxes": [
{
"id": "gleam",
"title": "Gleam",
"scopeName": "source.gleam",
"path": "syntaxes/gleam.tmLanguage.json",
"configuration": "syntaxes/language-configuration.json",
"extensions": [".gleam"]
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
Gleam TextMate grammar
Source: https://github.com/gleam-lang/vscode-gleam/blob/d6ce8c4358c278245e913b99a281d118637c9399/syntaxes/gleam.tmLanguage.json
Modifications by Orca: JSON formatting; the $schema key is removed.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2016 - present Louis Pilfold
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
## Runtime Library Exception to the Apache 2.0 License: ##
As an exception, if you use this Software to compile your source code and
portions of this Software are embedded into the binary product as a result,
you may redistribute such product without providing attribution as would
otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.
@@ -0,0 +1,172 @@
{
"name": "Gleam",
"patterns": [
{
"include": "#comments"
},
{
"include": "#keywords"
},
{
"include": "#strings"
},
{
"include": "#constant"
},
{
"include": "#entity"
},
{
"include": "#discards"
}
],
"repository": {
"keywords": {
"patterns": [
{
"name": "keyword.control.gleam",
"match": "\\b(as|use|case|if|fn|import|let|assert|pub|type|opaque|const|todo|panic|else|echo)\\b"
},
{
"name": "keyword.operator.arrow.gleam",
"match": "(<\\-|\\->)"
},
{
"name": "keyword.operator.pipe.gleam",
"match": "\\|>"
},
{
"name": "keyword.operator.splat.gleam",
"match": "\\.\\."
},
{
"name": "keyword.operator.comparison.gleam",
"match": "(==|!=)"
},
{
"name": "keyword.operator.comparison.float.gleam",
"match": "(<=\\.|>=\\.|<\\.|>\\.)"
},
{
"name": "keyword.operator.comparison.int.gleam",
"match": "(<=|>=|<|>)"
},
{
"name": "keyword.operator.logical.gleam",
"match": "(&&|\\|\\|)"
},
{
"name": "keyword.operator.string.gleam",
"match": "<>"
},
{
"name": "keyword.operator.other.gleam",
"match": "\\|"
},
{
"name": "keyword.operator.arithmetic.float.gleam",
"match": "(\\+\\.|\\-\\.|/\\.|\\*\\.)"
},
{
"name": "keyword.operator.arithmetic.int.gleam",
"match": "(\\+|\\-|/|\\*|%)"
},
{
"name": "keyword.operator.assignment.gleam",
"match": "="
}
]
},
"strings": {
"name": "string.quoted.double.gleam",
"begin": "\"",
"end": "\"",
"patterns": [
{
"name": "constant.character.escape.gleam",
"match": "\\\\."
}
]
},
"comments": {
"patterns": [
{
"name": "comment.line.gleam",
"match": "//.*"
}
]
},
"constant": {
"patterns": [
{
"include": "#binary_number"
},
{
"include": "#octal_number"
},
{
"include": "#hexadecimal_number"
},
{
"include": "#decimal_number"
},
{
"name": "entity.name.type.gleam",
"match": "[[:upper:]][[:alnum:]]*"
}
]
},
"binary_number": {
"name": "constant.numeric.binary.gleam",
"match": "\\b0[bB][01_]*\\b",
"patterns": []
},
"octal_number": {
"name": "constant.numeric.octal.gleam",
"match": "\\b0[oO][0-7_]*\\b",
"patterns": []
},
"decimal_number": {
"name": "constant.numeric.decimal.gleam",
"match": "\\b([0-9][0-9_]*)(\\.([0-9_]*)?(e-?[0-9]+)?)?\\b",
"patterns": []
},
"hexadecimal_number": {
"name": "constant.numeric.hexadecimal.gleam",
"match": "\\b0[xX][0-9a-fA-F_]+\\b",
"patterns": []
},
"entity": {
"patterns": [
{
"begin": "\\b([[:lower:]][[:word:]]*)\\b[[:space:]]*\\(",
"end": "\\)",
"patterns": [
{
"include": "$self"
}
],
"captures": {
"1": {
"name": "entity.name.function.gleam"
}
}
},
{
"name": "variable.parameter.gleam",
"match": "\\b([[:lower:]][[:word:]]*):\\s"
},
{
"name": "entity.name.namespace.gleam",
"match": "\\b([[:lower:]][[:word:]]*):"
}
]
},
"discards": {
"name": "comment.unused.gleam",
"match": "\\b_(?:[[:word:]]+)?\\b"
}
},
"scopeName": "source.gleam",
"fileTypes": ["gleam"]
}
@@ -0,0 +1,20 @@
{
"comments": { "lineComment": "//" },
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"" }
],
"surroundingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"" }
]
}
+3
View File
@@ -75,6 +75,8 @@
"smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"generate:plugin-schemas": "node config/scripts/generate-plugin-schemas.mjs",
"validate:plugin": "node config/scripts/validate-plugin-files.mjs",
"generate:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs",
"verify:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs --check",
"generate:acp-protocol": "node config/scripts/acp/generate-protocol.mjs",
@@ -298,6 +300,7 @@
"@xterm/addon-web-links": "0.13.0-beta.301",
"@xterm/addon-webgl": "0.20.0-beta.300",
"@xterm/xterm": "6.1.0-beta.304",
"ajv": "8.20.0",
"bun": "1.4.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+3
View File
@@ -432,6 +432,9 @@ importers:
'@xterm/xterm':
specifier: 6.1.0-beta.304
version: 6.1.0-beta.304(patch_hash=6a96a556cb32825d4084043f7e18fe16203686d8352affd2f01613c0b464a6ed)
ajv:
specifier: 8.20.0
version: 8.20.0
bun:
specifier: 1.4.4
version: 1.4.4
@@ -1,10 +1,4 @@
{
"version": 1,
"plugins": [
{
"pluginKey": "stablyai.orca-navigation-shortcuts",
"path": "stablyai.orca-navigation-shortcuts",
"contentHash": "ce3a146bae9e121a18cb86a710973422be749a25da5a1c21a8911e2e98cc3a77"
}
]
"plugins": []
}
@@ -2,16 +2,6 @@
"name": "Orca Official Plugins",
"owner": "stablyai",
"plugins": [
{
"id": "stablyai.orca-portuguese",
"source": {
"kind": "git",
"url": "https://github.com/stablyai/orca-portuguese.git",
"ref": "v1.0.0"
},
"description": "Brazilian Portuguese translations for common Orca navigation.",
"categories": ["languages", "official"]
},
{
"id": "stablyai.orca-multipass-recipes",
"source": {
@@ -21,16 +11,6 @@
},
"description": "A reviewed starter lifecycle for disposable Multipass workspaces.",
"categories": ["vm-recipes", "official"]
},
{
"id": "stablyai.orca-navigation-shortcuts",
"source": {
"kind": "git",
"url": "https://github.com/stablyai/orca-navigation-shortcuts.git",
"ref": "v1.0.0"
},
"description": "Command aliases and optional shortcuts for frequent Orca views.",
"categories": ["keybindings", "official"]
}
]
}
@@ -1,34 +0,0 @@
{
"manifestVersion": 1,
"id": "orca-navigation-shortcuts",
"publisher": "stablyai",
"name": "Orca Navigation Shortcuts",
"version": "1.0.0",
"description": "Command aliases and optional shortcuts for frequent Orca views.",
"repository": "https://github.com/stablyai/orca-navigation-shortcuts",
"engines": { "orca": ">=1.4.0" },
"pluginApi": 1,
"contributes": {
"commands": [
{ "id": "open-tasks", "title": "Open Tasks", "context": "global", "action": "view.tasks" },
{
"id": "toggle-search",
"title": "Toggle Search",
"context": "global",
"action": "sidebar.search.toggle"
},
{
"id": "toggle-source-control",
"title": "Toggle Source Control",
"context": "global",
"action": "sidebar.sourceControl.toggle"
}
],
"keybindings": [
{ "command": "open-tasks", "key": "Mod+Alt+T", "when": "global" },
{ "command": "toggle-search", "key": "Mod+Alt+F", "when": "global" },
{ "command": "toggle-source-control", "key": "Mod+Alt+G", "when": "global" }
]
},
"capabilities": []
}
@@ -1,36 +0,0 @@
{
"settings": {
"appearance": {
"language": {
"title": "Idioma",
"description": "Escolha o idioma usado na interface do Orca.",
"system": "Sistema",
"english": "Inglês",
"chinese": "Chinês simplificado",
"korean": "Coreano",
"japanese": "Japonês",
"spanish": "Espanhol"
}
}
},
"menu": {
"checkForUpdates": "Verificar atualizações...",
"settings": "Configurações",
"file": "Arquivo",
"exit": "Sair",
"edit": "Editar",
"appearance": "Aparência",
"view": "Visualizar",
"reload": "Recarregar",
"window": "Janela",
"help": "Ajuda",
"paste": "Colar"
},
"tray": {
"openOrca": "Abrir o Orca",
"quit": "Encerrar",
"minimizeNotice": {
"body": "O Orca continua em execução na bandeja do sistema"
}
}
}
@@ -1,15 +0,0 @@
{
"manifestVersion": 1,
"id": "orca-portuguese",
"publisher": "stablyai",
"name": "Português do Brasil",
"version": "1.0.0",
"description": "Brazilian Portuguese translations for common Orca navigation.",
"repository": "https://github.com/stablyai/orca-portuguese",
"engines": { "orca": ">=1.4.0" },
"pluginApi": 1,
"contributes": {
"languagePacks": [{ "locale": "pt-BR", "path": "locales/pt-BR.json" }]
},
"capabilities": []
}
+7 -4
View File
@@ -20,7 +20,7 @@ import {
UI_LANGUAGE_SPANISH
} from '../../shared/ui-language'
import { mainI18n, setMainPluginLanguagePacks, setMainUiLanguage, translateMain } from './main-i18n'
import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact'
import { pluginLanguageResourceId } from '../../shared/plugins/artifacts/plugin-language-pack-artifact'
describe('main-i18n lazy locale loading', () => {
afterEach(() => vi.restoreAllMocks())
@@ -112,14 +112,15 @@ describe('main-i18n lazy locale loading', () => {
})
it('loads a contributed catalog for native menus and dialogs', async () => {
const id = 'plugin:orca-samples.portuguese/pt-BR' as const
const id = 'plugin:samples.portuguese/pt-BR' as const
setMainPluginLanguagePacks([
{
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: 'orca-samples.portuguese',
pluginKey: 'samples.portuguese',
locale: 'pt-BR',
catalog: { menu: { file: 'Arquivo Orca' } }
catalog: { menu: { file: 'Arquivo Orca' } },
coverage: 100
}
])
@@ -138,6 +139,7 @@ describe('main-i18n lazy locale loading', () => {
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey,
coverage: 100,
locale: 'pt-BR',
catalog: { menu: { file: pluginKey } }
}
@@ -160,6 +162,7 @@ describe('main-i18n lazy locale loading', () => {
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: 'orca-samples.russian',
coverage: 100,
locale: 'ru-RU',
catalog: {
terminals_one: '{{count}} one',
+11 -2
View File
@@ -1,5 +1,5 @@
import { app, clipboard, dialog, type BrowserWindow } from 'electron'
import { parseSkillShareId } from '../shared/skill-share-link'
import { isRoutedOrcaDeepLink } from './startup/orca-deep-link-url'
import { createMacAppActivationHandler } from './window/macos-app-activation'
import { isBackgroundLaunch, isOrcadSidecarLaunch } from './window/foreground-activation-policy'
import { createDesktopHandoff, desktopHandoffArguments } from './window/macos-desktop-handoff'
@@ -38,6 +38,7 @@ import { wakeRememberedWslHosts } from './wsl/wsl-host-early-wake'
import { wslHostsServing } from './wsl/wsl-host-gate'
import { configureWslHostsGateFromDisk } from './wsl/wsl-host-gate-startup'
import { ignoreInheritedWslHostsGateState } from '../shared/wsl-host-internal-gate'
import { registerOrcaProtocolClient } from './startup/orca-protocol-registration'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -57,6 +58,9 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.skillShareDeepLinks.capture(argv, (shareId) => {
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
})
state.pluginInstallLinks.capture(argv, (link) => {
state.mainWindow?.webContents.send('ui:openPluginInstallLink', link)
})
state.osOpenedDocuments.capture(argv, publishOsOpenedDocuments)
// Duplicate serve launches must not activate a desktop on a headless server (#11935).
if (!shouldActivateDesktopForSecondInstance(argv)) {
@@ -123,7 +127,7 @@ if (preflightReady) {
wakeRememberedWslHosts(app.getPath('userData'))
}
app.on('open-url', (event, url) => {
if (!parseSkillShareId(url)) {
if (!isRoutedOrcaDeepLink(url)) {
return
}
event.preventDefault()
@@ -140,7 +144,12 @@ if (preflightReady) {
requestDesktopActivation()
}
})
registerOrcaProtocolClient(app, {
platform: process.platform,
desktopLaunch: !state.isServeMode && !isBackgroundLaunch() && !isOrcadSidecarLaunch()
})
state.skillShareDeepLinks.capture(process.argv)
state.pluginInstallLinks.capture(process.argv)
// The first renderer pulls on mount; nothing listens this early.
state.osOpenedDocuments.capture(process.argv)
registerMainProcessIpcHandlers()
+4 -4
View File
@@ -225,11 +225,11 @@ describe('registerEphemeralVmHandlers', () => {
)
const pluginService = pluginServiceWithRecipes([
{
pluginKey: 'orca-samples.recipes',
pluginKey: 'samples.recipes',
recipe: { id: 'shared', name: 'Plugin Shared', create: 'plugin-shared' }
},
{
pluginKey: 'orca-samples.recipes',
pluginKey: 'samples.recipes',
recipe: { id: 'global', name: 'Plugin Global', create: 'plugin-global' }
}
])
@@ -265,7 +265,7 @@ describe('registerEphemeralVmHandlers', () => {
writeFileSync(destroyPath, "require('fs').writeFileSync('plugin-cleaned.txt', 'yes')")
const registrations = [
{
pluginKey: 'orca-samples.recipes',
pluginKey: 'samples.recipes',
recipe: {
id: 'plugin-cloud',
name: 'Plugin Cloud',
@@ -316,7 +316,7 @@ describe('registerEphemeralVmHandlers', () => {
})
const pluginService = pluginServiceWithRecipes([
{
pluginKey: 'orca-samples.recipes',
pluginKey: 'samples.recipes',
recipe: {
id: 'shared-id',
name: 'Later Plugin Recipe',
@@ -0,0 +1,90 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { registerFilesystemWriteHandlers } from './filesystem-write-handlers'
import type { FilesystemHandlerContext } from './filesystem-handler-context'
const handlers = vi.hoisted(() => new Map<string, (_event: unknown, args: unknown) => unknown>())
vi.mock('electron', () => ({
ipcMain: {
handle: (name: string, handler: (_event: unknown, args: unknown) => unknown) =>
handlers.set(name, handler)
},
shell: {}
}))
vi.mock('../local-file-access-resolution', () => ({
resolveLocalWriteRequestPath: async (path: string) => path
}))
vi.mock('./filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() }))
vi.mock('../agent-session-attachment-upload-ipc', () => ({
registerAgentSessionAttachmentUploadHandlers: vi.fn()
}))
const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
const loaded = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0xff, 0x00])
const edited = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x80, 0xfe, 0x00, 0x01])
let root = ''
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-bytes-ipc-'))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization is stubbed, and no other context member is used by this handler.
registerFilesystemWriteHandlers({ store: {} } as FilesystemHandlerContext)
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
handlers.clear()
})
function writeBytes(filePath: string, bytes: Uint8Array, expectedSha256?: string) {
return handlers.get('fs:writeFileBytes')!(null, {
filePath,
bytes,
diskPrecondition: expectedSha256 ? { expectedSha256, allowMissing: true } : {}
})
}
it('replaces the bytes atomically when the file still holds what the editor loaded', async () => {
const filePath = join(root, 'book.xlsx')
await writeFile(filePath, loaded)
await expect(writeBytes(filePath, edited, sha256(loaded))).resolves.toEqual({ ok: true })
expect(new Uint8Array(await readFile(filePath))).toEqual(edited)
expect(await readdir(root)).toEqual(['book.xlsx'])
})
it('refuses a stale baseline and leaves the newer disk bytes alone', async () => {
const filePath = join(root, 'book.xlsx')
await writeFile(filePath, new Uint8Array([1, 2, 3]))
await expect(writeBytes(filePath, edited, sha256(loaded))).resolves.toEqual({
ok: false,
diskConflict: 'changed'
})
expect(new Uint8Array(await readFile(filePath))).toEqual(new Uint8Array([1, 2, 3]))
})
it.skipIf(process.platform === 'win32')(
'writes through a symlink to its target and keeps the file mode',
async () => {
const target = join(root, 'real.bin')
const link = join(root, 'link.bin')
await writeFile(target, loaded, { mode: 0o640 })
await symlink(target, link)
await expect(writeBytes(link, edited, sha256(loaded))).resolves.toEqual({ ok: true })
expect(new Uint8Array(await readFile(target))).toEqual(edited)
expect((await stat(target)).mode & 0o777).toBe(0o640)
}
)
it('recreates a deleted file only when the save allows it', async () => {
const filePath = join(root, 'gone.bin')
await expect(
handlers.get('fs:writeFileBytes')!(null, {
filePath,
bytes: edited,
diskPrecondition: { expectedSha256: sha256(loaded) }
})
).resolves.toEqual({ ok: false, diskConflict: 'missing' })
await expect(writeBytes(filePath, edited, sha256(loaded))).resolves.toEqual({ ok: true })
expect(new Uint8Array(await readFile(filePath))).toEqual(edited)
})
@@ -1,5 +1,5 @@
import { ipcMain, shell } from 'electron'
import { lstat, rm } from 'node:fs/promises'
import { lstat, rm, writeFile } from 'node:fs/promises'
import type { SshMutationExpectation } from '../../../shared/ssh-types'
import { assertSshMutationExpectation } from '../../ssh/connection/ssh-connection-generation'
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
@@ -18,6 +18,8 @@ import type {
FileWriteResult
} from '../../../shared/file-write-disk-precondition'
import { writeLocalTextFileWithDiskPrecondition } from './local-disk-write-precondition'
import { replaceLocalFileBytesIfUnchanged } from './local-disk-bytes-replace'
import type { FileBytesWritePrecondition } from '../../../shared/file-bytes-write'
import { windowsFsPath } from '../../wsl/wsl-unc-drive-fs-path'
import { TRASH_DELETE_FAILED_MESSAGE } from '../../../shared/trash-delete-failure'
@@ -60,6 +62,39 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
}
)
ipcMain.handle(
'fs:writeFileBytes',
async (
_event,
args: {
filePath: string
bytes: Uint8Array
connectionId?: string
access?: LocalFileAccess
diskPrecondition: FileBytesWritePrecondition
} & SshMutationExpectation
): Promise<FileWriteResult> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
throw new SshHostNotServedError(args.connectionId)
}
if (!(args.bytes instanceof Uint8Array)) {
throw new Error('Missing file content')
}
const filePath = await resolveLocalWriteRequestPath(args.filePath, args.access, store)
return replaceLocalFileBytesIfUnchanged(
filePath,
(tempPath) => writeFile(tempPath, args.bytes),
args.diskPrecondition ?? {}
)
}
)
ipcMain.handle(
'fs:deletePath',
async (
@@ -0,0 +1,71 @@
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, realpath, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import type { FileBytesWritePrecondition } from '../../../shared/file-bytes-write'
import { FILE_WRITE_OK, type FileWriteResult } from '../../../shared/file-write-disk-precondition'
import { renameFileWithWindowsRetryAsync } from '../../../shared/windows-retry-file-operations'
import { windowsFsPath } from '../../wsl/wsl-unc-drive-fs-path'
import { isENOENT } from './filesystem-path-containment'
import { runLocalEditorWrite } from './local-disk-write-precondition'
import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read'
async function sha256OfFile(filePath: string): Promise<string | null> {
const hash = createHash('sha256')
try {
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk)
}
} catch (error) {
if (isENOENT(error)) {
return null
}
throw error
}
return hash.digest('hex')
}
/**
* Replaces a file's bytes atomically (sibling temp file, then rename) unless it no longer holds
* the bytes the editor loaded. A symlink is followed, so its target is replaced, not the link.
*/
export async function replaceLocalFileBytesIfUnchanged(
requestedPath: string,
writeTemp: (tempPath: string) => Promise<void>,
precondition: FileBytesWritePrecondition
): Promise<FileWriteResult> {
const filePath = windowsFsPath(requestedPath)
return runLocalEditorWrite(filePath, async () => {
const target = await realpath(filePath).catch((error: unknown) => {
if (isENOENT(error)) {
return filePath
}
throw error
})
if (precondition.expectedSha256 !== undefined) {
const current = await sha256OfFile(target)
if (current === null && precondition.allowMissing !== true) {
return { ok: false, diskConflict: 'missing' }
}
if (current !== null && current !== precondition.expectedSha256) {
return { ok: false, diskConflict: 'changed' }
}
}
await assertLocalWriteTargetIsRegularFile(target)
const mode = await stat(target).then(
(stats) => stats.mode & 0o7777,
() => null
)
const temp = join(dirname(target), `.${basename(target)}.orca-save-${randomUUID()}`)
try {
await writeTemp(temp)
if (mode !== null) {
await chmod(temp, mode)
}
await renameFileWithWindowsRetryAsync(temp, target)
} finally {
await rm(temp, { force: true }).catch(() => undefined)
}
return FILE_WRITE_OK
})
}
@@ -178,6 +178,17 @@ export async function writeLocalTextFileWithDiskPrecondition(
}))
}
/** Runs one compare-and-write under the path and inode locks every editor save of the file shares. */
export function runLocalEditorWrite(
filePath: string,
compareAndWrite: () => Promise<FileWriteResult>
): Promise<FileWriteResult> {
return runSerializedLocalFileWrite(filePath, compareAndWrite, () => ({
ok: false,
diskConflict: 'changed'
}))
}
/** Every in-process writer that checks a file before replacing it shares this lock, so no two pass the check together. */
export async function runSerializedLocalFileWrite<T>(
filePath: string,
+138 -8
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer'
import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service'
import { getDefaultSettings } from '../../shared/constants'
import type { PluginMarketplaceInstaller } from '../plugins/catalog/plugin-marketplace-installer'
import type { PluginMarketplaceService } from '../plugins/catalog/plugin-marketplace-service'
import type { PluginService } from '../plugins/plugin-service'
type IpcHandler = (event: unknown, args?: unknown) => unknown
@@ -16,19 +17,21 @@ import {
const SOURCE_ID = 'a'.repeat(32)
const MARKETPLACE_COMMIT = 'b'.repeat(40)
const PLUGIN_COMMIT = 'c'.repeat(40)
const PLUGIN_KEY = 'orca-samples.demo'
const PLUGIN_KEY = 'samples.demo'
let handlers: Map<string, IpcHandler>
function createServices(): PluginMarketplaceHandlerServices {
return {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only these members.
marketplace: {
listSources: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]),
addSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }),
removeSource: vi.fn().mockResolvedValue(true),
refreshSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }),
refreshAll: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]),
listPlugins: vi.fn().mockResolvedValue([{ pluginKey: PLUGIN_KEY }])
listPlugins: vi.fn().mockResolvedValue([{ pluginKey: PLUGIN_KEY }]),
seedOfficialSource: vi.fn().mockResolvedValue({ id: SOURCE_ID })
} as unknown as PluginMarketplaceService,
installer: {
preview: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }),
@@ -40,7 +43,14 @@ function createServices(): PluginMarketplaceHandlerServices {
}
function createPluginService(): PluginService {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only these members.
return {
options: {
isPluginSystemEnabled: () => true,
getPluginConsents: () => ({}),
getDisabledPlugins: () => []
},
findValidPlugin: vi.fn(() => null),
deactivatePlugin: vi.fn().mockResolvedValue(undefined),
refresh: vi.fn().mockResolvedValue(undefined)
} as unknown as PluginService
@@ -64,7 +74,12 @@ beforeEach(() => {
describe('plugin marketplace IPC authority', () => {
it('validates every mutating or plugin-selecting request strictly', async () => {
registerPluginMarketplaceHandlers(createPluginService(), createServices())
registerPluginMarketplaceHandlers(
createPluginService(),
createServices(),
() => getDefaultSettings('/tmp'),
vi.fn()
)
await expect(
invoke('plugins:addMarketplace', {
@@ -107,7 +122,12 @@ describe('plugin marketplace IPC authority', () => {
it('dispatches source listing, add, removal, and refresh operations', async () => {
const services = createServices()
registerPluginMarketplaceHandlers(createPluginService(), services)
registerPluginMarketplaceHandlers(
createPluginService(),
services,
() => getDefaultSettings('/tmp'),
vi.fn()
)
const source = {
kind: 'git' as const,
url: 'https://example.com/marketplace.git',
@@ -122,6 +142,8 @@ describe('plugin marketplace IPC authority', () => {
await invoke('plugins:listMarketplacePlugins')
expect(services.marketplace.listSources).toHaveBeenCalledTimes(2)
// The startup seed is deferred, so opening the marketplace seeds the official catalog.
expect(services.marketplace.seedOfficialSource).toHaveBeenCalledTimes(2)
expect(services.marketplace.addSource).toHaveBeenCalledWith(source)
expect(services.marketplace.removeSource).toHaveBeenCalledWith(SOURCE_ID)
expect(services.marketplace.refreshSource).toHaveBeenCalledWith(SOURCE_ID)
@@ -132,7 +154,12 @@ describe('plugin marketplace IPC authority', () => {
it('refreshes discovery only after a successful install', async () => {
const services = createServices()
const pluginService = createPluginService()
registerPluginMarketplaceHandlers(pluginService, services)
registerPluginMarketplaceHandlers(
pluginService,
services,
() => getDefaultSettings('/tmp'),
vi.fn()
)
const preview = {
marketplaceSourceId: SOURCE_ID,
marketplaceCommit: MARKETPLACE_COMMIT,
@@ -153,7 +180,12 @@ describe('plugin marketplace IPC authority', () => {
it('deactivates before rollback and refreshes discovery only on success', async () => {
const services = createServices()
const pluginService = createPluginService()
registerPluginMarketplaceHandlers(pluginService, services)
registerPluginMarketplaceHandlers(
pluginService,
services,
() => getDefaultSettings('/tmp'),
vi.fn()
)
await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY })
@@ -169,4 +201,102 @@ describe('plugin marketplace IPC authority', () => {
await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY })
expect(pluginService.refresh).not.toHaveBeenCalled()
})
it('turns on only content-only plugins, and only when asked, with main reading the manifest', async () => {
const services = createServices()
services.installer.install = vi
.fn()
.mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY, consentFingerprint: 'sha256-fp' })
const execution = { value: 'data' }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only refresh and findValidPlugin.
const pluginService = {
...createPluginService(),
findValidPlugin: vi.fn(() => ({
pluginKey: PLUGIN_KEY,
consentFingerprint: 'sha256-fp',
manifest: { manifestVersion: 2, execution: execution.value }
}))
} as unknown as PluginService
const approve = vi.fn().mockResolvedValue(undefined)
registerPluginMarketplaceHandlers(
pluginService,
services,
() => getDefaultSettings('/tmp'),
approve
)
const handler = handlers.get('plugins:installMarketplacePlugin')
const identity = {
marketplaceSourceId: SOURCE_ID,
marketplaceCommit: MARKETPLACE_COMMIT,
pluginKey: PLUGIN_KEY,
resolvedCommit: PLUGIN_COMMIT
}
const event = { sender: { id: 7 } }
await expect(handler?.(event, identity)).resolves.not.toHaveProperty('needsReview')
expect(approve).not.toHaveBeenCalled()
await expect(handler?.(event, { ...identity, enableIfData: true })).resolves.toMatchObject({
needsReview: false
})
expect(approve).toHaveBeenCalledWith(PLUGIN_KEY, 'sha256-fp', 7)
approve.mockClear()
await expect(
handler?.(event, { ...identity, enableIfData: true, expectedFingerprint: 'sha256-other' })
).resolves.toMatchObject({ needsReview: true })
expect(approve).not.toHaveBeenCalled()
await expect(
handler?.(event, { ...identity, enableIfData: true, expectedFingerprint: 'sha256-fp' })
).resolves.toMatchObject({ needsReview: false })
expect(approve).toHaveBeenCalledWith(PLUGIN_KEY, 'sha256-fp', 7)
approve.mockClear()
execution.value = 'declarative'
await expect(handler?.(event, { ...identity, enableIfData: true })).resolves.toMatchObject({
needsReview: true
})
expect(approve).not.toHaveBeenCalled()
})
it('sends an update from a moved repository to review even when one-click was asked', async () => {
const services = createServices()
services.installer.install = vi.fn().mockResolvedValue({
ok: true,
pluginKey: PLUGIN_KEY,
consentFingerprint: 'sha256-fp',
repositoryMoved: true
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only refresh and findValidPlugin.
const pluginService = {
...createPluginService(),
findValidPlugin: vi.fn(() => ({
pluginKey: PLUGIN_KEY,
consentFingerprint: 'sha256-fp',
manifest: { manifestVersion: 2, execution: 'data' }
}))
} as unknown as PluginService
const approve = vi.fn().mockResolvedValue(undefined)
registerPluginMarketplaceHandlers(
pluginService,
services,
() => getDefaultSettings('/tmp'),
approve
)
const identity = {
marketplaceSourceId: SOURCE_ID,
marketplaceCommit: MARKETPLACE_COMMIT,
pluginKey: PLUGIN_KEY,
resolvedCommit: PLUGIN_COMMIT
}
const handler = handlers.get('plugins:installMarketplacePlugin')
for (const args of [
identity,
{ ...identity, enableIfData: true, expectedFingerprint: 'sha256-fp' }
]) {
await expect(handler?.({ sender: { id: 7 } }, args)).resolves.toMatchObject({
needsReview: true
})
}
expect(approve).not.toHaveBeenCalled()
})
})
+88 -17
View File
@@ -1,18 +1,35 @@
import {
installCatalogReleaseWithApproval,
readPreviewAccessGrowth
} from '../plugins/catalog/plugin-update-approval'
import { ipcMain } from 'electron'
import { z } from 'zod'
import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile'
import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest'
import { pluginMarketplaceGitSourceSchema } from '../../shared/plugins/plugin-marketplace'
import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer'
import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service'
import { PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN } from '../plugins/plugin-marketplace-store'
import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/installation/plugin-install-lockfile'
import { isQualifiedPluginKey } from '../../shared/plugins/manifest/plugin-manifest'
import { pluginMarketplaceGitSourceSchema } from '../../shared/plugins/catalog/plugin-marketplace'
import type { PluginMarketplaceInstaller } from '../plugins/catalog/plugin-marketplace-installer'
import type { PluginMarketplaceService } from '../plugins/catalog/plugin-marketplace-service'
import { PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN } from '../plugins/catalog/plugin-marketplace-store'
import type { PluginService } from '../plugins/plugin-service'
import type { PluginAutoUpdater } from '../plugins/catalog/plugin-auto-updater'
import type { PluginAutoUpdateState } from '../../shared/plugins/catalog/plugin-update-policy'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { pluginFileTypeSchema } from '../../shared/plugins/contributions/plugin-file-type'
import { findPluginFileTypeSuggestion } from '../plugins/catalog/plugin-file-type-suggestion'
export type PluginMarketplaceHandlerServices = {
marketplace: PluginMarketplaceService
installer: PluginMarketplaceInstaller
autoUpdater?: Pick<PluginAutoUpdater, 'getState'>
}
/** The consent write path (applyPluginConsent), bound to the store by the caller. */
export type PluginApproveConsent = (
pluginKey: string,
reviewedFingerprint: string,
originWebContentsId: number
) => Promise<void>
const sourceIdSchema = z.string().regex(PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN)
const removeMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema })
const refreshMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema.optional() })
@@ -22,7 +39,11 @@ const marketplacePluginSchema = z.strictObject({
})
const installMarketplacePluginSchema = marketplacePluginSchema.extend({
marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN),
resolvedCommit: z.string().regex(PLUGIN_COMMIT_PATTERN)
resolvedCommit: z.string().regex(PLUGIN_COMMIT_PATTERN),
/** Ask main to approve in the same step if the installed plugin is content only. */
enableIfData: z.literal(true).optional(),
/** The preview's consentFingerprint: one-click approves only these exact bytes. */
expectedFingerprint: z.string().min(1).max(256).optional()
})
const installedPluginSchema = z.strictObject({
pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key')
@@ -30,9 +51,37 @@ const installedPluginSchema = z.strictObject({
export function registerPluginMarketplaceHandlers(
pluginService: PluginService,
services: PluginMarketplaceHandlerServices
services: PluginMarketplaceHandlerServices,
getSettings: () => GlobalSettings,
approveConsent: PluginApproveConsent
): void {
ipcMain.handle('plugins:listMarketplaces', () => services.marketplace.listSources())
// Why cached only: this runs on every file open, which must never wait on a catalog fetch.
ipcMain.handle(
'plugins:suggestForFileType',
async (_event, fileType: unknown, purpose: unknown) => {
const parsed = pluginFileTypeSchema.safeParse(fileType)
if (!parsed.success) {
return null
}
return findPluginFileTypeSuggestion({
fileType: parsed.data,
purpose: purpose === 'editor' ? 'editor' : 'open',
settings: getSettings(),
listings: await services.marketplace.listCachedPlugins(),
installed: pluginService.getDiscovered()
})
}
)
// Startup defers the official seed; a marketplace view must not wait for that timer.
const ensureOfficialSeed = (): void => {
if (getSettings().pluginSystemEnabled === true) {
void services.marketplace.seedOfficialSource().catch(() => undefined)
}
}
ipcMain.handle('plugins:listMarketplaces', () => {
ensureOfficialSeed()
return services.marketplace.listSources()
})
ipcMain.handle('plugins:addMarketplace', async (_event, args: unknown) => {
const source = pluginMarketplaceGitSourceSchema.parse(args)
return services.marketplace.addSource(source)
@@ -48,21 +97,43 @@ export function registerPluginMarketplaceHandlers(
? [await services.marketplace.refreshSource(sourceId)]
: services.marketplace.refreshAll()
})
ipcMain.handle('plugins:listMarketplacePlugins', () => services.marketplace.listPlugins())
ipcMain.handle('plugins:listMarketplacePlugins', () => {
ensureOfficialSeed()
return services.marketplace.listPlugins()
})
ipcMain.handle('plugins:previewMarketplacePlugin', async (_event, args: unknown) => {
const parsed = marketplacePluginSchema.parse(args)
return services.installer.preview(parsed.marketplaceSourceId, parsed.pluginKey)
})
ipcMain.handle('plugins:installMarketplacePlugin', async (_event, args: unknown) => {
const result = await services.installer.install(installMarketplacePluginSchema.parse(args))
if (result.ok) {
await pluginService.refresh()
}
return result
ipcMain.handle('plugins:installMarketplacePlugin', async (event, args: unknown) => {
const { enableIfData, expectedFingerprint, ...identity } =
installMarketplacePluginSchema.parse(args)
return installCatalogReleaseWithApproval({
pluginKey: identity.pluginKey,
pluginService,
install: () => services.installer.install(identity),
approve: (pluginKey, reviewedFingerprint) =>
approveConsent(pluginKey, reviewedFingerprint, event.sender.id),
...(enableIfData ? { enableIfData: { expectedFingerprint } } : {})
})
})
ipcMain.handle('plugins:previewMarketplaceUpdate', async (_event, args: unknown) => {
const { pluginKey } = installedPluginSchema.parse(args)
return services.installer.previewInstalledUpdate(pluginKey)
const preview = await services.installer.previewInstalledUpdate(pluginKey)
const installed = pluginService.findValidPlugin(pluginKey)
if (!installed) {
return preview
}
return { ...preview, accessGrowth: await readPreviewAccessGrowth(installed, preview) }
})
ipcMain.handle(
'plugins:getAutoUpdateState',
(): PluginAutoUpdateState =>
services.autoUpdater?.getState() ?? { updated: {}, needsReview: {} }
)
ipcMain.handle('plugins:getRollbackVersion', (_event, args: unknown) => {
const { pluginKey } = installedPluginSchema.parse(args)
return services.installer.rollbackVersion(pluginKey)
})
ipcMain.handle('plugins:rollbackMarketplacePlugin', async (_event, args: unknown) => {
const { pluginKey } = installedPluginSchema.parse(args)
@@ -4,13 +4,13 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import { panelSessionTokenSchema } from '../../shared/plugins/plugin-panel-bridge'
import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest'
import { panelSessionTokenSchema } from '../../shared/plugins/frames/plugin-frame-bridge'
import type { Store } from '../persistence'
import { PluginContentVerifier } from '../plugins/plugin-content-integrity'
import type { ValidDiscoveredPlugin } from '../plugins/plugin-discovery'
import { PluginPanelController } from '../plugins/plugin-panel-controller'
import { PluginPanelSessions } from '../plugins/plugin-panel-sessions'
import { PluginPanelController } from '../plugins/panels/plugin-panel-controller'
import { PluginPanelSessions } from '../plugins/panels/plugin-panel-sessions'
import type { PluginService } from '../plugins/plugin-service'
const mocks = vi.hoisted(() => ({
+4 -4
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile'
import type { PluginLockfile } from '../../shared/plugins/installation/plugin-install-lockfile'
import type { PluginService } from '../plugins/plugin-service'
import type { Store } from '../persistence'
@@ -23,19 +23,19 @@ beforeEach(() => {
describe('plugin consent IPC schema', () => {
it('requires the fingerprint reviewed by the caller', () => {
expect(() =>
parsePluginConsentArgs({ pluginKey: 'orca-samples.demo', decision: 'approve' })
parsePluginConsentArgs({ pluginKey: 'samples.demo', decision: 'approve' })
).toThrow()
})
it('accepts an explicit reviewed fingerprint', () => {
expect(
parsePluginConsentArgs({
pluginKey: 'orca-samples.demo',
pluginKey: 'samples.demo',
reviewedFingerprint: 'sha256-reviewed',
decision: 'approve'
})
).toEqual({
pluginKey: 'orca-samples.demo',
pluginKey: 'samples.demo',
reviewedFingerprint: 'sha256-reviewed',
decision: 'approve'
})
+90 -43
View File
@@ -1,29 +1,28 @@
import { pluginThemeSettingsAfterRemoval } from '../../shared/plugin-theme-settings'
import { ipcMain } from 'electron'
import { ipcMain, type IpcMainInvokeEvent } from 'electron'
import { z } from 'zod'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type {
PluginPanelActionOutcome,
PluginPanelEntry
} from '../../shared/plugins/plugin-panel-bridge'
import type { PluginFrameEntry } from '../../shared/plugins/frames/plugin-frame-bridge'
import type { PluginHostCallOutcome } from '../../shared/plugins/frames/plugin-protocol'
import { getUserPluginsDir, getPluginsDataDir } from '../plugins/plugin-discovery'
import {
installPluginFromGit,
installPluginFromLocalPath,
readPluginLockfile,
removeInstalledPlugin
} from '../plugins/plugin-install'
} from '../plugins/installation/plugin-install'
import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement'
import { readApprovedPluginNewFileTemplate } from '../plugins/plugin-new-file-template'
import type { PluginService } from '../plugins/plugin-service'
import { bindPluginPanelOwnerLifecycle } from '../plugins/plugin-panel-owner-lifecycle'
import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest'
import { pluginConsentRequestSchema } from '../../shared/plugins/plugin-consent-request'
import { normalizePluginIdList } from '../../shared/plugins/plugin-consent-state'
import { bindPluginPanelOwnerLifecycle } from '../plugins/panels/plugin-panel-owner-lifecycle'
import { isQualifiedPluginKey } from '../../shared/plugins/manifest/plugin-manifest'
import { pluginConsentRequestSchema } from '../../shared/plugins/approval/plugin-consent-request'
import { normalizePluginIdList } from '../../shared/plugins/approval/plugin-consent-state'
import {
isAllowedPluginGitUrl,
type PluginLockfile
} from '../../shared/plugins/plugin-install-lockfile'
} from '../../shared/plugins/installation/plugin-install-lockfile'
import {
registerPluginMarketplaceHandlers,
type PluginMarketplaceHandlerServices
@@ -43,6 +42,16 @@ const readPanelEntryArgsSchema = z.object({
panelId: z.string().min(1)
})
const readCustomEditorEntryArgsSchema = z.object({
pluginKey: z.string().min(1),
editorId: z.string().min(1)
})
const readNewFileTemplateArgsSchema = z.object({
pluginKey: z.string().min(1),
newFileId: z.string().min(1)
})
const invokeCommandArgsSchema = z.object({
pluginKey: z.string().min(1),
commandId: z.string().min(1),
@@ -69,7 +78,7 @@ const removeArgsSchema = z.object({
const logsArgsSchema = z.object({ pluginKey: z.string().min(1) })
// Preserve import compatibility; runtime RPC uses the Electron-free client-list module.
import { listPluginsForClients } from '../plugins/plugin-client-list'
import { listPluginsForClients, readInstalledPluginManifest } from '../plugins/plugin-client-list'
export function canRemoveInstalledPlugin(
pluginService: PluginService,
@@ -108,18 +117,28 @@ export function registerPluginHandlers(
// All handlers must await startup discovery to avoid returning a pre-discovery empty list.
ipcMain.handle('plugins:list', async () => listPluginsForClients(pluginService))
ipcMain.handle('plugins:listLanguagePacks', async () => {
await pluginService.whenReady()
return pluginService.contentPacks.languagePacks.list()
})
ipcMain.handle('plugins:listThemes', async () => {
await pluginService.whenReady()
return pluginService.contentPacks.themes.list()
})
ipcMain.handle('plugins:listTerminalThemes', async () => {
await pluginService.whenReady()
return pluginService.contentPacks.terminalThemes.list()
ipcMain.handle('plugins:getInstalledManifest', async (_event, args: unknown) => {
const { pluginKey } = logsArgsSchema.parse(args)
return readInstalledPluginManifest(pluginService, pluginKey)
})
const handleContentPacks = (channel: string, read: (arg: unknown) => unknown): void => {
ipcMain.handle(channel, async (_event, arg: unknown) => {
await pluginService.whenReady()
return read(arg)
})
}
const { contentPacks } = pluginService
handleContentPacks('plugins:listLanguagePacks', () => contentPacks.languagePacks.list())
handleContentPacks('plugins:listThemes', () => contentPacks.themes.list())
handleContentPacks('plugins:listTerminalThemes', () => contentPacks.terminalThemes.list())
handleContentPacks('plugins:listIconThemes', () => contentPacks.iconThemes.list())
handleContentPacks('plugins:loadIconTheme', (id) =>
typeof id === 'string' ? contentPacks.iconThemes.load(id) : null
)
handleContentPacks('plugins:listSyntaxes', () => contentPacks.syntaxes.list())
handleContentPacks('plugins:loadSyntax', (id) =>
typeof id === 'string' ? contentPacks.syntaxes.load(id) : null
)
ipcMain.handle('plugins:consent', async (event, args: unknown) => {
await pluginService.whenReady()
const parsed = parsePluginConsentArgs(args)
@@ -148,28 +167,43 @@ export function registerPluginHandlers(
})
// Sandboxed iframe srcdoc needs CSP-wrapped HTML contents across IPC.
ipcMain.handle(
'plugins:readPanelEntry',
async (event, args: unknown): Promise<PluginPanelEntry | null> => {
const ownerKey = rendererPanelOwner(event.sender.id)
const ownerLease = bindPluginPanelOwnerLifecycle(event.sender, () =>
pluginService.panels.revokeOwner(ownerKey)
)
await pluginService.whenReady()
const parsed = readPanelEntryArgsSchema.parse(args)
return pluginService.panels.open(
ownerKey,
parsed.pluginKey,
parsed.panelId,
ownerLease.isCurrent
)
}
)
const openFrameEntry = async (
sender: IpcMainInvokeEvent['sender'],
open: (ownerKey: string, isCurrent: () => boolean) => Promise<PluginFrameEntry | null>
): Promise<PluginFrameEntry | null> => {
const ownerKey = rendererPanelOwner(sender.id)
const ownerLease = bindPluginPanelOwnerLifecycle(sender, () =>
pluginService.panels.revokeOwner(ownerKey)
)
await pluginService.whenReady()
// Why not revoke on a stale load: the owner key may already belong to a successor document.
return open(ownerKey, ownerLease.isCurrent)
}
// Renderer forwards panel actions; main enforces capabilities.
ipcMain.handle('plugins:readPanelEntry', (event, args: unknown) => {
const parsed = readPanelEntryArgsSchema.parse(args)
return openFrameEntry(event.sender, (ownerKey, isCurrent) =>
pluginService.panels.open(ownerKey, parsed.pluginKey, parsed.panelId, isCurrent)
)
})
ipcMain.handle('plugins:readCustomEditorEntry', (event, args: unknown) => {
const parsed = readCustomEditorEntryArgsSchema.parse(args)
return openFrameEntry(event.sender, (ownerKey, isCurrent) =>
pluginService.panels.openCustomEditor(ownerKey, parsed.pluginKey, parsed.editorId, isCurrent)
)
})
ipcMain.handle('plugins:readNewFileTemplate', async (_event, args: unknown) => {
const parsed = readNewFileTemplateArgsSchema.parse(args)
await pluginService.whenReady()
return readApprovedPluginNewFileTemplate(pluginService, parsed.pluginKey, parsed.newFileId)
})
// Renderer forwards frame host calls; main enforces capabilities and surfaces.
ipcMain.handle(
'plugins:panelAction',
async (event, args: unknown): Promise<PluginPanelActionOutcome> => {
async (event, args: unknown): Promise<PluginHostCallOutcome> => {
await pluginService.whenReady()
return pluginService.panels.execute(rendererPanelOwner(event.sender.id), args)
}
@@ -252,6 +286,19 @@ export function registerPluginHandlers(
return listPluginsForClients(pluginService)
})
if (marketplaceServices) {
registerPluginMarketplaceHandlers(pluginService, marketplaceServices)
registerPluginMarketplaceHandlers(
pluginService,
marketplaceServices,
() => store.getSettings(),
(pluginKey, reviewedFingerprint, originWebContentsId) =>
applyPluginConsent({
store,
pluginService,
pluginKey,
reviewedFingerprint,
decision: 'approve',
originWebContentsId
})
)
}
}
+2 -2
View File
@@ -543,8 +543,8 @@ describe('registerSettingsHandlers', () => {
) => Promise<unknown>
await handler(settingsInvokeEvent, {
pluginConsents: { 'orca-samples.demo': 'sha256-forged' },
disabledPlugins: ['orca-samples.demo']
pluginConsents: { 'samples.demo': 'sha256-forged' },
disabledPlugins: ['samples.demo']
})
expect(store.updateSettings).toHaveBeenCalledWith(
+4 -4
View File
@@ -150,7 +150,7 @@ describe('keybinding-file', () => {
filePath,
JSON.stringify({
keybindings: {
'plugin:orca-samples.tasks/open': 'Mod+Shift+T',
'plugin:samples.tasks/open': 'Mod+Shift+T',
'plugin:tasks/open': 'Mod+Alt+T'
}
}),
@@ -159,15 +159,15 @@ describe('keybinding-file', () => {
const snapshot = readKeybindingFile(filePath, 'linux')
expect(snapshot.overrides).toEqual({
'plugin:orca-samples.tasks/open': ['Mod+Shift+T']
'plugin:samples.tasks/open': ['Mod+Shift+T']
})
expect(snapshot.diagnostics).toMatchObject([
{ severity: 'warning', actionId: 'plugin:tasks/open' }
])
writeKeybindingOverride(filePath, 'linux', 'plugin:orca-samples.tasks/open', [])
writeKeybindingOverride(filePath, 'linux', 'plugin:samples.tasks/open', [])
expect(readKeybindingFile(filePath, 'linux').overrides).toEqual({
'plugin:orca-samples.tasks/open': []
'plugin:samples.tasks/open': []
})
})
@@ -0,0 +1,20 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
// Each setting whose default flipped once, paired with its one-shot migration guard.
const DEFAULT_ON_MIGRATION_GUARDS = [
['terminalTuiScrollSensitivity', 'terminalTuiScrollSensitivityDefaultedToOne'],
['autoRenameBranchFromWork', 'autoRenameBranchFromWorkDefaultedOn'],
['pluginSystemEnabled', 'pluginSystemDefaultedOn']
] as const satisfies readonly (readonly [keyof GlobalSettings, keyof GlobalSettings])[]
/** Explicit writes (or attempts to clear a guard) keep the guard set so later choices stick. */
export function stampDefaultOnMigrationGuards(
updates: Partial<GlobalSettings>,
sanitizedUpdates: Partial<GlobalSettings>
): void {
for (const [setting, guard] of DEFAULT_ON_MIGRATION_GUARDS) {
if (setting in updates || guard in updates) {
sanitizedUpdates[guard] = true
}
}
}
@@ -0,0 +1,70 @@
import { closeTestStores, testState, createStore, writeDataFile } from '../persistence-test-harness'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
}
}))
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../telemetry/cohort-classifier', () => ({
getCohortAtEmit: () => ({ nth_repo_added: 2 })
}))
function writeSettings(settings: Record<string, unknown>): void {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings,
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
}
describe('plugin system default-on migration', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
it('turns plugins on for new profiles', async () => {
const store = await createStore()
expect(store.getSettings().pluginSystemEnabled).toBe(true)
expect(store.getSettings().pluginSystemDefaultedOn).toBe(true)
})
it('flips a saved pre-rollout false on once', async () => {
writeSettings({ pluginSystemEnabled: false })
const store = await createStore()
expect(store.getSettings().pluginSystemEnabled).toBe(true)
expect(store.getSettings().pluginSystemDefaultedOn).toBe(true)
})
it('keeps a deliberate false saved after the migration', async () => {
writeSettings({ pluginSystemEnabled: false, pluginSystemDefaultedOn: true })
const store = await createStore()
expect(store.getSettings().pluginSystemEnabled).toBe(false)
})
it('stamps the guard on explicit writes so they survive reload', async () => {
const store = await createStore()
const updated = store.updateSettings({
pluginSystemEnabled: false,
pluginSystemDefaultedOn: false
})
expect(updated.pluginSystemEnabled).toBe(false)
expect(updated.pluginSystemDefaultedOn).toBe(true)
})
})
@@ -27,14 +27,14 @@ describe('normalizeRightSidebarTab', () => {
})
it('preserves well-formed plugin panel tabs', () => {
expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/dashboard')).toBe(
'plugin:orca-samples.my-plugin/dashboard'
expect(normalizeRightSidebarTab('plugin:samples.my-plugin/dashboard')).toBe(
'plugin:samples.my-plugin/dashboard'
)
})
it('normalizes malformed plugin tabs to the default tab', () => {
expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin')).toBe('explorer')
expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/panel/extra')).toBe('explorer')
expect(normalizeRightSidebarTab('plugin:samples.my-plugin')).toBe('explorer')
expect(normalizeRightSidebarTab('plugin:samples.my-plugin/panel/extra')).toBe('explorer')
expect(normalizeRightSidebarTab('plugin:My_Plugin/Panel!')).toBe('explorer')
})
@@ -1,5 +1,6 @@
import { publishSettingsUpdate } from './settings-update-effects'
import { legacyChatPermissionWrite } from './legacy-chat-permission-write'
import { stampDefaultOnMigrationGuards } from './default-on-migration-guards'
import { normalizeNativeChatAppearanceSettings } from '../../../shared/native-chat/native-chat-appearance-settings'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
@@ -10,7 +11,7 @@ import { sanitizeAgentLaunchProfileUpdates } from '../../../shared/tui-agent-lau
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
import { assertTerminalQuickCommandsStorable } from '../../../shared/terminal-quick-command-prompt-limit'
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
import { normalizePluginThemeSettings } from '../../../shared/plugin-theme-settings'
import { normalizePluginOwnedSettings } from '../../../shared/plugins/capabilities/plugin-owned-settings'
import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell'
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy'
@@ -129,7 +130,7 @@ export function updateSettings(
updates.terminalCustomThemes
)
}
Object.assign(sanitizedUpdates, normalizePluginThemeSettings(updates))
Object.assign(sanitizedUpdates, normalizePluginOwnedSettings(updates))
if ('terminalCursorStyle' in updates) {
Object.assign(
sanitizedUpdates,
@@ -150,12 +151,6 @@ export function updateSettings(
updates.terminalMinimumContrastRatio
)
}
if (
'terminalTuiScrollSensitivity' in updates ||
'terminalTuiScrollSensitivityDefaultedToOne' in updates
) {
sanitizedUpdates.terminalTuiScrollSensitivityDefaultedToOne = true
}
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
const taskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders:
@@ -173,9 +168,7 @@ export function updateSettings(
sanitizedUpdates.visibleTaskProvidersDefaultedForJira = true
}
}
if ('autoRenameBranchFromWork' in updates || 'autoRenameBranchFromWorkDefaultedOn' in updates) {
sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true
}
stampDefaultOnMigrationGuards(updates, sanitizedUpdates)
if ('openInApplications' in updates) {
sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications)
}
@@ -77,6 +77,8 @@ type RetiredGlobalSettings = {
opencodeGoApiKey?: unknown
experimentalStructuredNativeChat?: unknown
openAgentTabsInChatByDefault?: unknown
// Full-access plugins now need only their own approval.
pluginFullAccessEnabled?: unknown
}
export function stripRetiredGlobalSettings(
@@ -92,6 +94,7 @@ export function stripRetiredGlobalSettings(
opencodeGoApiKey: _legacyOpenCodeGoApiKey,
experimentalStructuredNativeChat: _legacyStructuredNativeChat,
openAgentTabsInChatByDefault: _legacyChatDefaultView,
pluginFullAccessEnabled: _retiredPluginFullAccessSwitch,
...rest
} = (settings ?? {}) as Partial<GlobalSettings> & RetiredGlobalSettings
void _retiredChatPermissionCopies
@@ -103,6 +106,7 @@ export function stripRetiredGlobalSettings(
void _legacyOpenCodeGoApiKey
void _legacyStructuredNativeChat
void _legacyChatDefaultView
void _retiredPluginFullAccessSwitch
return rest
}
@@ -1,6 +1,6 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getDefaultUIState } from '../../../shared/constants'
import { isPluginPanelTabKey } from '../../../shared/plugins/plugin-manifest'
import { isPluginPanelTabKey } from '../../../shared/plugins/manifest/plugin-manifest'
export function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] {
if (
@@ -6,7 +6,7 @@ import { normalizeOpenInApplications } from '../../../shared/open-in-application
import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings'
import { normalizeAppIconId } from '../../../shared/app-icon'
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
import { normalizePluginThemeSettings } from '../../../shared/plugin-theme-settings'
import { normalizePluginOwnedSettings } from '../../../shared/plugins/capabilities/plugin-owned-settings'
import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell'
import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai'
import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat/native-chat-shell-environment'
@@ -43,6 +43,7 @@ export function normalizeLoadedGlobalSettings(
migratedPortugueseUiLanguage,
migratedExperimentalActivity,
migratedAutoRenameBranchFromWork,
migratedPluginSystemEnabled,
migratedTerminalCursorStyle,
migratedTerminalLineHeight,
terminalRightClickToPasteDefaultedForPlatform,
@@ -89,6 +90,7 @@ export function normalizeLoadedGlobalSettings(
primarySelectionMiddleClickPasteDefaultedForTerminalDefaults:
primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults,
...migratedAutoRenameBranchFromWork,
...migratedPluginSystemEnabled,
...migratedTerminalCursorStyle,
terminalLineHeight: migratedTerminalLineHeight,
// False was always explicit; the old true default may be reset once.
@@ -121,7 +123,7 @@ export function normalizeLoadedGlobalSettings(
terminalDefaultShell: normalizeTerminalDefaultShell(parsed.settings?.terminalDefaultShell),
terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands),
terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes),
...normalizePluginThemeSettings(parsed.settings ?? {}),
...normalizePluginOwnedSettings(parsed.settings ?? {}),
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
mobilePairingCustomAddress,
mobilePairingCustomAddresses,
@@ -4,6 +4,7 @@ import type { ProjectGroup } from '../../../shared/project-group-types'
import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime'
import { normalizeTaskProviderSettings } from '../../../shared/task-providers'
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings'
import { normalizePluginSystemEnabledDefaultOn } from '../../../shared/plugins/plugin-system-settings'
import {
addMobilePairingCustomAddress,
normalizeMobilePairingCustomAddress,
@@ -35,6 +36,7 @@ export type PreparedLoadedProfileSettings = {
GlobalSettings,
'autoRenameBranchFromWork' | 'autoRenameBranchFromWorkDefaultedOn'
>
migratedPluginSystemEnabled: ReturnType<typeof normalizePluginSystemEnabledDefaultOn>
migratedTerminalCursorStyle: Pick<
GlobalSettings,
'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock'
@@ -87,6 +89,10 @@ export function prepareLoadedProfileSettings(
const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn(
parsed.settings
)
const migratedPluginSystemEnabled = normalizePluginSystemEnabledDefaultOn(parsed.settings)
if (parsed.settings?.pluginSystemDefaultedOn !== true) {
markNeedsSave()
}
const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings)
if (
parsed.settings?.terminalCursorStyle !== migratedTerminalCursorStyle.terminalCursorStyle ||
@@ -258,6 +264,7 @@ export function prepareLoadedProfileSettings(
migratedPortugueseUiLanguage,
migratedExperimentalActivity,
migratedAutoRenameBranchFromWork,
migratedPluginSystemEnabled,
migratedTerminalCursorStyle,
migratedTerminalLineHeight,
terminalRightClickToPasteDefaultedForPlatform,
@@ -11,7 +11,9 @@ export async function serveRepositoriesOverTestSsh(
const shim = join(shimDirectory, 'git-ssh-shim.cjs')
await writeFile(
shim,
await readFile(join(import.meta.dirname, '..', 'plugin-private-marketplace-ssh-shim.cjs'))
await readFile(
join(import.meta.dirname, '..', 'catalog', 'plugin-private-marketplace-ssh-shim.cjs')
)
)
const node = process.execPath.replaceAll('\\', '/')
vi.stubEnv('GIT_SSH_COMMAND', `"${node}" "${shim.replaceAll('\\', '/')}"`)
@@ -6,12 +6,12 @@ import type * as ChildProcess from 'node:child_process'
import { PluginContentVerifier } from '../plugin-content-integrity'
import { PluginService } from '../plugin-service'
import { registerPluginHandlers } from '../../ipc/plugins'
import { installPluginFromLocalPath, installBundledPlugin } from '../plugin-install'
import { installStagedPluginTree } from '../plugin-install-staging'
import { installPluginFromLocalPath, installBundledPlugin } from '../installation/plugin-install'
import { installStagedPluginTree } from '../installation/plugin-install-staging'
import { getUserPluginsDir } from '../plugin-discovery'
import { PluginWorkerController } from '../plugin-worker-controller'
import { PluginWorkerController } from '../workers/plugin-worker-controller'
import { UninstallWorkerPort } from '../plugin-uninstall-worker-fixture'
import type { PluginManifest } from '../../../shared/plugins/plugin-manifest'
import type { PluginManifest } from '../../../shared/plugins/manifest/plugin-manifest'
const ports = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
@@ -113,7 +113,7 @@ export async function createUninstallFixture() {
pluginApi: 1,
main: 'worker.js',
contributes: { commands: [{ id: 'run', title: 'Run' }], ...contributes },
capabilities: contributes.events ? [{ kind: 'events:subscribe' }] : []
capabilities: contributes.events ? [{ kind: 'workspace:read' }, { kind: 'agent:read' }] : []
}
await writeFile(join(sourcePath, 'orca-plugin.json'), JSON.stringify(manifest))
await writeFile(join(sourcePath, 'worker.js'), 'export default function () {}')
@@ -125,7 +125,15 @@ export async function createUninstallFixture() {
pluginsDir: getUserPluginsDir(root),
stagingDir: sourcePath,
hostVersion: '1.4.0',
source: { kind: 'git', url: 'https://github.com/stablyai/orca-plugins.git', ref: 'main' },
source: {
kind: 'marketplace',
marketplace: {
url: 'https://github.com/stablyai/orca-plugins.git',
ref: 'main',
resolvedCommit: '2'.repeat(40)
},
plugin: { url: `https://github.com/stablyai/${id}.git`, ref: 'main' }
},
resolvedCommit: '1'.repeat(40)
})
: await installPluginFromLocalPath({
@@ -0,0 +1,304 @@
import { describe, expect, it, vi } from 'vitest'
import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint'
import {
pluginManifestSchema,
type PluginManifest
} from '../../../shared/plugins/manifest/plugin-manifest'
import {
isPluginAutoUpdateOn,
normalizePluginUpdatePolicySettings,
type PluginAutoUpdateState
} from '../../../shared/plugins/catalog/plugin-update-policy'
import type { ValidDiscoveredPlugin } from '../plugin-discovery'
import { PLUGIN_AUTO_UPDATE_HOLD_BACK_MS, PluginAutoUpdater } from './plugin-auto-updater'
const KEY = 'samples.demo'
const REPO = 'https://example.com/samples/demo.git'
const HOUR = 60 * 60 * 1000
const COMMIT = 'c'.repeat(40)
function release(version: string) {
return {
version,
commit: COMMIT,
contentHash: 'sha-new',
execution: 'trusted-worker' as const,
minimumOrca: '1.0.0'
}
}
function manifest(version: string, overrides: Partial<PluginManifest> = {}): PluginManifest {
return pluginManifestSchema.parse({
manifestVersion: 1,
id: 'demo',
publisher: 'samples',
name: 'Demo',
version,
engines: { orca: '>=1.0.0' },
pluginApi: 1,
main: 'worker.js',
capabilities: [{ kind: 'notifications:show' }],
...overrides
})
}
function discovered(m: PluginManifest, contentHash: string, isDev = false): ValidDiscoveredPlugin {
return {
pluginKey: KEY,
rootDir: `/plugins/${KEY}/${contentHash}`,
manifest: m,
consentFingerprint: fingerprintPluginConsent(m, contentHash),
contentHash,
isDev
}
}
function setup(
options: {
next?: PluginManifest
officialCatalog?: boolean
repository?: string
approved?: boolean
disabled?: boolean
dev?: boolean
enabled?: boolean
blocked?: boolean
pinned?: boolean
previewCommit?: string
refreshMs?: number
killListRefresh?: () => Promise<unknown>
} = {}
) {
let now = 1_000_000
// Why first pass only: later refreshes hit warm caches and finish sooner.
let refreshMs = options.refreshMs ?? 0
const previous = discovered(manifest('1.0.0'), 'sha-old', options.dev)
const nextManifest = options.next ?? manifest('1.1.0')
const next = discovered(nextManifest, 'sha-new')
let current = previous
const consents: Record<string, string> =
options.approved === false ? {} : { [KEY]: previous.consentFingerprint }
const firstSeen = new Map<string, { release: string; at: number }>()
const install = vi.fn(async () => ({
ok: true as const,
pluginKey: KEY,
version: nextManifest.version,
contentHash: 'sha-new',
consentFingerprint: next.consentFingerprint,
resolvedCommit: null
}))
const changes: { state: PluginAutoUpdateState; updated: Record<string, string> }[] = []
const updater = new PluginAutoUpdater({
pluginService: {
options: {
isPluginSystemEnabled: () => true,
getPluginConsents: () => consents,
getDisabledPlugins: () => (options.disabled ? [KEY] : [])
},
getDiscovered: () => [current],
findValidPlugin: () => current,
refresh: async () => {
current = next
}
},
installer: {
findInstalledUpdateListing: vi.fn(async () => ({
listing: {
marketplaceSourceId: 'a'.repeat(32),
marketplaceName: 'Samples',
marketplaceOwner: 'samples',
marketplaceCommit: 'b'.repeat(40),
pluginKey: KEY,
source: { kind: 'git' as const, url: REPO, ref: 'main' },
categories: [],
official: false,
bundled: false,
version: nextManifest.version,
...(options.pinned === false ? {} : { release: release(nextManifest.version) }),
...(options.blocked ? { blockedByKillList: { reason: 'malware' } } : {})
},
officialCatalog: options.officialCatalog === true
})),
previewInstalledUpdate: vi.fn(async () => ({
marketplaceSourceId: 'a'.repeat(32),
marketplaceName: 'Samples',
marketplaceOwner: 'samples',
marketplaceCommit: 'b'.repeat(40),
pluginKey: KEY,
source: { kind: 'git' as const, url: options.repository ?? REPO, ref: 'main' },
...(options.repository ? { repositoryMoved: true as const } : {}),
resolvedCommit: options.previewCommit ?? COMMIT,
contentHash: 'sha-new',
consentFingerprint: next.consentFingerprint,
manifest: nextManifest,
official: false,
bundled: false
})),
install
},
firstSeen: {
firstSeen: async (pluginKey, release, at) => {
const known = firstSeen.get(pluginKey)
if (known?.release === release) {
return known.at
}
firstSeen.set(pluginKey, { release, at })
return at
}
},
isEnabled: () => options.enabled !== false,
refreshCatalogs: async () => {
now += refreshMs
refreshMs = 0
},
refreshKillList: options.killListRefresh ?? (async () => undefined),
approve: async (pluginKey, fingerprint) => {
consents[pluginKey] = fingerprint
},
onChanged: (state, updated) => changes.push({ state, updated }),
now: () => now
})
return {
updater,
install,
consents,
next,
changes,
advance: (ms: number) => {
now += ms
}
}
}
describe('plugin auto-update', () => {
it('holds a release back until it has been listed for 12 hours, then installs it in place', async () => {
const { updater, install, consents, next, changes, advance } = setup()
await updater.run()
expect(install).not.toHaveBeenCalled()
advance(PLUGIN_AUTO_UPDATE_HOLD_BACK_MS - HOUR)
await updater.run()
expect(install).not.toHaveBeenCalled()
advance(HOUR)
await updater.run()
expect(install).toHaveBeenCalledTimes(1)
expect(install).toHaveBeenCalledWith(expect.anything(), { requireInstalled: true })
expect(consents[KEY]).toBe(next.consentFingerprint)
expect(updater.getState()).toEqual({ updated: { [KEY]: '1.1.0' }, needsReview: {} })
expect(changes.at(-1)?.updated).toEqual({ [KEY]: '1.1.0' })
})
it('installs on the pass one hold later even when the first catalog refresh was slow', async () => {
const { updater, install, advance } = setup({ refreshMs: 60_000 })
await updater.run()
advance(PLUGIN_AUTO_UPDATE_HOLD_BACK_MS - 60_000)
await updater.run()
expect(install).toHaveBeenCalledTimes(1)
})
it('refreshes the kill list before installing and installs nothing when that fails', async () => {
const order: string[] = []
const ok = setup({
officialCatalog: true,
killListRefresh: async () => order.push('kill-list')
})
ok.install.mockImplementation(async () => {
order.push('install')
throw new Error('stop')
})
await ok.updater.run()
expect(order).toEqual(['kill-list', 'install'])
const offline = setup({
officialCatalog: true,
killListRefresh: async () => {
throw new Error('offline')
}
})
await offline.updater.run()
expect(offline.install).not.toHaveBeenCalled()
})
it('installs at once from an official catalog and logs it for hosts without a UI', async () => {
const info = vi.spyOn(console, 'info').mockImplementation(() => undefined)
const { updater, install } = setup({ officialCatalog: true })
await updater.run()
expect(install).toHaveBeenCalledTimes(1)
expect(info).toHaveBeenCalledWith(`[plugins] auto-updated ${KEY} to 1.1.0`)
info.mockRestore()
})
it('leaves an update that asks for more access uninstalled and marked for review', async () => {
const { updater, install, consents } = setup({
officialCatalog: true,
next: manifest('1.1.0', {
capabilities: [{ kind: 'notifications:show' }, { kind: 'terminal:send' }]
})
})
const before = consents[KEY]
await updater.run()
expect(install).not.toHaveBeenCalled()
expect(consents[KEY]).toBe(before)
expect(updater.getState().needsReview).toEqual({ [KEY]: '1.1.0' })
})
it('marks an update from a moved repository for review', async () => {
const { updater, install } = setup({
officialCatalog: true,
repository: 'https://example.com/someone-else/demo.git'
})
await updater.run()
expect(install).not.toHaveBeenCalled()
expect(updater.getState().needsReview).toEqual({ [KEY]: '1.1.0' })
})
it('skips disabled, unapproved, development, blocked plugins and the manual policy', async () => {
for (const options of [
{ disabled: true },
{ approved: false },
{ dev: true },
{ blocked: true },
{ enabled: false }
]) {
const { updater, install } = setup({ officialCatalog: true, ...options })
await updater.run()
expect(install, JSON.stringify(options)).not.toHaveBeenCalled()
}
})
it('never auto-installs an unpinned listing, whose bytes are a moving branch head', async () => {
const { updater, install, advance } = setup({ pinned: false })
await updater.run()
advance(PLUGIN_AUTO_UPDATE_HOLD_BACK_MS)
await updater.run()
expect(install).not.toHaveBeenCalled()
})
it('ignores a release re-pinned after a commit that only touched a sibling plugin', async () => {
// Why: the plugin's directory, so its version and hash, did not change; only `commit` did.
const { updater, install } = setup({ officialCatalog: true, next: manifest('1.0.0') })
await updater.run()
expect(install).not.toHaveBeenCalled()
expect(updater.getState()).toEqual({ updated: {}, needsReview: {} })
})
it('installs only the release that was held, not one listed since', async () => {
const { updater, install } = setup({ officialCatalog: true, previewCommit: 'd'.repeat(40) })
await updater.run()
expect(install).not.toHaveBeenCalled()
})
it('keeps the policy a bounded union with auto as the default and unknown values manual', () => {
expect(normalizePluginUpdatePolicySettings({})).toEqual({})
expect(isPluginAutoUpdateOn({})).toBe(true)
expect(
isPluginAutoUpdateOn(
normalizePluginUpdatePolicySettings({ pluginUpdatePolicy: 'official-only' })
)
).toBe(false)
expect(normalizePluginUpdatePolicySettings({ pluginUpdatePolicy: 'manual' })).toEqual({
pluginUpdatePolicy: 'manual'
})
expect(normalizePluginUpdatePolicySettings({ pluginUpdatePolicy: 'official-only' })).toEqual({
pluginUpdatePolicy: 'manual'
})
})
})
@@ -0,0 +1,196 @@
import { compareAppVersions } from '../../../shared/app-version'
import { pluginUpdateGrowsAccess } from '../../../shared/plugins/approval/plugin-update-access-growth'
import type { PluginAutoUpdateState } from '../../../shared/plugins/catalog/plugin-update-policy'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from '../plugin-discovery'
import {
installCatalogReleaseWithApproval,
readApprovedPredecessor,
readPreviewAccessGrowth,
type PluginCatalogInstallOutcome
} from './plugin-update-approval'
import type { PluginPolicySource } from '../plugin-activation-policy'
import type { PluginMarketplaceInstaller } from './plugin-marketplace-installer'
import type { PluginReleaseFirstSeenStore } from './plugin-release-first-seen-store'
// Why half the hold: a release first seen one pass ago is old enough by the second pass, not the third.
export const PLUGIN_AUTO_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000
/** Why: a release must stay listed this long before it installs unasked, so takedowns can land. */
export const PLUGIN_AUTO_UPDATE_HOLD_BACK_MS = 12 * 60 * 60 * 1000
type AutoUpdatePluginService = {
readonly options: PluginPolicySource
getDiscovered: () => readonly DiscoveredPlugin[]
findValidPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null
refresh: () => Promise<void>
}
type AutoUpdateInstaller = Pick<
PluginMarketplaceInstaller,
'findInstalledUpdateListing' | 'previewInstalledUpdate' | 'install'
>
/**
* Installs catalog updates for enabled plugins when they ask for nothing new, like an editor's
* extension auto-update. An update that grows access, or comes from a moved repository, is left
* uninstalled and marked for review; the running version keeps running.
*/
export class PluginAutoUpdater {
private readonly options: {
pluginService: AutoUpdatePluginService
installer: AutoUpdateInstaller
firstSeen: Pick<PluginReleaseFirstSeenStore, 'firstSeen'>
isEnabled: () => boolean
refreshCatalogs: () => Promise<unknown>
refreshKillList: () => Promise<unknown>
approve: (pluginKey: string, reviewedFingerprint: string) => Promise<void>
onChanged: (state: PluginAutoUpdateState, updatedNow: PluginAutoUpdateState['updated']) => void
now?: () => number
}
private state: PluginAutoUpdateState = { updated: {}, needsReview: {} }
private running: Promise<void> | null = null
private timer: ReturnType<typeof setInterval> | null = null
constructor(options: PluginAutoUpdater['options']) {
this.options = options
}
getState(): PluginAutoUpdateState {
return this.state
}
/** One pass; concurrent callers share it. */
run(): Promise<void> {
this.running ??= this.performRun().finally(() => {
this.running = null
})
return this.running
}
syncPeriodicRun(enabled: boolean, intervalMs = PLUGIN_AUTO_UPDATE_INTERVAL_MS): void {
if (!enabled) {
this.dispose()
return
}
if (this.timer) {
return
}
this.timer = setInterval(() => {
void this.run().catch((error) => console.warn('[plugins] auto-update failed:', error))
}, intervalMs)
this.timer.unref?.()
}
dispose(): void {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
}
private async performRun(): Promise<void> {
if (!this.options.isEnabled()) {
return
}
// Why before the refresh: a pass that starts exactly one hold later must see the hold as over.
const startedAt = (this.options.now ?? Date.now)()
await this.options
.refreshCatalogs()
.catch((error) => console.warn('[plugins] auto-update kept cached catalogs:', error))
// Why once, lazily: the kill list can be hours old, so take a fresh copy only when about to install.
let killListFresh: Promise<void> | null = null
const refreshKillList = (): Promise<void> =>
(killListFresh ??= this.options.refreshKillList().then(() => undefined))
const updatedNow: PluginAutoUpdateState['updated'] = {}
const needsReview: PluginAutoUpdateState['needsReview'] = {}
for (const plugin of this.options.pluginService.getDiscovered()) {
if (isInvalidDiscoveredPlugin(plugin) || !this.options.isEnabled()) {
continue
}
try {
const verdict = await this.updateOne(plugin, startedAt, refreshKillList)
// Why logged: a headless or remote host has no toast, so the log is the only record.
if (verdict?.kind === 'updated') {
updatedNow[plugin.pluginKey] = verdict.version
console.info(`[plugins] auto-updated ${plugin.pluginKey} to ${verdict.version}`)
} else if (verdict?.kind === 'needs-review') {
needsReview[plugin.pluginKey] = verdict.version
console.info(`[plugins] update ${verdict.version} of ${plugin.pluginKey} needs review`)
}
} catch (error) {
console.warn(`[plugins] auto-update skipped ${plugin.pluginKey}:`, error)
}
}
const updated = { ...this.state.updated, ...updatedNow }
for (const key of Object.keys(needsReview)) {
delete updated[key]
}
this.state = { updated, needsReview }
this.options.onChanged(this.state, updatedNow)
}
private async updateOne(
plugin: ValidDiscoveredPlugin,
now: number,
refreshKillList: () => Promise<void>
): Promise<{ kind: 'updated' | 'needs-review'; version: string } | null> {
const { pluginService, installer } = this.options
// Why approved only: a disabled or never-approved plugin is the user's to update.
if (!readApprovedPredecessor(pluginService.options, plugin)) {
return null
}
const found = await installer.findInstalledUpdateListing(plugin.pluginKey)
// Why pinned only: an unpinned listing installs a branch head, which can move after the hold.
const release = found?.listing.release
if (
!found ||
!release ||
found.listing.blockedByKillList ||
compareAppVersions(release.version, plugin.manifest.version) <= 0
) {
return null
}
if (!found.officialCatalog) {
const firstSeenAt = await this.options.firstSeen.firstSeen(
plugin.pluginKey,
`${release.version}@${release.commit}`,
now
)
if (now - firstSeenAt < PLUGIN_AUTO_UPDATE_HOLD_BACK_MS) {
return null
}
}
const preview = await installer.previewInstalledUpdate(plugin.pluginKey)
// Why: a catalog refresh since the hold check can list another release; install only the held one.
if (
preview.resolvedCommit !== release.commit ||
preview.contentHash !== release.contentHash ||
preview.contentHash === plugin.contentHash
) {
return null
}
if (
preview.repositoryMoved ||
pluginUpdateGrowsAccess(await readPreviewAccessGrowth(plugin, preview))
) {
return { kind: 'needs-review', version: preview.manifest.version }
}
// Why fail closed: without a current kill list a takedown could not stop this install.
await refreshKillList()
const outcome: PluginCatalogInstallOutcome = await installCatalogReleaseWithApproval({
pluginKey: plugin.pluginKey,
pluginService,
install: () => installer.install(preview, { requireInstalled: true }),
approve: this.options.approve
})
if (!outcome.ok) {
throw new Error(outcome.error)
}
return outcome.needsReview
? { kind: 'needs-review', version: outcome.version }
: { kind: 'updated', version: outcome.version }
}
}
@@ -0,0 +1,87 @@
import type { PluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog-release'
import { readPluginImageHeader } from '../../../shared/plugins/images/plugin-image-format'
import {
PLUGIN_IMAGE_RULES,
pluginImageRuleError,
type PluginCatalogImages,
type PluginImageKind
} from '../../../shared/plugins/images/plugin-image-fields'
import type { PluginMarketplace } from '../../../shared/plugins/catalog/plugin-marketplace'
import { sha256Hex } from '../plugin-image-cache'
// Why a catalog-wide cap: a large catalog must not turn one refresh into an unbounded download.
const CATALOG_IMAGES_MAX_BYTES = 64 * 1024 * 1024
type CatalogImage = NonNullable<PluginCatalogImages['cover']>
export type CatalogBlobReader = (path: string, maxBytes: number) => Promise<Buffer | null>
/** Reads every image a catalog references from the catalog repository at its commit, keeps the
* ones whose bytes match their hash, size and format, and drops the rest from the listing. */
export async function extractCatalogImages(
marketplace: PluginMarketplace,
readBlob: CatalogBlobReader
): Promise<{ marketplace: PluginMarketplace; images: Buffer[] }> {
const verified = new Map<string, Buffer>()
const rejected = new Set<string>()
let budget = CATALOG_IMAGES_MAX_BYTES
const check = async (image: CatalogImage, kind: PluginImageKind): Promise<boolean> => {
if (verified.has(image.sha256)) {
return true
}
if (rejected.has(image.sha256)) {
return false
}
const maxBytes = Math.min(PLUGIN_IMAGE_RULES[kind].maxBytes, budget)
const bytes = maxBytes > 0 ? await readBlob(image.path, maxBytes).catch(() => null) : null
const info = bytes ? readPluginImageHeader(bytes) : 'missing'
const ok =
bytes !== null &&
typeof info !== 'string' &&
sha256Hex(bytes) === image.sha256 &&
info.width === image.width &&
info.height === image.height &&
pluginImageRuleError(kind, info, bytes.byteLength) === null
if (ok && bytes) {
budget -= bytes.byteLength
verified.set(image.sha256, bytes)
} else {
rejected.add(image.sha256)
}
return ok
}
const keep = async (images: PluginCatalogImages): Promise<PluginCatalogImages> => {
const icon = images.icon && (await check(images.icon, 'icon')) ? images.icon : undefined
const cover = images.cover && (await check(images.cover, 'cover')) ? images.cover : undefined
const screenshots: CatalogImage[] = []
for (const shot of images.screenshots ?? []) {
if (await check(shot, 'screenshot')) {
screenshots.push(shot)
}
}
return {
...(icon ? { icon } : {}),
...(cover ? { cover } : {}),
...(screenshots.length ? { screenshots } : {})
}
}
const plugins: PluginMarketplace['plugins'] = []
for (const entry of marketplace.plugins) {
if (!entry.releases) {
plugins.push(entry)
continue
}
const releases: PluginCatalogRelease[] = []
for (const release of entry.releases) {
if (!release.images) {
releases.push(release)
continue
}
const { images, ...rest } = release
const kept = await keep(images)
releases.push(Object.keys(kept).length ? { ...rest, images: kept } : rest)
}
plugins.push({ ...entry, releases })
}
return { marketplace: { ...marketplace, plugins }, images: [...verified.values()] }
}
@@ -0,0 +1,91 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import type { PluginMarketplace } from '../../../shared/plugins/catalog/plugin-marketplace'
import { extractCatalogImages } from './plugin-catalog-image-extraction'
import { fillMonorepoReleaseImages } from './plugin-catalog-monorepo-images'
const CATALOG_URL = 'https://github.com/stablyai/orca-plugins.git'
const be32 = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]
function png(width: number, height: number): Buffer {
const ihdr = [...Buffer.from('IHDR'), ...be32(width), ...be32(height), 8, 6, 0, 0, 0]
return Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
0,
0,
0,
13,
...ihdr,
0,
0,
0,
0
])
}
const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')
function catalog(url: string, images?: Record<string, unknown>): PluginMarketplace {
return {
name: 'Official',
owner: 'stablyai',
plugins: [
{
id: 'stablyai.orca-midnight-theme',
source: { kind: 'git', url, ref: 'main', path: 'plugins/midnight-theme' },
categories: ['themes'],
releases: [
{
version: '2.1.0',
commit: 'c'.repeat(40),
contentHash: 'f'.repeat(64),
execution: 'data',
minimumOrca: '1.4.245',
...(images ? { images } : {})
}
]
}
]
}
}
const cover = png(1600, 1000)
const manifest = {
name: 'Orca Midnight',
cover: { path: 'images/cover.png', alt: 'Midnight on the editor' },
screenshots: [{ path: 'images/missing.png', alt: 'Gone' }]
}
const files: Record<string, Buffer> = {
'plugins/midnight-theme/orca-plugin.json': Buffer.from(JSON.stringify(manifest)),
'plugins/midnight-theme/images/cover.png': cover
}
const read = async (path: string): Promise<Buffer | null> => files[path] ?? null
describe('fillMonorepoReleaseImages', () => {
it('lists the manifest cover for a same-repository release published without images', async () => {
const filled = await fillMonorepoReleaseImages(catalog(CATALOG_URL), CATALOG_URL, read)
const extracted = await extractCatalogImages(filled, read)
expect(extracted.marketplace.plugins[0]?.releases?.[0]?.images).toEqual({
cover: {
path: 'plugins/midnight-theme/images/cover.png',
alt: 'Midnight on the editor',
sha256: sha(cover),
width: 1600,
height: 1000
}
})
expect(extracted.images).toEqual([cover])
})
it('leaves plugins hosted elsewhere and releases that declare images untouched', async () => {
const elsewhere = catalog('https://github.com/acme/midnight.git')
expect(await fillMonorepoReleaseImages(elsewhere, CATALOG_URL, read)).toEqual(elsewhere)
const declared = catalog(CATALOG_URL, {})
expect(await fillMonorepoReleaseImages(declared, CATALOG_URL, read)).toEqual(declared)
})
})
@@ -0,0 +1,47 @@
import {
isSameGitRepository,
type PluginMarketplace
} from '../../../shared/plugins/catalog/plugin-marketplace'
import { catalogImagesFromManifest } from '../../../shared/plugins/images/plugin-manifest-catalog-images'
import { sha256Hex } from '../plugin-image-cache'
import type { CatalogBlobReader } from './plugin-catalog-image-extraction'
const MANIFEST_MAX_BYTES = 256 * 1024
/**
* A catalog that keeps its plugins in its own repository already holds their images, so a newest
* release listed without `images` borrows the manifest's from the catalog commit. Extraction then
* checks those bytes like any declared image.
*/
export async function fillMonorepoReleaseImages(
marketplace: PluginMarketplace,
catalogUrl: string,
readBlob: CatalogBlobReader
): Promise<PluginMarketplace> {
const plugins: PluginMarketplace['plugins'] = []
for (const entry of marketplace.plugins) {
const [newest, ...older] = entry.releases ?? []
const prefix = entry.source.path
if (!newest || newest.images || !prefix || !isSameGitRepository(entry.source.url, catalogUrl)) {
plugins.push(entry)
continue
}
const manifestBytes = await readBlob(`${prefix}/orca-plugin.json`, MANIFEST_MAX_BYTES).catch(
() => null
)
let manifest: unknown = null
try {
manifest = manifestBytes ? JSON.parse(manifestBytes.toString('utf8')) : null
} catch {
manifest = null
}
const images = await catalogImagesFromManifest({
manifest,
prefix,
read: readBlob,
sha256: (bytes) => sha256Hex(Buffer.from(bytes))
})
plugins.push(images ? { ...entry, releases: [{ ...newest, images }, ...older] } : entry)
}
return { ...marketplace, plugins }
}
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest'
import type { ValidDiscoveredPlugin } from '../plugin-discovery'
import { findPluginFileTypeSuggestion } from './plugin-file-type-suggestion'
import type { PluginMarketplaceListing } from './plugin-marketplace-projection'
function listing(
pluginKey: string,
fileTypes: string[],
extra: Partial<PluginMarketplaceListing> = {}
) {
return {
marketplaceSourceId: 'official',
marketplaceName: 'Orca',
marketplaceOwner: 'stablyai',
marketplaceCommit: 'c'.repeat(40),
pluginKey,
source: { kind: 'git', url: `https://example.invalid/${pluginKey}.git`, ref: 'v1' },
categories: [],
official: true,
bundled: false,
release: {
version: '1.0.0',
commit: 'c'.repeat(40),
contentHash: 'f'.repeat(64),
execution: 'data',
minimumOrca: '1.4.0',
types: ['syntax'],
fileTypes
},
...extra
} satisfies PluginMarketplaceListing
}
function installed(pluginKey: string, extensions: string[]): ValidDiscoveredPlugin {
const [publisher = '', id = ''] = pluginKey.split('.')
const manifest = pluginManifestSchema.parse({
manifestVersion: 1,
id,
publisher,
name: id,
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: {
syntaxes: extensions.length
? [{ id: 'lang', title: 'Lang', scopeName: 'source.lang', path: 'g.json', extensions }]
: []
}
})
return {
pluginKey,
rootDir: '/tmp/none',
manifest,
consentFingerprint: fingerprintPluginConsent(manifest),
contentHash: null,
isDev: false
}
}
const on = { pluginSystemEnabled: true }
const listings = [
listing('community.typst', ['typ'], { official: false, marketplaceSourceId: 'community' }),
listing('samples.blocked', ['typ'], { blockedByKillList: { reason: 'revoked' } }),
listing('samples.first', ['typ'], {
release: { ...listing('x', []).release, fileTypes: ['typ'], name: 'Typst' }
}),
listing('samples.second', ['typ', 'gleam'])
]
describe('findPluginFileTypeSuggestion', () => {
it('picks the first official, unblocked catalog plugin and carries its release name', () => {
expect(
findPluginFileTypeSuggestion({ fileType: 'typ', settings: on, listings, installed: [] })
).toEqual({
marketplaceSourceId: 'official',
pluginKey: 'samples.first',
fileType: 'typ',
name: 'Typst'
})
})
it('suggests nothing when off, dismissed, or the plugin system is off', () => {
for (const settings of [
{ pluginSystemEnabled: false },
{ ...on, pluginFileTypeSuggestions: false },
{ ...on, pluginDismissedFileTypes: ['typ'] }
]) {
expect(
findPluginFileTypeSuggestion({ fileType: 'typ', settings, listings, installed: [] })
).toBeNull()
}
})
it('suggests nothing an installed plugin already handles, and skips installed listings', () => {
expect(
findPluginFileTypeSuggestion({
fileType: 'typ',
settings: on,
listings,
installed: [installed('local.typst', ['.TYP'])]
})
).toBeNull()
expect(
findPluginFileTypeSuggestion({
fileType: 'typ',
settings: on,
listings,
installed: [installed('samples.first', [])]
})?.pluginKey
).toBe('samples.second')
})
})
describe('editor suggestions for files Orca already opens as text', () => {
const excalidraw = (pluginKey = 'stablyai.excalidraw') =>
listing(pluginKey, ['excalidraw'], {
release: {
version: '1.0.0',
commit: 'c'.repeat(40),
contentHash: 'f'.repeat(64),
execution: 'sandboxed-ui',
minimumOrca: '1.4.0',
fileTypes: ['excalidraw'],
editorFileTypes: ['excalidraw'],
contributes: [
{ kind: 'customEditors', name: 'Excalidraw', detail: '.excalidraw, .excalidraw.json' }
]
}
})
const settings = { pluginSystemEnabled: true }
const suggest = (fileType: string, listing: PluginMarketplaceListing, extra = {}) =>
findPluginFileTypeSuggestion({
fileType,
purpose: 'editor',
settings: { ...settings, ...extra },
listings: [listing],
installed: []
})
it('suggests a catalog custom editor for the type until dismissed', () => {
expect(suggest('excalidraw', excalidraw())).toMatchObject({
pluginKey: 'stablyai.excalidraw',
fileType: 'excalidraw'
})
expect(
suggest('excalidraw', excalidraw(), { pluginDismissedFileTypes: ['excalidraw'] })
).toBeNull()
})
it('never suggests a syntax plugin for a file that is already highlighted', () => {
expect(suggest('gleam', listing('stablyai.gleam', ['gleam']))).toBeNull()
})
it('trusts only verified, structured file types, never the display summary', () => {
const misstated = excalidraw()
misstated.release.fileTypes = []
const summaryOnly = excalidraw()
delete summaryOnly.release.editorFileTypes
expect(suggest('excalidraw', misstated)).toBeNull()
expect(suggest('excalidraw', summaryOnly)).toBeNull()
})
it('suggests the same editor for a binary file Orca cannot open', () => {
const sheet = listing('stablyai.spreadsheet', ['xlsx'], {
release: {
version: '1.0.0',
commit: 'c'.repeat(40),
contentHash: 'f'.repeat(64),
execution: 'sandboxed-ui',
minimumOrca: '1.4.0',
fileTypes: ['xlsx'],
contributes: [{ kind: 'customEditors', name: 'Spreadsheet', detail: '.xlsx' }]
}
})
expect(
findPluginFileTypeSuggestion({ fileType: 'xlsx', settings, listings: [sheet], installed: [] })
).toMatchObject({ pluginKey: 'stablyai.spreadsheet' })
})
})
@@ -0,0 +1,70 @@
import {
pluginEditorFileTypes,
pluginHandledFileTypes,
type PluginFileTypeSuggestionPurpose,
type PluginFileTypeSuggestionSettings
} from '../../../shared/plugins/contributions/plugin-file-type'
import { isInvalidDiscoveredPlugin, type DiscoveredPlugin } from '../plugin-discovery'
import type { PluginMarketplaceListing } from './plugin-marketplace-projection'
export type PluginFileTypeSuggestion = {
marketplaceSourceId: string
pluginKey: string
fileType: string
/** The release's verified plugin name, when the catalog gives one. */
name?: string
}
/**
* The first official catalog plugin, in catalog order, that says it opens this file type. A
* user-added marketplace never produces an unsolicited suggestion. Nothing when
* suggestions are off or dismissed for the type, or when any installed plugin (even a disabled
* one) already handles it, so a user never gets pointed at what they already have. With the
* `editor` purpose, for a file Orca already opens as text, only a custom editor counts.
*/
export function findPluginFileTypeSuggestion(input: {
fileType: string
purpose?: PluginFileTypeSuggestionPurpose
settings: PluginFileTypeSuggestionSettings & { pluginSystemEnabled?: boolean }
listings: readonly PluginMarketplaceListing[]
installed: readonly DiscoveredPlugin[]
}): PluginFileTypeSuggestion | null {
const { fileType, settings } = input
const editorOnly = input.purpose === 'editor'
if (
settings.pluginSystemEnabled !== true ||
settings.pluginFileTypeSuggestions === false ||
settings.pluginDismissedFileTypes?.includes(fileType)
) {
return null
}
const installedKeys = new Set<string>()
for (const plugin of input.installed) {
if (isInvalidDiscoveredPlugin(plugin)) {
continue
}
installedKeys.add(plugin.pluginKey)
const handled = editorOnly
? pluginEditorFileTypes(plugin.manifest.contributes)
: pluginHandledFileTypes(plugin.manifest.contributes)
if (handled.includes(fileType)) {
return null
}
}
const listing = input.listings.find(
(candidate) =>
candidate.official &&
!candidate.blockedByKillList &&
!installedKeys.has(candidate.pluginKey) &&
candidate.release?.fileTypes?.includes(fileType) &&
(!editorOnly || candidate.release?.editorFileTypes?.includes(fileType))
)
return listing
? {
marketplaceSourceId: listing.marketplaceSourceId,
pluginKey: listing.pluginKey,
fileType,
...(listing.release?.name ? { name: listing.release.name } : {})
}
: null
}
@@ -2,23 +2,27 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
import { getApprovedPluginVmRecipes } from './plugin-approved-vm-recipes'
import { PluginService } from './plugin-service'
import { hashPluginTree } from './plugin-content-hash'
import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint'
import { devFolderConsentIdentity } from '../plugin-discovery'
import {
pluginManifestSchema,
type PluginManifest
} from '../../../shared/plugins/manifest/plugin-manifest'
import { getApprovedPluginVmRecipes } from '../plugin-approved-vm-recipes'
import { PluginService } from '../plugin-service'
import { hashPluginTree } from '../plugin-content-hash'
// VM recipe create strings execute through a shell; revocation must remove declarative content too.
const roots: string[] = []
const services: PluginService[] = []
const pluginKey = 'orca-samples.recipes'
const pluginKey = 'samples.recipes'
function contentManifest(): PluginManifest {
return pluginManifestSchema.parse({
manifestVersion: 1,
id: 'recipes',
publisher: 'orca-samples',
publisher: 'samples',
name: 'Recipes',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
@@ -56,13 +60,14 @@ async function createService(root: string, isKilled: () => boolean): Promise<Plu
if (!content.ok) {
throw new Error(content.error)
}
const identity = await devFolderConsentIdentity(root, content.hash)
const service = new PluginService({
userDataPath: root,
hostVersion: '1.4.0',
isPluginSystemEnabled: () => true,
getDisabledPlugins: () => [],
getPluginConsents: () => ({
[pluginKey]: fingerprintPluginConsent(contentManifest(), content.hash)
[pluginKey]: fingerprintPluginConsent(contentManifest(), identity)
}),
getDevPluginPaths: () => [root],
getPluginKillListEntry: (key) =>
@@ -2,8 +2,12 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PluginKillList } from '../../shared/plugins/plugin-kill-list'
import { fetchPluginKillList, PluginKillListService } from './plugin-kill-list-service'
import type { PluginKillList } from '../../../shared/plugins/catalog/plugin-kill-list'
import {
fetchPluginKillList,
PLUGIN_KILL_LIST_RETRY_MS,
PluginKillListService
} from './plugin-kill-list-service'
import type { PluginKillListStore } from './plugin-kill-list-store'
const roots: string[] = []
@@ -134,6 +138,57 @@ describe('PluginKillListService', () => {
await expect(service.refresh()).rejects.toThrow('older snapshot')
expect(service.snapshot()?.generatedAt).toBe('2026-07-12T20:00:00Z')
})
it('refreshes periodically while plugins are on so long sessions see revocations', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
let revoked = false
const service = new PluginKillListService({
pluginsDataDir: await tempRoot(),
fetcher: async () =>
revoked
? killList('2026-07-13T20:00:00Z')
: { version: 1, generatedAt: '2026-07-12T20:00:00Z', plugins: [] }
})
await service.refresh()
const changed = vi.fn()
service.onChanged(changed)
service.syncPeriodicRefresh(true, 1000)
service.syncPeriodicRefresh(true, 1000)
revoked = true
vi.advanceTimersByTime(1000)
await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1))
expect(service.reason('community.unsafe')).toBe('Malware advisory')
service.syncPeriodicRefresh(false)
vi.advanceTimersByTime(5000)
await Promise.resolve()
expect(changed).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
})
describe('PluginKillListService retry', () => {
it('retries 15 minutes after a failed refresh instead of waiting a full interval', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'setTimeout', 'clearTimeout'] })
let online = false
const service = new PluginKillListService({
pluginsDataDir: await tempRoot(),
fetcher: async () => {
if (!online) {
throw new Error('offline')
}
return killList()
}
})
service.syncPeriodicRefresh(true)
await expect(service.refresh()).rejects.toThrow('offline')
online = true
await vi.advanceTimersByTimeAsync(PLUGIN_KILL_LIST_RETRY_MS)
await vi.waitFor(() => expect(service.reason('community.unsafe')).toBe('Malware advisory'))
service.dispose()
expect(vi.getTimerCount()).toBe(0)
})
})
describe('fetchPluginKillList', () => {
@@ -146,6 +201,7 @@ describe('fetchPluginKillList', () => {
)
await expect(fetchPluginKillList(fetcher)).resolves.toEqual(killList())
expect(fetcher.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
})
it('rejects non-success responses', async () => {
@@ -4,11 +4,18 @@ import {
pluginKillListSchema,
type PluginKillList,
type PluginKillListEntry
} from '../../shared/plugins/plugin-kill-list'
} from '../../../shared/plugins/catalog/plugin-kill-list'
import { PluginKillListStore } from './plugin-kill-list-store'
export const PLUGIN_KILL_LIST_URL = 'https://onorca.dev/plugins/kill-list.json'
const PLUGIN_KILL_LIST_DOWNLOAD_LIMIT = 4 * 1024 * 1024
// Why: revocations must reach sessions that stay open for days, not only fresh launches.
export const PLUGIN_KILL_LIST_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000
// Why: a failed refresh must not leave revocations hours stale until the next interval.
export const PLUGIN_KILL_LIST_RETRY_MS = 15 * 60 * 1000
const PLUGIN_KILL_LIST_RETRY_CAP_MS = 2 * 60 * 60 * 1000
// Why: refreshes are chained, so one stalled request would block every later one.
const PLUGIN_KILL_LIST_FETCH_TIMEOUT_MS = 30_000
type PluginKillListFetcher = () => Promise<PluginKillList>
@@ -18,11 +25,10 @@ export class PluginKillListService {
private readonly listeners = new Set<() => void>()
private currentList: PluginKillList | null = null
private loadPromise: Promise<void> | null = null
private refreshChain: Promise<PluginKillList> = Promise.resolve({
version: 1,
generatedAt: '1970-01-01T00:00:00Z',
plugins: []
})
private periodicTimer: ReturnType<typeof setInterval> | null = null
private retryTimer: ReturnType<typeof setTimeout> | null = null
private failures = 0
private refreshChain: Promise<PluginKillList> = Promise.resolve(emptyKillList())
constructor(options: {
pluginsDataDir: string
@@ -69,9 +75,61 @@ export class PluginKillListService {
.catch(() => this.currentList ?? emptyKillList())
.then(() => this.performRefresh())
this.refreshChain = refresh
refresh.then(
() => {
this.failures = 0
},
() => this.scheduleRetry()
)
return refresh
}
private scheduleRetry(): void {
if (!this.periodicTimer || this.retryTimer) {
return
}
this.failures += 1
const delay = Math.min(
PLUGIN_KILL_LIST_RETRY_MS * 2 ** (this.failures - 1),
PLUGIN_KILL_LIST_RETRY_CAP_MS
)
this.retryTimer = setTimeout(() => {
this.retryTimer = null
this.refreshInBackground()
}, delay)
this.retryTimer.unref?.()
}
/** Keeps a background refresh running while plugins are on; stops it when they are off. */
syncPeriodicRefresh(enabled: boolean, intervalMs = PLUGIN_KILL_LIST_REFRESH_INTERVAL_MS): void {
if (!enabled) {
this.dispose()
return
}
if (this.periodicTimer) {
return
}
this.periodicTimer = setInterval(() => this.refreshInBackground(), intervalMs)
this.periodicTimer.unref?.()
}
private refreshInBackground(): void {
void this.refresh().catch((error) =>
console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error)
)
}
dispose(): void {
if (this.periodicTimer) {
clearInterval(this.periodicTimer)
this.periodicTimer = null
}
if (this.retryTimer) {
clearTimeout(this.retryTimer)
this.retryTimer = null
}
}
private async performRefresh(): Promise<PluginKillList> {
await this.initialize()
const fetched = pluginKillListSchema.parse(await this.fetcher())
@@ -97,7 +155,10 @@ export async function fetchPluginKillList(
fetcher: typeof fetch = fetch,
url = PLUGIN_KILL_LIST_URL
): Promise<PluginKillList> {
const response = await fetcher(url, { cache: 'no-store' })
const response = await fetcher(url, {
cache: 'no-store',
signal: AbortSignal.timeout(PLUGIN_KILL_LIST_FETCH_TIMEOUT_MS)
})
if (!response.ok) {
throw new Error(`plugin kill-list request failed with HTTP ${response.status}`)
}
@@ -1,8 +1,11 @@
import { createReadStream } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { pluginKillListSchema, type PluginKillList } from '../../shared/plugins/plugin-kill-list'
import { writePluginFileAtomically } from './plugin-atomic-file-write'
import {
pluginKillListSchema,
type PluginKillList
} from '../../../shared/plugins/catalog/plugin-kill-list'
import { writePluginFileAtomically } from '../installation/plugin-atomic-file-write'
const PLUGIN_KILL_LIST_MAX_BYTES = 4 * 1024 * 1024
@@ -2,8 +2,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { gitExecFileAsync } from '../git/runner'
import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture'
import { gitExecFileAsync } from '../../git/runner'
import { serveRepositoriesOverTestSsh } from '../__mocks__/plugin-git-ssh-remote-fixture'
import { fetchPluginMarketplace } from './plugin-marketplace-fetch'
const roots: string[] = []
@@ -12,7 +12,7 @@ afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
it('reads only the catalog index, prefers the v2 file, and ignores plugin size limits', async () => {
it('reads only the catalog index, prefers orca-catalog.json, and ignores plugin size limits', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-fetch-'))
roots.push(root)
const repository = join(root, 'catalog')
@@ -30,8 +30,13 @@ it('reads only the catalog index, prefers the v2 file, and ignores plugin size l
await writeFile(join(repository, 'packs', `${index}.txt`), String(index))
}
await writeFile(join(repository, 'orca-marketplace.json'), catalog('Old', [entry]))
await writeFile(join(repository, 'orca-marketplace.v2.json'), catalog('New', []))
await writeFile(
join(repository, 'orca-catalog.json'),
JSON.stringify({ catalogVersion: 2, name: 'New', owner: 'samples', plugins: [] })
)
await git(['init', '--quiet'])
// Git's detached auto-maintenance after commit can still be writing .git during teardown.
await git(['config', 'maintenance.auto', 'false'])
await git(['add', '--all'])
await git(['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-qm', 'c'])
await git(['tag', 'catalog'])
@@ -1,8 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
PLUGIN_MARKETPLACE_FILENAME,
PLUGIN_MARKETPLACE_V2_FILENAME
} from '../../shared/plugins/plugin-marketplace'
import { PLUGIN_CATALOG_FILENAME } from '../../../shared/plugins/catalog/plugin-catalog'
import { PLUGIN_MARKETPLACE_FILENAME } from '../../../shared/plugins/catalog/plugin-marketplace'
import { parsePluginMarketplaceIndex } from './plugin-marketplace-fetch'
const entry = {
@@ -24,31 +22,35 @@ function parse(filename: string, catalog: unknown) {
}
describe('parsePluginMarketplaceIndex', () => {
it('keeps valid v2 entries next to ones this client cannot install', () => {
const marketplace = parse(PLUGIN_MARKETPLACE_V2_FILENAME, {
it('keeps valid catalog entries next to ones this client cannot install', () => {
const marketplace = parse(PLUGIN_CATALOG_FILENAME, {
catalogVersion: 2,
name: 'New',
owner: 'samples',
futureHeader: true,
plugins: [
{ ...entry, id: 'samples.theme', release: { ...release, types: ['icon-theme'] } },
{ ...entry, id: 'samples.broken', release: { ...release, commit: 'nope' } },
{ ...entry, release: { ...release, futureField: 1 }, futureField: 1 }
{ ...entry, id: 'samples.theme', releases: [{ ...release, types: ['file-viewer'] }] },
{ ...entry, id: 'samples.broken', releases: [{ ...release, commit: 'nope' }] },
{ ...entry, releases: [{ ...release, futureField: 1 }], futureField: 1 }
]
})
expect(marketplace).toEqual({ name: 'New', owner: 'samples', plugins: [{ ...entry, release }] })
expect(marketplace).toMatchObject({
name: 'New',
owner: 'samples',
plugins: [{ ...entry, releases: [release] }]
})
expect(marketplace.plugins).toHaveLength(1)
})
it('rejects duplicate ids even when one copy is unreadable', () => {
it('refuses a newer catalog format with the fix', () => {
expect(() =>
parse(PLUGIN_MARKETPLACE_V2_FILENAME, {
parse(PLUGIN_CATALOG_FILENAME, {
catalogVersion: 3,
name: 'New',
owner: 'samples',
plugins: [
{ ...entry, release },
{ ...entry, release: { ...release, commit: 'nope' } }
]
plugins: []
})
).toThrow('duplicate plugin id')
).toThrow('orca-catalog.json uses catalog format 3; update Orca to browse this catalog')
})
it('keeps release entries out of the frozen v1 file', () => {
@@ -0,0 +1,141 @@
import { tmpdir } from 'node:os'
import {
PLUGIN_MARKETPLACE_FILENAME,
pluginMarketplaceSchema,
type PluginMarketplace
} from '../../../shared/plugins/catalog/plugin-marketplace'
import {
PLUGIN_CATALOG_FILENAME,
pluginCatalogSchema,
pluginCatalogVersionError
} from '../../../shared/plugins/catalog/plugin-catalog'
import { formatPluginSchemaIssues } from '../../../shared/plugins/manifest/plugin-schema-issues'
import { gitExecFileAsyncBuffer } from '../../git/runner'
import { extractCatalogImages } from './plugin-catalog-image-extraction'
import { fillMonorepoReleaseImages } from './plugin-catalog-monorepo-images'
import { readPluginGitCommit } from '../installation/plugin-git-repository'
import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store'
const MARKETPLACE_INDEX_MAX_BYTES = 16 * 1024 * 1024
// Why its own cap: a catalog repository may hold far more than one plugin, and only its index is read.
const MARKETPLACE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
const MARKETPLACE_GIT_TIMEOUT_MS = 120_000
export type PluginMarketplaceFetchResult = {
marketplaceCommit: string
marketplace: PluginMarketplace
/** Catalog images whose bytes matched their declared hash, size and format. */
images?: Buffer[]
}
/** System Git retains private-repo SSH agent and credential-helper support. */
export async function fetchPluginMarketplace(
source: PluginMarketplaceRegisteredSource
): Promise<PluginMarketplaceFetchResult> {
const { commit, value } = await readPluginGitCommit(
{
url: source.source.url,
ref: source.source.ref,
workingDirectory: tmpdir(),
maxDownloadBytes: MARKETPLACE_DOWNLOAD_MAX_BYTES
},
(repository, commit) => readMarketplaceAtCommit(repository, commit, source.source.url)
)
return { marketplaceCommit: commit, marketplace: value.marketplace, images: value.images }
}
/** Reads only the catalog index blobs, so the rest of the repository is never written out.
* Prefers orca-catalog.json; repositories that only publish the frozen v1 file still load. */
async function readMarketplaceAtCommit(
repository: string,
commit: string,
catalogUrl: string
): Promise<{ marketplace: PluginMarketplace; images: Buffer[] }> {
const options = { cwd: repository, timeout: MARKETPLACE_GIT_TIMEOUT_MS }
const listing = await gitExecFileAsyncBuffer(
['ls-tree', '-l', '-z', commit, '--', PLUGIN_CATALOG_FILENAME, PLUGIN_MARKETPLACE_FILENAME],
{ ...options, maxBuffer: 64 * 1024 }
)
const blobs = new Map<string, { object: string; size: number }>()
for (const record of listing.stdout.toString('utf8').split('\0')) {
const match = /^100(?:644|755) blob ([0-9a-f]{40}) +([0-9]+)\t(.+)$/s.exec(record)
if (match) {
blobs.set(match[3]!, { object: match[1]!, size: Number(match[2]) })
}
}
const filename = blobs.has(PLUGIN_CATALOG_FILENAME)
? PLUGIN_CATALOG_FILENAME
: PLUGIN_MARKETPLACE_FILENAME
const blob = blobs.get(filename)
if (!blob) {
throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} not found`)
}
if (blob.size > MARKETPLACE_INDEX_MAX_BYTES) {
throw new Error(`${filename} exceeds its size limit`)
}
const bytes = await gitExecFileAsyncBuffer(['cat-file', 'blob', blob.object], {
...options,
maxBuffer: MARKETPLACE_INDEX_MAX_BYTES
})
const readBlob = (path: string, maxBytes: number): Promise<Buffer | null> =>
readCatalogBlob(repository, commit, path, maxBytes)
const marketplace = await fillMonorepoReleaseImages(
parsePluginMarketplaceIndex(filename, bytes.stdout),
catalogUrl,
readBlob
)
return extractCatalogImages(marketplace, readBlob)
}
/** One file from the catalog commit, refused before reading when it is larger than allowed. */
async function readCatalogBlob(
repository: string,
commit: string,
path: string,
maxBytes: number
): Promise<Buffer | null> {
const options = { cwd: repository, timeout: MARKETPLACE_GIT_TIMEOUT_MS }
const listing = await gitExecFileAsyncBuffer(['ls-tree', '-l', '-z', commit, '--', path], {
...options,
maxBuffer: 4 * 1024
})
const match = /^100(?:644|755) blob ([0-9a-f]{40}) +([0-9]+)\t/.exec(
listing.stdout.toString('utf8')
)
if (!match?.[1] || Number(match[2]) > maxBytes) {
return null
}
const blob = await gitExecFileAsyncBuffer(['cat-file', 'blob', match[1]], {
...options,
maxBuffer: maxBytes
})
return blob.stdout
}
export function parsePluginMarketplaceIndex(filename: string, bytes: Buffer): PluginMarketplace {
let raw: unknown
try {
raw = JSON.parse(bytes.toString('utf8'))
} catch (error) {
throw new Error(
`invalid ${filename}: ${error instanceof Error ? error.message : String(error)}`
)
}
if (filename === PLUGIN_CATALOG_FILENAME) {
const tooNew = pluginCatalogVersionError(raw)
if (tooNew) {
throw new Error(`${filename} ${tooNew}`)
}
const parsed = pluginCatalogSchema.safeParse(raw)
if (!parsed.success) {
throw new Error(`invalid ${filename}: ${formatPluginSchemaIssues(parsed.error.issues, raw)}`)
}
const { name, owner, plugins } = parsed.data
return { name, owner, plugins }
}
const parsed = pluginMarketplaceSchema.safeParse(raw)
if (!parsed.success) {
throw new Error(`invalid ${filename}: ${formatPluginSchemaIssues(parsed.error.issues, raw)}`)
}
return parsed.data
}
@@ -0,0 +1,470 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import {
PLUGIN_SCHEMA_NEWEST_SINCE,
pluginHostVersion
} from '../../../shared/plugins/installation/plugin-host-version'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog-release'
import type { PluginMarketplace } from '../../../shared/plugins/catalog/plugin-marketplace'
import { readPluginLockfile, removeInstalledPlugin } from '../installation/plugin-install'
import { PluginImageCache } from '../plugin-image-cache'
import { PluginMarketplaceInstaller } from './plugin-marketplace-installer'
import { PluginMarketplaceService } from './plugin-marketplace-service'
const git = vi.hoisted(
(): {
checkout: ReturnType<typeof vi.fn>
version: string
publisher: string
id: string
commit: string
payload: string
v2: boolean
release: PluginCatalogRelease | undefined
pluginUrl: string
marketplaceCommit: string
worker: boolean
icon: boolean
/** Directories of one repository listed as separate plugins named after them. */
paths: string[] | null
} => ({
checkout: vi.fn(),
version: '1.0.0',
publisher: 'community',
id: 'notes',
commit: 'a'.repeat(40),
payload: 'first',
v2: false,
release: undefined,
pluginUrl: 'https://github.com/community/notes.git',
marketplaceCommit: 'f'.repeat(40),
worker: false,
icon: false,
paths: null
})
)
vi.mock('../installation/plugin-git-repository', () => ({
checkoutPluginGitSource: git.checkout
}))
const roots: string[] = []
async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-installer-'))
roots.push(root)
return root
}
function marketplace(): PluginMarketplace {
if (git.paths) {
return {
name: 'Community',
owner: 'community',
plugins: git.paths.map((path) => ({
id: `community.${path.split('/').at(-1)}`,
source: { kind: 'git', url: git.pluginUrl, ref: 'stable', path },
categories: []
}))
}
}
return {
name: 'Community',
owner: 'community',
plugins: [
{
id: 'community.notes',
source: {
kind: 'git',
url: git.pluginUrl,
ref: 'stable'
},
categories: ['productivity'],
...(git.release ? { releases: [git.release] } : {})
}
]
}
}
async function writeCurrentPlugin(destination: string, path?: string): Promise<void> {
await mkdir(destination, { recursive: true })
await writeFile(
join(destination, 'orca-plugin.json'),
JSON.stringify({
...(git.v2
? {
manifestVersion: 2,
execution: 'declarative',
engines: { orca: `>=${PLUGIN_SCHEMA_NEWEST_SINCE}` },
contributes: { commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] }
}
: { manifestVersion: 1, engines: { orca: '>=1.0.0' }, pluginApi: 1 }),
id: path?.split('/').at(-1) ?? git.id,
publisher: git.publisher,
name: 'Notes',
version: git.version,
capabilities: [],
...(git.worker ? { main: 'worker.js' } : {}),
...(git.icon ? { icon: 'icon.png' } : {})
})
)
if (git.icon) {
await writeFile(join(destination, 'icon.png'), squarePng(128))
}
await writeFile(join(destination, 'payload.txt'), git.payload)
if (git.worker) {
await writeFile(join(destination, 'worker.js'), '')
}
}
function squarePng(side: number): Buffer {
const be32 = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
const ihdr = [0, 0, 0, 13, ...Buffer.from('IHDR'), ...be32(side), ...be32(side), 8, 6, 0, 0, 0]
return Buffer.from([...signature, ...ihdr, 0, 0, 0, 0])
}
async function setup(imageCache?: PluginImageCache): Promise<{
root: string
marketplace: PluginMarketplaceService
installer: PluginMarketplaceInstaller
sourceId: string
}> {
const root = await tempRoot()
const service = new PluginMarketplaceService({
pluginsDataDir: join(root, 'plugins-data'),
fetcher: async () => ({ marketplaceCommit: git.marketplaceCommit, marketplace: marketplace() })
})
const added = await service.addSource({
kind: 'git',
url: 'https://github.com/community/plugins.git',
ref: 'main'
})
return {
root,
marketplace: service,
installer: new PluginMarketplaceInstaller({
marketplace: service,
userDataPath: root,
hostVersion: pluginHostVersion('1.4.0'),
...(imageCache ? { imageCache } : {})
}),
sourceId: added.id
}
}
beforeEach(() => {
git.version = '1.0.0'
git.publisher = 'community'
git.id = 'notes'
git.commit = 'a'.repeat(40)
git.payload = 'first'
git.v2 = false
git.release = undefined
git.pluginUrl = 'https://github.com/community/notes.git'
git.marketplaceCommit = 'f'.repeat(40)
git.worker = false
git.icon = false
git.paths = null
git.checkout.mockReset()
git.checkout.mockImplementation(
async ({ destination, path }: { destination: string; path?: string }) => {
await writeCurrentPlugin(destination, path)
return git.commit
}
)
})
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('PluginMarketplaceInstaller', () => {
it('previews exact validated bytes and records marketplace provenance on install', async () => {
const { root, installer, sourceId } = await setup()
const preview = await installer.preview(sourceId, 'community.notes')
expect(preview).toMatchObject({
pluginKey: 'community.notes',
resolvedCommit: 'a'.repeat(40),
marketplaceCommit: 'f'.repeat(40),
manifest: { version: '1.0.0' }
})
const result = await installer.install(preview)
if (!result.ok) {
throw new Error(result.error)
}
expect(result).toMatchObject({ ok: true, resolvedCommit: 'a'.repeat(40) })
const lock = await readPluginLockfile(join(root, 'plugins'))
expect(lock.plugins['community.notes']?.source).toEqual({
kind: 'marketplace',
marketplace: {
url: 'https://github.com/community/plugins.git',
ref: 'main',
resolvedCommit: 'f'.repeat(40)
},
plugin: {
url: 'https://github.com/community/notes.git',
ref: 'stable'
}
})
})
it('previews without the image when the image cache cannot save it', async () => {
git.v2 = true
git.icon = true
const cache = new PluginImageCache(join(await tempRoot(), 'images'))
vi.spyOn(cache, 'put').mockRejectedValue(
Object.assign(new Error('rename failed'), { code: 'ENOENT' })
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const { installer, sourceId } = await setup(cache)
const preview = await installer.preview(sourceId, 'community.notes')
expect(preview.manifest.icon).toBe('icon.png')
expect(preview.images?.icon).toBeUndefined()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
it('requires a fresh review when the plugin ref moves after preview', async () => {
const { root, installer, sourceId } = await setup()
const preview = await installer.preview(sourceId, 'community.notes')
git.commit = 'b'.repeat(40)
git.version = '2.0.0'
await expect(installer.install(preview)).resolves.toEqual({
ok: false,
error: 'plugin source changed after preview; review the update again'
})
await expect(readFile(join(root, 'plugins', 'plugins.lock.json'))).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('installs only the exact bytes a catalog release pins', async () => {
git.v2 = true
const unpinnedSetup = await setup()
const unpinned = await unpinnedSetup.installer.preview(
unpinnedSetup.sourceId,
'community.notes'
)
git.release = {
version: '1.0.0',
commit: git.commit,
contentHash: unpinned.contentHash,
execution: 'declarative',
minimumOrca: PLUGIN_SCHEMA_NEWEST_SINCE,
types: ['instructions']
}
const { installer, sourceId } = await setup()
const preview = await installer.preview(sourceId, 'community.notes')
// The pinned commit is fetched directly, so the listed branch is display text only.
expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit }))
await expect(installer.install(preview)).resolves.toMatchObject({ ok: true })
expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit }))
git.payload = 'tampered at the same commit'
await expect(installer.install(preview)).resolves.toEqual({
ok: false,
error: 'plugin bytes do not match the catalog release'
})
await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow(
'plugin bytes do not match the catalog release'
)
})
it('lists a release for a newer Orca but refuses it before downloading', async () => {
git.release = {
version: '1.0.0',
commit: git.commit,
contentHash: 'c'.repeat(64),
execution: 'declarative',
minimumOrca: '9.0.0',
types: ['instructions']
}
const { marketplace, installer, sourceId } = await setup()
await expect(marketplace.findPlugin(sourceId, 'community.notes')).resolves.toBeTruthy()
await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow(
`requires Orca >=9.0.0 (this is ${PLUGIN_SCHEMA_NEWEST_SINCE})`
)
await expect(
installer.install({
marketplaceSourceId: sourceId,
marketplaceCommit: 'f'.repeat(40),
pluginKey: 'community.notes',
resolvedCommit: git.commit
})
).resolves.toEqual({
ok: false,
error: `requires Orca >=9.0.0 (this is ${PLUGIN_SCHEMA_NEWEST_SINCE})`
})
expect(git.checkout).not.toHaveBeenCalled()
})
it('installs a full-access plugin with no global switch; approval stays per plugin', async () => {
git.worker = true
const { installer, sourceId, root } = await setup()
const preview = await installer.preview(sourceId, 'community.notes')
await expect(installer.install(preview)).resolves.toMatchObject({ ok: true })
expect(Object.keys((await readPluginLockfile(join(root, 'plugins'))).plugins)).toEqual([
'community.notes'
])
})
it('rejects a source whose manifest identity differs from its listing', async () => {
const { installer, sourceId } = await setup()
git.publisher = 'attacker'
await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow(
'attacker.notes does not match marketplace listing community.notes'
)
})
it('updates from recorded marketplace provenance and rolls back one immutable version', async () => {
const { root, installer, sourceId } = await setup()
const firstPreview = await installer.preview(sourceId, 'community.notes')
const firstInstall = await installer.install(firstPreview)
expect(firstInstall.ok).toBe(true)
if (!firstInstall.ok) {
return
}
git.commit = 'b'.repeat(40)
git.version = '2.0.0'
git.payload = 'second'
const updatePreview = await installer.previewInstalledUpdate('community.notes')
expect(updatePreview).toMatchObject({ resolvedCommit: 'b'.repeat(40) })
await expect(installer.install(updatePreview)).resolves.toMatchObject({
ok: true,
version: '2.0.0'
})
await expect(installer.install(updatePreview)).resolves.not.toHaveProperty('repositoryMoved')
await expect(installer.rollbackVersion('community.notes')).resolves.toBe('1.0.0')
await expect(installer.rollbackVersion('not a key')).resolves.toBeNull()
await expect(installer.rollback('community.notes')).resolves.toMatchObject({
ok: true,
version: '1.0.0',
contentHash: firstInstall.contentHash
})
const lock = await readPluginLockfile(join(root, 'plugins'))
expect(lock.plugins['community.notes']).toMatchObject({
version: '1.0.0',
resolvedCommit: 'a'.repeat(40)
})
})
it('keeps an install pinned to its catalog when a second catalog lists the same id', async () => {
const { root, marketplace, installer, sourceId } = await setup()
await installer.install(await installer.preview(sourceId, 'community.notes'))
const mirror = await marketplace.addSource({
kind: 'git',
url: 'https://gitlab.com/mirror/group/plugins.git',
ref: 'main'
})
git.commit = 'b'.repeat(40)
git.version = '2.0.0'
const switched = await installer.install(await installer.preview(mirror.id, 'community.notes'))
expect(switched).toMatchObject({ ok: false, error: expect.stringContaining('remove it first') })
const lock = await readPluginLockfile(join(root, 'plugins'))
expect(lock.plugins['community.notes']).toMatchObject({
version: '1.0.0',
source: { marketplace: { url: 'https://github.com/community/plugins.git' } }
})
await expect(installer.previewInstalledUpdate('community.notes')).resolves.toMatchObject({
marketplaceSourceId: sourceId
})
})
it('lets a Remove that starts during an automatic update win', async () => {
const { root, installer, sourceId } = await setup()
await installer.install(await installer.preview(sourceId, 'community.notes'))
git.commit = 'b'.repeat(40)
git.payload = 'second'
const update = await installer.previewInstalledUpdate('community.notes')
const pluginsDir = join(root, 'plugins')
const [, result] = await Promise.all([
removeInstalledPlugin({
pluginsDir,
pluginsDataDir: join(root, 'plugins-data'),
pluginKey: 'community.notes'
}),
installer.install(update, { requireInstalled: true })
])
expect(result).toMatchObject({ ok: false, error: expect.stringContaining('removed') })
expect((await readPluginLockfile(pluginsDir)).plugins).toEqual({})
})
it('flags an update whose catalog moved the plugin to a different repository', async () => {
const { marketplace: service, installer, sourceId } = await setup()
await installer.install(await installer.preview(sourceId, 'community.notes'))
git.pluginUrl = 'git@github.com:Community/Notes'
git.marketplaceCommit = 'e'.repeat(40)
await service.refreshSource(sourceId)
git.payload = 'respelled'
const respelledPreview = await installer.previewInstalledUpdate('community.notes')
expect(respelledPreview).not.toHaveProperty('repositoryMoved')
const respelled = await installer.install(respelledPreview)
expect(respelled).toMatchObject({ ok: true })
expect(respelled).not.toHaveProperty('repositoryMoved')
git.pluginUrl = 'https://github.com/someone-else/notes.git'
git.marketplaceCommit = 'd'.repeat(40)
await service.refreshSource(sourceId)
git.payload = 'moved'
const movedPreview = await installer.previewInstalledUpdate('community.notes')
expect(movedPreview).toMatchObject({ repositoryMoved: true })
await expect(installer.install(movedPreview)).resolves.toMatchObject({
ok: true,
repositoryMoved: true
})
})
it('installs two plugins from one repository side by side, each from its own directory', async () => {
git.paths = ['plugins/notes', 'plugins/tasks']
const { root, installer, sourceId } = await setup()
for (const key of ['community.notes', 'community.tasks']) {
await expect(
installer.install(await installer.preview(sourceId, key))
).resolves.toMatchObject({ ok: true, pluginKey: key })
}
expect(git.checkout.mock.calls.map(([input]) => input.path)).toEqual([
'plugins/notes',
'plugins/notes',
'plugins/tasks',
'plugins/tasks'
])
const lock = await readPluginLockfile(join(root, 'plugins'))
expect(lock.plugins['community.notes']?.source).toMatchObject({
plugin: { url: git.pluginUrl, ref: 'stable', path: 'plugins/notes' }
})
expect(lock.plugins['community.tasks']?.source).toMatchObject({
plugin: { path: 'plugins/tasks' }
})
})
it('treats a changed directory in the same repository as a moved plugin', async () => {
git.paths = ['plugins/notes']
const { marketplace: service, installer, sourceId } = await setup()
await installer.install(await installer.preview(sourceId, 'community.notes'))
git.paths = ['archive/notes']
git.marketplaceCommit = 'e'.repeat(40)
await service.refreshSource(sourceId)
git.payload = 'moved'
const movedPreview = await installer.previewInstalledUpdate('community.notes')
expect(movedPreview).toMatchObject({ repositoryMoved: true })
await expect(installer.install(movedPreview)).resolves.toMatchObject({
ok: true,
repositoryMoved: true
})
})
})
@@ -0,0 +1,305 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { PluginManifest } from '../../../shared/plugins/manifest/plugin-manifest'
import { PLUGIN_IMAGE_RULES } from '../../../shared/plugins/images/plugin-image-fields'
import {
pluginShaImageUrl,
type PluginHostImages
} from '../../../shared/plugins/images/plugin-image-url'
import { readContainedPluginArtifactBytes } from '../plugin-artifact-validation'
import { declaredPluginImages } from '../plugin-image-artifact-validation'
import type { PluginImageCache } from '../plugin-image-cache'
import { pluginManifestHostImages } from '../plugin-manifest-images'
import { readPluginVmRecipeIdentities } from '../plugin-vm-recipe-identities'
import type { PluginVmRecipeIdentities } from '../../../shared/plugins/approval/plugin-update-access-growth'
import { pluginCatalogReleaseCompatibilityError } from '../../../shared/plugins/manifest/plugin-manifest-compatibility'
import { isQualifiedPluginKey } from '../../../shared/plugins/manifest/plugin-manifest'
import { getUserPluginsDir } from '../plugin-discovery'
import { readPluginRollbackVersion } from '../installation/plugin-rollback-candidate'
import { checkoutPluginGitSource } from '../installation/plugin-git-repository'
import {
installPluginFromMarketplace,
readPluginLockfile,
rollbackInstalledPlugin,
type PluginInstallResult
} from '../installation/plugin-install'
import { inspectPluginInstallTree } from '../installation/plugin-install-staging'
import type {
PluginMarketplaceListing,
PluginMarketplaceService
} from './plugin-marketplace-service'
import { marketplaceSourceId } from './plugin-marketplace-store'
import { isSamePluginSource } from '../../../shared/plugins/catalog/plugin-marketplace'
import type { PluginInstallSource } from '../../../shared/plugins/installation/plugin-install-lockfile'
export type PluginMarketplaceInstallPreview = {
marketplaceSourceId: string
marketplaceName: string
marketplaceOwner: string
marketplaceCommit: string
pluginKey: string
source: PluginMarketplaceListing['source']
resolvedCommit: string
contentHash: string
consentFingerprint: string
manifest: PluginManifest
official: boolean
bundled: boolean
blockedByKillList?: { reason: string; advisoryUrl?: string }
/** The manifest's images, verified at preview and cached by hash. */
images?: PluginHostImages
/** What each recipe file can run, read before the staging tree is deleted; null if unreadable. */
vmRecipeIdentities?: PluginVmRecipeIdentities
/** Update previews only: the catalog now points at a different repository than the install. */
repositoryMoved?: true
}
/** `repositoryMoved`: this replaced an install that came from a different repository. */
export type PluginMarketplaceInstallResult = PluginInstallResult & { repositoryMoved?: true }
type MarketplaceInstallSource = Extract<PluginInstallSource, { kind: 'marketplace' }>
export type PluginMarketplacePreviewIdentity = Pick<
PluginMarketplaceInstallPreview,
'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit'
>
export class PluginMarketplaceInstaller {
private readonly marketplace: PluginMarketplaceService
private readonly userDataPath: string
private readonly hostVersion: string
private readonly blockedPluginReason: (pluginKey: string) => string | null
private readonly imageCache: PluginImageCache | null
constructor(options: {
marketplace: PluginMarketplaceService
userDataPath: string
hostVersion: string
blockedPluginReason?: (pluginKey: string) => string | null
imageCache?: PluginImageCache
}) {
this.imageCache = options.imageCache ?? null
this.marketplace = options.marketplace
this.userDataPath = options.userDataPath
this.hostVersion = options.hostVersion
this.blockedPluginReason = options.blockedPluginReason ?? (() => null)
}
async preview(
marketplaceSourceId: string,
pluginKey: string
): Promise<PluginMarketplaceInstallPreview> {
const listing = await this.requireListing(marketplaceSourceId, pluginKey)
const incompatible = this.releaseCompatibilityError(listing)
if (incompatible) {
throw new Error(incompatible)
}
const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-preview-'))
try {
const resolvedCommit = await checkoutPluginGitSource({
url: listing.source.url,
// Why: a pinned release names its commit; the listed ref is only what the author calls it.
ref: listing.release?.commit ?? listing.source.ref,
...(listing.source.path ? { path: listing.source.path } : {}),
destination: stagingDirectory,
workingDirectory: tmpdir()
})
const inspection = await inspectPluginInstallTree({
rootDir: stagingDirectory,
hostVersion: this.hostVersion,
expectedPluginKey: pluginKey,
...(listing.release ? { release: { record: listing.release, resolvedCommit } } : {})
})
if (!inspection.ok) {
throw new Error(inspection.error)
}
const images = await this.cachePreviewImages(stagingDirectory, inspection.manifest)
const vmRecipeIdentities =
inspection.manifest.contributes.vmRecipes.length > 0
? await readPluginVmRecipeIdentities(stagingDirectory, inspection.manifest)
: undefined
return {
marketplaceSourceId,
marketplaceName: listing.marketplaceName,
marketplaceOwner: listing.marketplaceOwner,
marketplaceCommit: listing.marketplaceCommit,
pluginKey,
source: listing.source,
resolvedCommit,
contentHash: inspection.contentHash,
consentFingerprint: inspection.consentFingerprint,
manifest: inspection.manifest,
official: listing.official,
bundled: listing.bundled,
...(listing.blockedByKillList ? { blockedByKillList: listing.blockedByKillList } : {}),
...(images ? { images } : {}),
...(vmRecipeIdentities !== undefined ? { vmRecipeIdentities } : {})
}
} finally {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
async install(
preview: PluginMarketplacePreviewIdentity,
options: { requireInstalled?: boolean } = {}
): Promise<PluginMarketplaceInstallResult> {
const listing = await this.requireListing(preview.marketplaceSourceId, preview.pluginKey)
const blockedReason =
listing.blockedByKillList?.reason ?? this.blockedPluginReason(preview.pluginKey)
if (blockedReason) {
return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` }
}
const incompatible = this.releaseCompatibilityError(listing)
if (incompatible) {
return { ok: false, error: incompatible }
}
if (listing.marketplaceCommit !== preview.marketplaceCommit) {
return { ok: false, error: 'marketplace changed after preview; review the plugin again' }
}
const sourceState = (await this.marketplace.listSources()).find(
(source) => source.id === preview.marketplaceSourceId
)
if (!sourceState) {
return { ok: false, error: 'marketplace source is no longer configured' }
}
// Why read first: the install rewrites the lock entry this comparison needs.
const installed = await this.readMarketplaceInstall(preview.pluginKey)
const repositoryMoved =
installed !== null && !isSamePluginSource(installed.plugin, listing.source)
const result = await installPluginFromMarketplace({
pluginsDir: getUserPluginsDir(this.userDataPath),
hostVersion: this.hostVersion,
expectedPluginKey: preview.pluginKey,
expectedResolvedCommit: preview.resolvedCommit,
marketplace: {
url: sourceState.source.url,
ref: sourceState.source.ref,
resolvedCommit: preview.marketplaceCommit
},
plugin: {
url: listing.source.url,
ref: listing.source.ref,
...(listing.source.path ? { path: listing.source.path } : {})
},
...(listing.release ? { release: listing.release } : {}),
blockedPluginReason: this.blockedPluginReason,
...(options.requireInstalled ? { requireInstalled: true } : {})
})
return result.ok && repositoryMoved ? { ...result, repositoryMoved: true } : result
}
/** The catalog an installed plugin came from, or null when it was not a marketplace install. */
private async readMarketplaceInstall(
pluginKey: string
): Promise<{ sourceId: string; plugin: MarketplaceInstallSource['plugin'] } | null> {
const entry = (await readPluginLockfile(getUserPluginsDir(this.userDataPath))).plugins[
pluginKey
]
if (entry?.source.kind !== 'marketplace') {
return null
}
const { marketplace, plugin } = entry.source
return {
sourceId: marketplaceSourceId({ kind: 'git', url: marketplace.url, ref: marketplace.ref }),
plugin
}
}
async previewInstalledUpdate(pluginKey: string): Promise<PluginMarketplaceInstallPreview> {
const installed = await this.readMarketplaceInstall(pluginKey)
if (!installed) {
throw new Error(`plugin ${pluginKey} was not installed from a marketplace`)
}
const preview = await this.preview(installed.sourceId, pluginKey)
return isSamePluginSource(installed.plugin, preview.source)
? preview
: { ...preview, repositoryMoved: true }
}
/** The own-catalog listing an installed plugin would update from, read from caches only. */
async findInstalledUpdateListing(pluginKey: string): Promise<{
listing: PluginMarketplaceListing
officialCatalog: boolean
} | null> {
const installed = await this.readMarketplaceInstall(pluginKey)
const listing = installed
? await this.marketplace.findPlugin(installed.sourceId, pluginKey)
: null
if (!installed || !listing) {
return null
}
const source = (await this.marketplace.listSources()).find(
({ id }) => id === installed.sourceId
)
return { listing, officialCatalog: source?.official === true }
}
/** What `rollback` would restore, read only when the UI asks. */
async rollbackVersion(pluginKey: string): Promise<string | null> {
return isQualifiedPluginKey(pluginKey)
? readPluginRollbackVersion(join(getUserPluginsDir(this.userDataPath), pluginKey))
: null
}
async rollback(pluginKey: string): Promise<PluginInstallResult> {
return rollbackInstalledPlugin({
pluginsDir: getUserPluginsDir(this.userDataPath),
pluginKey,
hostVersion: this.hostVersion,
blockedPluginReason: this.blockedPluginReason
})
}
/** Why cache now: the staging tree is deleted after preview, and images were already checked by
* the install inspection, so their hashes are the only stable names. */
private async cachePreviewImages(
rootDir: string,
manifest: PluginManifest
): Promise<PluginHostImages | undefined> {
const cache = this.imageCache
if (!cache) {
return undefined
}
const hashes = new Map<string, string>()
for (const image of declaredPluginImages(manifest)) {
const bytes = await readContainedPluginArtifactBytes(
rootDir,
image.path,
PLUGIN_IMAGE_RULES[image.kind].maxBytes
).catch(() => null)
// A cache failure only costs the image; it must never fail the preview or update.
const sha256 = bytes
? await cache.put(bytes).catch((error: unknown) => {
console.warn(`[plugins] could not cache image ${image.path}:`, error)
return null
})
: null
if (sha256) {
hashes.set(image.path, sha256)
}
}
return pluginManifestHostImages(manifest, (_slot, path) => {
const sha256 = hashes.get(path)
return sha256 ? pluginShaImageUrl(sha256) : null
})
}
private releaseCompatibilityError(listing: PluginMarketplaceListing): string | null {
return listing.release
? pluginCatalogReleaseCompatibilityError(listing.release, this.hostVersion)
: null
}
private async requireListing(
marketplaceSourceId: string,
pluginKey: string
): Promise<PluginMarketplaceListing> {
const listing = await this.marketplace.findPlugin(marketplaceSourceId, pluginKey)
if (!listing) {
throw new Error(`plugin ${pluginKey} is not listed by marketplace ${marketplaceSourceId}`)
}
return listing
}
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { pluginMarketplaceListingSchema } from '../../../shared/plugins/catalog/plugin-marketplace'
import { pluginMarketplaceListing } from './plugin-marketplace-projection'
describe('pluginMarketplaceListing trust', () => {
const listing = (catalogUrl: string, pluginKey: string) => {
const source = { kind: 'git' as const, url: catalogUrl, ref: 'main' }
const marketplace = pluginMarketplaceListingSchema.parse({
name: 'Orca Plugins',
owner: 'stablyai',
plugins: [
{
id: pluginKey,
source: { kind: 'git', url: 'https://github.com/stablyai/orca-plugins.git', ref: 'v1' }
}
]
})
return pluginMarketplaceListing({
source: { id: 'a'.repeat(32), source, addedAt: 0 },
snapshot: {
schemaVersion: 2,
sourceId: 'a'.repeat(32),
source,
marketplaceCommit: 'b'.repeat(40),
fetchedAt: 0,
marketplace
},
entry: marketplace.plugins[0]!,
hostVersion: null,
blocked: null
})
}
it('marks official what the official catalog lists, whatever the id', () => {
const official = 'https://github.com/stablyai/orca-plugins.git'
expect(listing(official, 'stablyai.spreadsheet').official).toBe(true)
expect(listing(official, 'stablyai.orca-gleam').official).toBe(true)
expect(
listing('https://github.com/acme/orca-plugins.git', 'stablyai.orca-gleam').official
).toBe(false)
})
})
@@ -0,0 +1,163 @@
import {
OFFICIAL_MARKETPLACE_OWNER,
isOfficialMarketplaceGitSource,
isOfficialOrganizationGitSource,
type PluginMarketplaceEntry,
type PluginMarketplaceGitSource
} from '../../../shared/plugins/catalog/plugin-marketplace'
import type { PluginKillListEntry } from '../../../shared/plugins/catalog/plugin-kill-list'
import type {
PluginMarketplaceCachedSnapshot,
PluginMarketplaceRegisteredSource
} from './plugin-marketplace-store'
import { selectPluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog'
import {
pluginShaImageUrl,
type PluginHostImages
} from '../../../shared/plugins/images/plugin-image-url'
import type { PluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog-release'
export type PluginMarketplaceSourceState = {
id: string
source: PluginMarketplaceGitSource
addedAt: number
marketplace: {
name: string
owner: string
resolvedCommit: string
fetchedAt: number
} | null
stale: boolean
official: boolean
error?: string
}
export type PluginMarketplaceListing = {
marketplaceSourceId: string
marketplaceName: string
marketplaceOwner: string
marketplaceCommit: string
pluginKey: string
source: PluginMarketplaceEntry['source']
description?: string
categories: string[]
official: boolean
bundled: boolean
blockedByKillList?: { reason: string; advisoryUrl?: string }
/** The release offered to this host: the newest it can install, else the one needing the least
* newer Orca. Absent for v1 catalogs. */
release?: PluginCatalogRelease
/** Every release the catalog lists for this plugin, newest first. */
releases?: PluginCatalogRelease[]
/** The offered release's images, served by main from verified bytes. */
images?: PluginHostImages
} & PluginCatalogListingDetails
/** Display fields a release carries, flattened so list rows need not know release records. */
export type PluginCatalogListingDetails = {
name?: string
version?: string
license?: string
repository?: string
homepage?: string
updatedAt?: string
changelog?: string
}
const DETAIL_KEYS = [
'name',
'license',
'repository',
'homepage',
'updatedAt',
'changelog'
] as const satisfies readonly (keyof PluginCatalogListingDetails & keyof PluginCatalogRelease)[]
/** Catalog images as the renderer loads them: hash-addressed, verified, never hotlinked. */
export function pluginCatalogListingImages(release: PluginCatalogRelease | undefined): {
images?: PluginHostImages
} {
const images = release?.images
if (!images) {
return {}
}
const host = (image: { sha256: string; alt: string; width: number; height: number }) => ({
url: pluginShaImageUrl(image.sha256),
alt: image.alt,
width: image.width,
height: image.height
})
return {
images: {
...(images.icon ? { icon: host(images.icon) } : {}),
...(images.cover ? { cover: host(images.cover) } : {}),
...(images.screenshots?.length ? { screenshots: images.screenshots.map(host) } : {})
}
}
}
/** The release to offer this host; without a host version, the newest. */
export function offeredCatalogRelease(
releases: readonly PluginCatalogRelease[] | undefined,
hostVersion: string | null
): PluginCatalogRelease | undefined {
if (!releases) {
return undefined
}
return hostVersion ? selectPluginCatalogRelease(releases, hostVersion) : releases[0]
}
export function pluginCatalogListingDetails(
release: PluginCatalogRelease | undefined
): PluginCatalogListingDetails {
if (!release) {
return {}
}
const details: PluginCatalogListingDetails = { version: release.version }
for (const key of DETAIL_KEYS) {
const value = release[key]
if (value !== undefined) {
details[key] = value
}
}
return details
}
/** One catalog entry as clients see it: trust is derived here, never taken from the catalog. */
export function pluginMarketplaceListing(input: {
source: PluginMarketplaceRegisteredSource
snapshot: PluginMarketplaceCachedSnapshot
entry: PluginMarketplaceEntry
hostVersion: string | null
blocked: PluginKillListEntry | null
}): PluginMarketplaceListing {
const { source, snapshot, entry, blocked } = input
const release = offeredCatalogRelease(entry.releases, input.hostVersion)
const official =
isOfficialMarketplaceGitSource(source.source.url) &&
snapshot.marketplace.owner.toLowerCase() === OFFICIAL_MARKETPLACE_OWNER &&
isOfficialOrganizationGitSource(entry.source.url)
return {
marketplaceSourceId: source.id,
marketplaceName: snapshot.marketplace.name,
marketplaceOwner: snapshot.marketplace.owner,
marketplaceCommit: snapshot.marketplaceCommit,
pluginKey: entry.id,
source: entry.source,
...(entry.description ? { description: entry.description } : {}),
categories: entry.categories,
official,
bundled: false,
...(release ? { release, releases: entry.releases } : {}),
...pluginCatalogListingDetails(release),
...pluginCatalogListingImages(release),
...(blocked
? {
blockedByKillList: {
reason: blocked.reason,
...(blocked.advisoryUrl ? { advisoryUrl: blocked.advisoryUrl } : {})
}
}
: {})
}
}
@@ -3,7 +3,7 @@ import {
isOfficialMarketplaceGitSource,
isOfficialOrganizationGitSource,
isReservedPluginIdentity
} from '../../shared/plugins/plugin-marketplace'
} from '../../../shared/plugins/catalog/plugin-marketplace'
import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch'
import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store'
@@ -11,14 +11,20 @@ export function validateMarketplaceProvenance(
source: PluginMarketplaceRegisteredSource,
fetched: PluginMarketplaceFetchResult
): void {
if (
isOfficialMarketplaceGitSource(source.source.url) &&
fetched.marketplace.owner.toLowerCase() !== OFFICIAL_MARKETPLACE_OWNER
) {
const official = isOfficialMarketplaceGitSource(source.source.url)
if (official && fetched.marketplace.owner.toLowerCase() !== OFFICIAL_MARKETPLACE_OWNER) {
throw new Error('official marketplace metadata has an unexpected owner')
}
for (const entry of fetched.marketplace.plugins) {
if (isReservedPluginIdentity(entry.id) && !isOfficialOrganizationGitSource(entry.source.url)) {
if (!isReservedPluginIdentity(entry.id)) {
continue
}
if (!official) {
throw new Error(
`${entry.id} uses a name reserved for official Orca plugins; only Orca's official catalog may list it`
)
}
if (!isOfficialOrganizationGitSource(entry.source.url)) {
throw new Error(
`reserved plugin identity ${entry.id} must resolve to the stablyai organization`
)
@@ -5,8 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
PluginMarketplace,
PluginMarketplaceGitSource
} from '../../shared/plugins/plugin-marketplace'
import { OFFICIAL_MARKETPLACE_GIT_SOURCE } from '../../shared/plugins/plugin-marketplace'
} from '../../../shared/plugins/catalog/plugin-marketplace'
import { OFFICIAL_MARKETPLACE_GIT_SOURCE } from '../../../shared/plugins/catalog/plugin-marketplace'
import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch'
import { PluginMarketplaceService } from './plugin-marketplace-service'
import {
@@ -142,11 +142,23 @@ describe('PluginMarketplaceService', () => {
})
await expect(service.addSource(source())).rejects.toThrow(
'reserved plugin identity community.orca-secrets'
"community.orca-secrets uses a name reserved for official Orca plugins; only Orca's official catalog may list it"
)
await expect(service.listSources()).resolves.toEqual([])
})
it('refuses a third-party catalog that relists an official plugin', async () => {
const service = new PluginMarketplaceService({
pluginsDataDir: await tempRoot(),
fetcher: async () =>
fetched(
marketplace('Mirror', 'stablyai.orca-notes', 'https://github.com/stablyai/orca-notes.git')
)
})
await expect(service.addSource(source())).rejects.toThrow('reserved for official Orca plugins')
})
it('derives the Official badge only from the canonical marketplace and source organization', async () => {
const officialMarketplace: PluginMarketplace = {
name: 'Orca Plugins',
@@ -183,7 +195,7 @@ describe('PluginMarketplaceService', () => {
{
id: 'community.midnight',
source: { kind: 'git', url: 'https://github.com/community/midnight.git', ref: 'v1' },
categories: ['icon-themes', 'official']
categories: ['skills', 'official']
},
{
id: 'community.recipes',
@@ -199,7 +211,7 @@ describe('PluginMarketplaceService', () => {
const added = await service.addSource(source())
// The icon-theme pack is filtered out; only the supported recipe listing remains.
// The skills pack is filtered out; only the supported recipe listing remains.
await expect(service.listPlugins()).resolves.toEqual([
expect.objectContaining({ pluginKey: 'community.recipes' })
])
@@ -1,14 +1,11 @@
import {
OFFICIAL_MARKETPLACE_OWNER,
OFFICIAL_MARKETPLACE_GIT_SOURCE,
isOfficialMarketplaceGitSource,
isOfficialOrganizationGitSource,
isOfficialPluginIdentity,
isMarketplaceListingSupported,
pluginMarketplaceGitSourceSchema,
type PluginMarketplaceEntry,
type PluginMarketplaceGitSource
} from '../../shared/plugins/plugin-marketplace'
} from '../../../shared/plugins/catalog/plugin-marketplace'
import {
fetchPluginMarketplace,
type PluginMarketplaceFetchResult
@@ -19,12 +16,14 @@ import {
type PluginMarketplaceCachedSnapshot,
type PluginMarketplaceRegisteredSource
} from './plugin-marketplace-store'
import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list'
import type { PluginKillListEntry } from '../../../shared/plugins/catalog/plugin-kill-list'
import type { PluginImageCache } from '../plugin-image-cache'
import { validateMarketplaceProvenance } from './plugin-marketplace-provenance'
import { pluginMarketplaceErrorMessage } from './plugin-marketplace-error-message'
import type {
PluginMarketplaceListing,
PluginMarketplaceSourceState
import {
pluginMarketplaceListing,
type PluginMarketplaceListing,
type PluginMarketplaceSourceState
} from './plugin-marketplace-projection'
export type {
PluginMarketplaceListing,
@@ -39,6 +38,8 @@ export class PluginMarketplaceService {
private readonly store: PluginMarketplaceStore
private readonly fetcher: MarketplaceFetcher
private readonly getKillListEntry: (pluginKey: string) => PluginKillListEntry | null
private readonly hostVersion: string | null
private readonly imageCache: PluginImageCache | null
private readonly refreshChains = new Map<string, Promise<PluginMarketplaceSourceState>>()
private readonly sourceErrors = new Map<string, string>()
private officialSeedPromise: Promise<PluginMarketplaceSourceState> | null = null
@@ -49,7 +50,12 @@ export class PluginMarketplaceService {
fetcher?: MarketplaceFetcher
store?: PluginMarketplaceStore
getKillListEntry?: (pluginKey: string) => PluginKillListEntry | null
/** Picks which of an entry's releases to offer; without it the newest is offered. */
hostVersion?: string
imageCache?: PluginImageCache
}) {
this.hostVersion = options.hostVersion ?? null
this.imageCache = options.imageCache ?? null
this.store = options.store ?? new PluginMarketplaceStore(options.pluginsDataDir)
this.fetcher = options.fetcher ?? fetchPluginMarketplace
this.getKillListEntry = options.getKillListEntry ?? (() => null)
@@ -150,19 +156,23 @@ export class PluginMarketplaceService {
async listPlugins(): Promise<PluginMarketplaceListing[]> {
await this.waitForOfficialSeed()
return (await this.listCachedPlugins()).sort((left, right) =>
`${left.pluginKey}\0${left.marketplaceSourceId}`.localeCompare(
`${right.pluginKey}\0${right.marketplaceSourceId}`
)
)
}
/** Listings from the cached catalogs in catalog order, without waiting on any fetch. */
async listCachedPlugins(): Promise<PluginMarketplaceListing[]> {
const states = await this.listSnapshots()
return states
.flatMap(({ source, snapshot }) =>
snapshot.marketplace.plugins
// Exclude unsupported contribution kinds before users reach install.
.filter((entry) => isMarketplaceListingSupported(entry.categories))
.map((entry) => this.listingFromEntry(source, snapshot, entry))
)
.sort((left, right) =>
`${left.pluginKey}\0${left.marketplaceSourceId}`.localeCompare(
`${right.pluginKey}\0${right.marketplaceSourceId}`
)
)
return states.flatMap(({ source, snapshot }) =>
snapshot.marketplace.plugins
// Why: hide packs whose contribution kind this build no longer
// supports (themes/icons/skills) so users never reach a dead install.
.filter((entry) => isMarketplaceListingSupported(entry.categories))
.map((entry) => this.listingFromEntry(source, snapshot, entry))
)
}
async findPlugin(
@@ -232,6 +242,17 @@ export class PluginMarketplaceService {
): Promise<PluginMarketplaceFetchResult> {
const fetched = await this.fetcher(source)
validateMarketplaceProvenance(source, fetched)
const cache = this.imageCache
if (cache) {
await Promise.all(
(fetched.images ?? []).map((image) =>
cache.put(image).catch((error: unknown) => {
console.warn('[plugins] could not cache marketplace image:', error)
return null
})
)
)
}
return fetched
}
@@ -260,33 +281,13 @@ export class PluginMarketplaceService {
snapshot: PluginMarketplaceCachedSnapshot,
entry: PluginMarketplaceEntry
): PluginMarketplaceListing {
const official =
isOfficialMarketplaceGitSource(source.source.url) &&
snapshot.marketplace.owner.toLowerCase() === OFFICIAL_MARKETPLACE_OWNER &&
isOfficialPluginIdentity(entry.id) &&
isOfficialOrganizationGitSource(entry.source.url)
const blocked = this.getKillListEntry(entry.id)
return {
marketplaceSourceId: source.id,
marketplaceName: snapshot.marketplace.name,
marketplaceOwner: snapshot.marketplace.owner,
marketplaceCommit: snapshot.marketplaceCommit,
pluginKey: entry.id,
source: entry.source,
...(entry.description ? { description: entry.description } : {}),
categories: entry.categories,
official,
bundled: false,
...(entry.release ? { release: entry.release } : {}),
...(blocked
? {
blockedByKillList: {
reason: blocked.reason,
...(blocked.advisoryUrl ? { advisoryUrl: blocked.advisoryUrl } : {})
}
}
: {})
}
return pluginMarketplaceListing({
source,
snapshot,
entry,
hostVersion: this.hostVersion,
blocked: this.getKillListEntry(entry.id)
})
}
private stateFromSnapshot(
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace'
import type { PluginMarketplaceGitSource } from '../../../shared/plugins/catalog/plugin-marketplace'
import { marketplaceSourceId, PluginMarketplaceStore } from './plugin-marketplace-store'
const roots: string[] = []
@@ -111,14 +111,16 @@ describe('PluginMarketplaceStore', () => {
id: 'community.notes',
source: { kind: 'git', url: 'https://github.com/community/notes.git', ref: 'v1' },
categories: [],
release: {
version: '1.0.0',
commit: 'c'.repeat(40),
contentHash: 'f'.repeat(64),
execution: 'data',
minimumOrca: '1.4.0',
types: ['interface-language']
}
releases: [
{
version: '1.0.0',
commit: 'c'.repeat(40),
contentHash: 'f'.repeat(64),
execution: 'data',
minimumOrca: '1.4.0',
types: ['interface-language']
}
]
}
]
}
@@ -3,15 +3,15 @@ import { createReadStream } from 'node:fs'
import { mkdir, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { z } from 'zod'
import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile'
import { PLUGIN_COMMIT_PATTERN } from '../../../shared/plugins/installation/plugin-install-lockfile'
import {
pluginMarketplaceGitSourceSchema,
pluginMarketplaceListingSchema,
pluginMarketplaceSchema,
type PluginMarketplace,
type PluginMarketplaceGitSource
} from '../../shared/plugins/plugin-marketplace'
import { writePluginFileAtomically } from './plugin-atomic-file-write'
} from '../../../shared/plugins/catalog/plugin-marketplace'
import { writePluginFileAtomically } from '../installation/plugin-atomic-file-write'
export const PLUGIN_MARKETPLACE_SOURCE_LIMIT = 64
export const PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN = /^[0-9a-f]{32}$/
@@ -66,6 +66,7 @@ export class PluginMarketplaceStore {
private readonly snapshotDirectory: string
private sources: PluginMarketplaceRegisteredSource[] | null = null
private writeChain: Promise<void> = Promise.resolve()
private readonly snapshots = new Map<string, Promise<PluginMarketplaceCachedSnapshot | null>>()
constructor(pluginsDataDir: string) {
const root = join(pluginsDataDir, 'marketplaces')
@@ -109,6 +110,7 @@ export class PluginMarketplaceStore {
return next
})
if (removed) {
this.snapshots.delete(parsedId)
await rm(this.snapshotPath(parsedId), { force: true })
await rm(this.legacySnapshotPath(parsedId), { force: true })
}
@@ -130,16 +132,31 @@ export class PluginMarketplaceStore {
marketplace: input.marketplace
})
await writeAtomicJson(this.snapshotPath(input.source.id), snapshot)
this.snapshots.set(input.source.id, Promise.resolve(snapshot))
return snapshot
}
/** Parsed once per process; every write goes through this store, which keeps it current. */
async readSnapshot(sourceId: string): Promise<PluginMarketplaceCachedSnapshot | null> {
const parsedId = sourceIdSchema.parse(sourceId)
let snapshot = this.snapshots.get(parsedId)
if (!snapshot) {
snapshot = this.readSnapshotFromDisk(parsedId)
this.snapshots.set(parsedId, snapshot)
// Why: a failed read must be retried, not cached.
void snapshot.catch(() => this.snapshots.delete(parsedId))
}
return snapshot
}
private async readSnapshotFromDisk(
sourceId: string
): Promise<PluginMarketplaceCachedSnapshot | null> {
return (
(await this.readSnapshotFile(this.snapshotPath(parsedId), parsedId, cachedSnapshotSchema)) ??
(await this.readSnapshotFile(this.snapshotPath(sourceId), sourceId, cachedSnapshotSchema)) ??
(await this.readSnapshotFile(
this.legacySnapshotPath(parsedId),
parsedId,
this.legacySnapshotPath(sourceId),
sourceId,
legacySnapshotSchema
))
)
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest'
import { PLUGIN_MANIFEST_V2_SINCE } from '../../../shared/plugins/manifest/plugin-schema-field-docs'
import type { ValidDiscoveredPlugin } from '../plugin-discovery'
import { enableIfDataPlugin } from './plugin-one-click-enable'
function installed(execution: 'data' | 'declarative', contributes: Record<string, unknown>) {
const plugin: ValidDiscoveredPlugin = {
pluginKey: 'acme.demo',
rootDir: '/plugins/acme.demo/hash',
manifest: pluginManifestSchema.parse({
manifestVersion: 2,
execution,
id: 'demo',
publisher: 'acme',
name: 'Demo',
version: '1.0.0',
engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` },
contributes
}),
consentFingerprint: 'sha256-installed',
contentHash: 'a'.repeat(64),
isDev: false
}
return plugin
}
const theme = { themes: [{ id: 'dusk', title: 'Dusk', path: 'dusk.json' }] }
const command = { commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] }
describe('enableIfDataPlugin', () => {
it('approves a content-only plugin with the installed fingerprint', async () => {
const approve = vi.fn(async () => undefined)
await expect(
enableIfDataPlugin({
pluginKey: 'acme.demo',
installedFingerprint: 'sha256-installed',
findValidPlugin: () => installed('data', theme),
approve
})
).resolves.toEqual({ needsReview: false })
expect(approve).toHaveBeenCalledWith('acme.demo', 'sha256-installed')
})
it.each([
['a plugin that adds instructions', installed('declarative', command), 'sha256-installed'],
['bytes other than the ones just installed', installed('data', theme), 'sha256-other'],
['a plugin that failed discovery', null, 'sha256-installed'],
['a development override', { ...installed('data', theme), isDev: true }, 'sha256-installed']
])('leaves %s for review', async (_label, plugin, installedFingerprint) => {
const approve = vi.fn(async () => undefined)
await expect(
enableIfDataPlugin({
pluginKey: 'acme.demo',
installedFingerprint,
findValidPlugin: () => plugin,
approve
})
).resolves.toEqual({ needsReview: true })
expect(approve).not.toHaveBeenCalled()
})
it('approves only the fingerprint the user saw at preview', async () => {
const approve = vi.fn(async () => undefined)
const run = (expectedFingerprint: string) =>
enableIfDataPlugin({
pluginKey: 'acme.demo',
installedFingerprint: 'sha256-installed',
expectedFingerprint,
findValidPlugin: () => installed('data', theme),
approve
})
await expect(run('sha256-installed')).resolves.toEqual({ needsReview: false })
expect(approve).toHaveBeenCalledOnce()
approve.mockClear()
await expect(run('sha256-previewed-earlier')).resolves.toEqual({ needsReview: true })
expect(approve).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,32 @@
import { pluginExecutionProfile } from '../../../shared/plugins/contributions/plugin-execution-profile'
import type { ValidDiscoveredPlugin } from '../plugin-discovery'
/**
* One-click install for content-only plugins. Why main decides: the click counts as consent only
* for plugins that cannot run anything, and that must be read from the installed, verified
* manifest, never from what the renderer claims. Anything else stays pending for review.
*/
export async function enableIfDataPlugin(input: {
pluginKey: string
/** Fingerprint of the bytes just installed; approval binds to exactly these. */
installedFingerprint: string
/** The fingerprint the user was shown at preview; when given, nothing else is approved. */
expectedFingerprint?: string
findValidPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null
approve: (pluginKey: string, reviewedFingerprint: string) => Promise<void>
}): Promise<{ needsReview: boolean }> {
const plugin = input.findValidPlugin(input.pluginKey)
if (
!plugin ||
// Why: a development override shadows the install, so it is not the bytes just reviewed.
plugin.isDev ||
plugin.consentFingerprint !== input.installedFingerprint ||
(input.expectedFingerprint !== undefined &&
input.expectedFingerprint !== input.installedFingerprint) ||
pluginExecutionProfile(plugin.manifest) !== 'data'
) {
return { needsReview: true }
}
await input.approve(input.pluginKey, plugin.consentFingerprint)
return { needsReview: false }
}
@@ -4,9 +4,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace'
import { getUserPluginsDir } from './plugin-discovery'
import { readPluginLockfile } from './plugin-install'
import type { PluginMarketplaceGitSource } from '../../../shared/plugins/catalog/plugin-marketplace'
import { getUserPluginsDir } from '../plugin-discovery'
import { readPluginLockfile } from '../installation/plugin-install'
import { PluginMarketplaceInstaller } from './plugin-marketplace-installer'
import { PluginMarketplaceService } from './plugin-marketplace-service'
@@ -0,0 +1,29 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { PluginReleaseFirstSeenStore } from './plugin-release-first-seen-store'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('plugin release first-seen store', () => {
it('keeps the first sighting across restarts and restarts the clock for a new release', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-first-seen-'))
roots.push(root)
expect(await new PluginReleaseFirstSeenStore(root).firstSeen('a.b', '1.1.0@c', 100)).toBe(100)
const reopened = new PluginReleaseFirstSeenStore(root)
expect(await reopened.firstSeen('a.b', '1.1.0@c', 500)).toBe(100)
expect(await reopened.firstSeen('a.b', '1.2.0@d', 900)).toBe(900)
})
it('treats a damaged file as nothing seen yet', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-first-seen-'))
roots.push(root)
await writeFile(join(root, 'plugin-release-first-seen.json'), '{not json')
expect(await new PluginReleaseFirstSeenStore(root).firstSeen('a.b', '1.0.0@c', 7)).toBe(7)
})
})
@@ -0,0 +1,53 @@
import { mkdir, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { z } from 'zod'
import { writePluginFileAtomically } from '../installation/plugin-atomic-file-write'
const firstSeenSchema = z.record(
z.string().max(256),
z.object({ release: z.string().max(512), firstSeenAt: z.number().finite().nonnegative() })
)
/**
* When this Orca first saw each plugin's newest catalog release. Why local: catalogs carry no
* listed-at time, and the auto-update hold-back must survive restarts so takedowns get time to land.
*/
export class PluginReleaseFirstSeenStore {
private readonly filePath: string
private cache: z.infer<typeof firstSeenSchema> | null = null
constructor(pluginsDataDir: string) {
this.filePath = join(pluginsDataDir, 'plugin-release-first-seen.json')
}
/** The first time `release` was seen for this plugin, recording `now` when it is new. */
async firstSeen(pluginKey: string, release: string, now: number): Promise<number> {
const records = await this.read()
const known = records[pluginKey]
if (known?.release === release) {
return known.firstSeenAt
}
records[pluginKey] = { release, firstSeenAt: now }
await mkdir(dirname(this.filePath), { recursive: true })
await writePluginFileAtomically(this.filePath, `${JSON.stringify(records, null, 2)}\n`)
return now
}
private async read(): Promise<z.infer<typeof firstSeenSchema>> {
if (!this.cache) {
const raw = await readFile(this.filePath, 'utf8').catch(() => null)
const parsed = raw ? firstSeenSchema.safeParse(safeJson(raw)) : null
// Why reset on damage: a lost record only restarts that release's hold-back.
this.cache = parsed?.success ? parsed.data : {}
}
return this.cache
}
}
function safeJson(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return null
}
}

Some files were not shown because too many files have changed in this diff Show More