Skip to content

Extension system with server-backed panels - #397

Merged
Anton-Horn merged 25 commits into
mainfrom
feat/extension-system
Jul 5, 2026
Merged

Extension system with server-backed panels#397
Anton-Horn merged 25 commits into
mainfrom
feat/extension-system

Conversation

@Anton-Horn

@Anton-Horn Anton-Horn commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Adds the extension system end to end: extensions add panels to Cate via a web frontend and an optional local server, served through a token-injecting proxy.

What's in it

  • Runtime capabilities: HTTP/WS tunneling + a managed server host (src/runtime/capabilities/{server,tunnel}.ts)
  • Main: ExtensionManager, ExtensionServerManager, proxy server, catalog download/install, and the cateHost reverse-API dispatch + per-extension storage
  • Preload: cateHost bridge injected into extension webview guests
  • Renderer: ExtensionPanel, ExtensionsSettings, useCateHostActionResponder
  • Shared: manifest/types, cate-host API typings, IPC channels
  • Docs: docs/extensions.md

cate-extensions

The extension catalog is its own repo (0-AI-UG/cate-extensions). It's gitignored here and only checked out locally for offline dev/tests. The two suites that use it (distribution.test.ts, kitchensinkServer.test.ts) skip when that checkout is absent. The Kitchen Sink demo's TS conversion is in 0-AI-UG/cate-extensions#1.

Tests

typecheck clean; extension + runtime-capabilities suites green.

End-to-end extension system that adds panels to Cate via a web frontend
and an optional local server, served through a token-injecting proxy.

- runtime capabilities: HTTP/WS tunneling + a managed server host
  (src/runtime/capabilities/{server,tunnel}.ts)
- main: ExtensionManager, ExtensionServerManager, proxy server, catalog
  download/install, and the cateHost reverse-API dispatch + storage
- preload: cateHost bridge injected into extension webview guests
- renderer: ExtensionPanel, ExtensionsSettings, useCateHostActionResponder
- shared: manifest/types, cate-host API typings, IPC channels
- docs/extensions.md describing the system

The extension catalog lives in its own repo (0-AI-UG/cate-extensions);
it's gitignored here and only checked out locally for dev/tests, which
skip when that checkout is absent.
@Anton-Horn
Anton-Horn force-pushed the feat/extension-system branch from b6f994b to 778d1cd Compare June 16, 2026 19:10
- ci.yml: check out + build 0-AI-UG/cate-extensions before unit tests so
  the distribution and kitchensink-server suites run against the real
  artifact instead of skipping.
- kitchensinkServer.test: derive the server entry from the manifest
  command (works for server.js or compiled dist/server.js), guard the
  manifest read so the file imports without the catalog, and annotate the
  WebSocket socket 'data' chunk as Buffer (fixes the CI typecheck failure
  under a stricter @types/node).
- distribution.test: read the built dist/catalog index and assert the
  manifest's declared server entry exists, layout/language-agnostic.
- eslint: ignore examples/ and the gitignored cate-extensions/ checkout
  (their own JS/TS projects), plus generated dist-runtime/ and local
  .cate/ worktrees, so lint only covers app source. This was the ubuntu
  CI lint failure (examples/ sample extensions tripping no-undef).
- ci.yml: run the catalog checkout + build only on non-Windows. The
  catalog repo's bash build.sh resolves a manifest path that breaks under
  Git-Bash/MSYS on Windows; the extension tests skipIf the catalog is
  absent, so the Windows leg stays green.
Add cate.agent.* so an enabled extension can run one agent turn through
the bundled pi. Gated by a dedicated `agent` scope, first-use user
consent, and one run per extension at a time. The run is an owner-bound,
visible agent session that resolves with the final assistant text
(on pi's terminal agent_end). Reachable from both server-backed
extensions (CATE_API) and frontend panels (cateHost bridge).

Also lands the production-readiness fixes from the review: manifest
scope enforcement, sender-derived guest identity, workspace path
confinement, verified and traversal-safe artifact extraction, and
quit/crash process hygiene.
…missions

Remove documentation for cate.* methods that aren't implemented yet
(workspace/theme.onChange, panel lifecycle + setBadge, editor getters,
the commands namespace, and canvas methods beyond createPanel). They
return 'unsupported' today; re-document if/when implemented.

Settings: list each extension's declared cateApi scopes as readable
permission chips (catalog + sideload rows), with the `agent` scope
highlighted as the most sensitive.
Replace the one-shot cate.agent.run with a turn-based session API:
open() returns a handle (pi's session file, so a conversation resumes
with no Cate-side state), send() runs one turn and returns the full
assistant message, dispose() tears down the live client. run() stays as
open->send->dispose sugar.

Drives the same create()/PiRpcClient/dispose() path a panel uses, so pi
owns all conversation state and Cate only holds the live handle. One live
session per extension, one turn in flight, the anti-runaway guard.

Also surface failed turns: a turn that ends on stopReason error (an
unsupported model, auth, bad request) now rejects with pi's reason instead
of returning silent empty text, and the handler passes that reason to the
extension. This was the cause of the persistent "(no text)".
… API

kitchensinkPanel.test.tsx runs the real shipped panel script in jsdom against
a mock cate bridge (mirrors frontendkitPanel) and drives the full surface,
including the agent flow: open-on-first-send, handle persisted to storage,
resume from a stored handle, error rendering, and dispose-on-end.

cateHost.test.tsx locks the preload wire contract: each cate.agent.* call maps
to the right { method, args } and guest identity on CATE_HOST_INVOKE. This was
the one untested layer between an extension calling cate.* and dispatch.
The real extensions live in the separate cate-extensions repo, not in-tree.
A canvas node persisted in `.cate` with a missing or invalid `size`/`origin`
(corrupt, half-written, or older-schema data) was seeded straight into the
store, so CanvasNode/useNodeResize crashed the whole canvas with an opaque
"Cannot read properties of undefined (reading 'width')".

loadWorkspaceCanvas now sanitizes loaded geometry: repair origin/size/counters
where safe, drop the unrecoverable (no panelId), guard viewport/zoom, and log
what changed. The next save persists the repaired state, healing the file.
…creen

Add a `files.drop` extension scope: an extension's isolated webview guest
can't see Cate's in-renderer file-explorer drags, and an HTML overlay can't
sit reliably above a webview. So while a Cate drag is in flight the host makes
the webview pointer-events:none, hit-tests the drop against the panel, reads
each file host-side (the user's drag authorises it, the guest never touches
the filesystem), and forwards the content on the same cate:event channel the
preload listens on. OS-file drops reach the guest's own DOM natively. Content
is byte-capped (truncated flag); guests subscribe via cate.files.onDrop.

Also exempt webview-backed panels (extensions) from viewport culling:
keepMountedOffscreen on the panel definition keeps their canvas nodes mounted
even when scrolled off-screen, so panning away no longer destroys the guest
process and resets in-page session state. selectVisibleNodeIds is split out as
a pure, unit-tested core with a cached keep-alive set.
distribution.test.ts hardcoded kitchensink/frontendkit, now dev-only and
excluded from the user-facing catalog; make it catalog-agnostic (pick a
server-backed and a frontend-only entry from the built index). frontendkit
panel test asserted a --fk-accent var the shipped app.js never sets; check
--fk-fg instead.
Security/reliability fixes from a production-readiness audit of the
extension system:

- validate manifest id/version as filesystem-safe slugs at the
  normalizeManifest chokepoint (blocks path traversal into stage/
  storage/install paths)
- tear down extension server sessions on runtime disconnect via a new
  runtimes.onDisconnected hook + ExtensionServerManager.disposeForRuntime
  and provisioned-cache invalidation (fixes permanent 502 after a
  daemon drop/reconnect); keyed on runtime.id, no local/remote branch
- require remote-sourced catalog artifacts to be http(s) + sha256; the
  local-artifact sha256 exemption now keys on the source's trust class,
  not the artifactUrl scheme
- add AbortSignal timeouts + streaming byte caps to catalog and artifact
  fetches
- bound cate.agent.open resume paths to the caller's workspace
- harden daemon tarball extraction: bare-name listing (spaces-safe) plus
  a post-extraction realpath containment check
- destroy the proxy tunnel Duplex on response close (per-request leak)
- add a Settings warning to only add trusted catalog sources

Full extension + runtime suite green (301 tests); each fix ships with
regression coverage.
Node and Panel error boundaries duplicated getDerivedStateFromError / componentDidCatch / reset-on-key-change logic. Pull the common lifecycle into BaseErrorBoundary and let each keep only its fallback + logging.
Guard against corrupt/missing persisted geometry and reused slots when restoring canvas nodes.
Pin the extension guest preload to the canonical cateHost bundle instead of trusting the renderer-supplied path; make per-runtime storage stores disposable so watchers aren't stranded on disconnect; add coverage for subscribe, proxy identity/start, storage, and webSecurity.
The isolated extension guest never sees the host stylesheet, so every
extension rendered Chromium's default light scrollbars inside the dark
app. Inject the app's scrollbar treatment at dom-ready with the current
theme's thumb colors baked in. Exported guestScrollbarCss for tests.
The official catalog now ships as assets on the cate-extensions `catalog`
GitHub Release instead of GitHub Pages. Update the default source URL.
A local catalog entry (sourceIsLocal) now re-stages and re-extracts on
every provision instead of short-circuiting on the host .ok marker, so
source edits (including manifest scope changes) land on reload without a
version bump or a manual reinstall. Remote installs keep the .ok
idempotency so their bytes upload at most once per version.
…g + cleanups

Correctness:
- proxyServer: reject backslash-delimited traversal and re-check path
  containment after the host join (Windows arbitrary-file-read)
- ExtensionServerManager: reuse the live server during GRACE instead of
  spawning a duplicate and orphaning the old process
- ExtensionManager: on force re-provision serve the pinned staged bytes
  rather than downloading latest into the pinned version dir; reinstall
  repairs the installed version in place without bumping it
- storage: fire onChange for in-app writes; flush pending writes
  synchronously on quit and on dispose so the last set() is not lost
- canvasStore: key the keep-alive cache on dock membership, not node
  count, so a cross-node tab move cannot cull a kept-mounted webview
- useCateHostActionResponder: normalize the remote-workspace locator
  before the containment check so absolute paths resolve
- cate-host-api: align createPanel options with what the responder honors

Cleanup and perf:
- ExtensionServerManager: unify the three teardown loops
- cateApiHandlers: extract guest-auth and once-destroyed helpers
- ExtensionsSettings: single row component, drop the double-fetch
- parallelize provisioning and staged-version scans
…ills

- skills/cate-extension: self-contained guide for building, testing, and
  publishing Cate extensions (manifest, scopes, host API, kit, server
  contract, sideload/local-catalog dev loops, catalog submission)
- registry/sources.json: add 0-AI-UG/cate (path skills/) as a curated source
- build-skills-index: firstParty flag exempts our own sources from the
  10k-star floor; description floor still applies
- docs/extensions.md: sync reverse-API block with cate-host-api.d.ts
  (createPanel options, session-based agent API)
…cel pool

The pool reused any covering ancestor tree for a new subscriber, but a
workspace-root tree natively prunes hidden dirs, so a subscriber under
.cate attached to it and silently never received an event. findCovering
now skips trees whose ignore globs prune the prefix; such subscribers get
their own tree rooted at the prefix, where the relative globs are inert.

Extension storage watched storage.json by file path, which parcel cannot
root a watcher at. It now watches the parent dir (created through the
runtime first, so local and remote arm identically), filters events by
basename, and drops same-content reloads.
…Windows CI

Windows runners hand out 8.3 short paths (C:\Users\RUNNER~1) in TEMP, so
roots got registered in short form while validatePathStrict resolves paths
through the native realpath, which expands to the long form. The long form
matched neither the tmpdir carve-out nor any registered root, so every
strict check under temp failed on Windows. Same class of mismatch exists on
macOS where /tmp resolves to /private/tmp.

Store each allowed root under both its lexical and native-realpath form and
check the tmpdir carve-out the same way.

Also fix storage.test.ts to build its fake paths with path.join: the store
uses hostJoin (path.join for local), so hardcoded /proj/... forward-slash
keys never matched on Windows.
…ored paths

install.test expected path.join dest for the remote runtime, but hostJoin
uses posix joins for remote hosts, so on a Windows client the dest tail is
forward-slashed. cateApiHandlers.test expected the raw posix resume handle,
but boundedResumePath canonicalizes via the local host's path flavor and
returns backslashes on Windows.
@Anton-Horn
Anton-Horn merged commit a606882 into main Jul 5, 2026
3 checks passed
@Anton-Horn
Anton-Horn deleted the feat/extension-system branch July 5, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant