Skip to content

feat: Chamber Plugin SPI for onboarding override#388

Open
patschmittdev wants to merge 7 commits into
masterfrom
feat/plugin-spi
Open

feat: Chamber Plugin SPI for onboarding override#388
patschmittdev wants to merge 7 commits into
masterfrom
feat/plugin-spi

Conversation

@patschmittdev

Copy link
Copy Markdown
Collaborator

Summary

Adds a small Service Provider Interface (SPI) that lets a separate, trusted package override Chamber's built-in Genesis onboarding without any plugin-specific code living in Chamber. Both seams default to a no-op, so the base Chamber build is unchanged unless a plugin is configured.

The contract lives in a new workspace-internal package, @chamber/plugin-api.

Why

Chamber needs an extension seam so downstream or branded distributions can replace the first-run experience without forking the whole app or leaking proprietary code into Chamber. This PR adds the generic seam only; it ships no consumer.

The two seams

Seam When Selected by Default
Renderer Build time CHAMBER_PLUGIN_RENDERER (resolved by the virtual:chamber-plugin Vite module) no-op plugin
Main Runtime chamberPlugin config field or CHAMBER_PLUGIN env var not loaded
  • Renderer: GenesisGate renders plugin.onboarding ?? GenesisFlow.
  • Main: PluginHost dynamic-imports the configured plugin after services and IPC are wired, calls registerMain once, and logs-and-swallows any failure so a broken plugin never blocks boot.

Trust model

This is a trusted-plugin model, not a sandbox. The controls are (1) opt-in loading and (2) a default-narrow capability handoff. A renderer plugin is bundled into the renderer and a main plugin runs with full main-process privileges, so only configure packages you trust. The genuinely enforced boundaries elsewhere (the chatroom ApprovalGate, the Electron sandbox flags, and seed-document path validation) are unchanged.

Capabilities

A renderer onboarding component receives OnboardingProps: onComplete, createMind, and optional serveOnboardingCanvas. Chamber owns all Electron access; the plugin only describes intent.

createMind treats mind creation as atomic and document seeding as best-effort: on seed failure it still selects the new mind and returns { success: true, mindId, seedError }; success: false means no mind was created. The optional seed document is written to a fixed, Chamber-owned path (.chamber/onboarding.md) that Chamber writes but never reads back.

Consumption

@chamber/plugin-api is a workspace-internal, types-only contract (private, raw-source export, matching @chamber/shared and @chamber/client). It is resolved in source by Chamber and by trusted plugin packages built within the workspace, and is not published to npm.

Testing

  • Type-level contract tests (expectTypeOf), PluginHost load / no-op / failure-isolation, the Vite virtual-module resolver, ConfigService normalization, seedOnboardingDocument path-safety, and GenesisGate fallback + plugin-override + createMind failure-path tests.
  • tsc, eslint, dependency-cruiser, actionlint, and markdownlint all clean.

Docs

See ai-docs/plugin-spi.md for both seams, the env vars and config, the trust model, the createMind contract, and minimal renderer and main authoring examples.

Patrick Schmitt and others added 7 commits June 10, 2026 13:30
Introduce a generic Service Provider Interface so a separate, trusted
TypeScript package can override Chamber's base Genesis/onboarding flow
without any enterprise-specific code living in Chamber.

Contract (types only): @chamber/plugin-api exposes ChamberRendererPlugin,
OnboardingProvider/OnboardingProps, ChamberMainPlugin, MainPluginContext,
and defineRendererPlugin/defineMainPlugin helpers.

Renderer path (build-time): a Vite virtual module `virtual:chamber-plugin`
resolves to a no-op plugin by default, or to the entry named by
CHAMBER_PLUGIN_RENDERER for enterprise builds. ChamberPluginProvider exposes
the active plugin via useChamberPlugin(); GenesisGate renders
`plugin.onboarding ?? GenesisFlow`, preserving default behavior.

Main path (runtime): PluginHost dynamic-imports an optional trusted plugin
named by config.chamberPlugin or CHAMBER_PLUGIN, calls registerMain(ctx)
once after services/IPC are wired, and logs-and-swallows any failure so a
broken plugin never blocks boot. AppConfig.chamberPlugin is normalized by
ConfigService.

Both paths default to no-op, so the base Chamber build is unchanged.

Tests: plugin-api contract (expectTypeOf), Vite resolver, PluginHost
load/no-op/failure isolation, GenesisGate fallback + plugin-override,
ConfigService chamberPlugin normalization. typecheck, eslint, and
deps:check are clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend the Chamber Plugin SPI so a plugin onboarding surface can create a mind
and seed an onboarding document into it, without touching Electron APIs.

- @chamber/plugin-api: OnboardingProps gains a typed `createMind` capability
  plus OnboardingMindRequest ({ templateId, marketplaceId?, seedDocument? }) and
  OnboardingMindResult. The built-in GenesisFlow remains a valid fallback (it
  ignores the extra prop), so default onboarding is unchanged.
- New path-safe capability: seedOnboardingDocument writes a document to a fixed,
  Chamber-owned path (.chamber/soul-code.md) inside a mind. The caller supplies
  only content (no path-traversal surface); escape + symlink checks mirror
  MindProfileService, and the write is atomic. Exposed via genesis:seedDocument
  IPC + preload, gated through MindManager mind-path resolution.
- GenesisGate implements createMind: install the template, optionally seed the
  document, then select the new mind (mirrors the built-in post-create flow).
  Chamber owns all Electron access; the plugin only describes intent.

Tests: plugin-api contract for createMind/request/result; seedOnboardingDocument
unit tests (write, overwrite, empty, oversize, missing dir, symlink reject);
GenesisGate test that createMind installs + seeds + selects; ipc-channels
assertion for the new channel. Browser API + test mocks gain seedDocument.

Validated: tsc --noEmit, eslint, deps:check, and targeted vitest all green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Node's ESM dynamic import() rejects raw absolute paths, notably Windows
drive-letter paths like 'C:\foo\bar.js' (parsed as a 'c:' protocol). The
PluginHost docstring promises "package name or absolute path", but the
implementation silently no-op'd absolute paths because the importer's
throw was log-and-swallowed by the resilience contract.

This is the path enterprise consumers will most often use when
configuring 'chamberPlugin' to a fully-qualified local install location.
The renderer override surface was unaffected (Vite has its own
resolution); only the main-side dynamic import was broken.

Fix: normalize via pathToFileURL when path.isAbsolute(target) is true.
Bare package specifiers ('@scope/pkg', 'pkg') pass through unchanged so
npm resolution still works. Added two unit tests covering both branches.

Discovered via live end-to-end verification on Windows where a raw
absolute path in CHAMBER_PLUGIN silently bypassed registerMain. With the
fix, the same path correctly loads the plugin and invokes registerMain
with the expected MainPluginContext.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eed path

Address two blocker findings from a Clean-Architecture audit of the Plugin
SPI before it goes into public Chamber. Both are about contract honesty, not
mechanics; no runtime behavior changes except the seed file name.

H1 -- trusted-plugin framing, not a security boundary. The contract comments
described the narrow handoff as a "security boundary" and said the plugin
"never touches Electron APIs directly". Neither seam is sandboxed: a renderer
plugin is bundled into the renderer and shares window.electronAPI; a main
plugin is dynamic-imported with full Node/Electron privileges. Reword
main-plugin.ts, PluginHost.ts, and renderer-plugin.ts to state the real
controls plainly -- opt-in loading plus a default-narrow handoff, not
containment. Keep the one genuinely isolated bit accurate: the canvas HTML
served by serveOnboardingCanvas runs at a separate origin.

H2 -- remove the leaked enterprise concept from the generic SPI. The seed
destination was hardcoded to .chamber/soul-code.md and the docs used "Soul
Code" / "enterprise agent" examples, even though nothing in Chamber (or the
enterprise variant, plugin, or Pulse mind) reads that file. Rename the
constant to the neutral .chamber/onboarding.md, document that Chamber writes
but never reads it (a plugin's own template/agent consumes it), and drop the
enterprise wording from the contract and the genesis IPC comment. Neutralize
the document content in the tests.

Verified: typecheck clean; eslint clean on changed files; the plugin-api
contract, PluginHost, seedOnboardingDocument, and GenesisGate plugin tests
pass (29 tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… state consistent

createMind previously returned early when seedOnboardingDocument failed,
without dispatching SET_MINDS / SET_ACTIVE_MIND. But the mind was already
created and set active in the main process, so the renderer store and main
diverged: a mind existed and was active in main yet was invisible in the UI.

Treat mind creation as the atomic deliverable and document seeding as
best-effort enrichment (seedDocument is already optional). On seed failure
createMind now still syncs the renderer to the main process and returns
{ success: true, mindId, seedError }. success:false is reserved for "no mind
was created" (the gate stays open). This also removes the prior "mindId on a
failure result" ambiguity.

Adds OnboardingMindResult.seedError to the public contract (optional, only
present on success) and locks it with a type-level test. Adds GenesisGate
tests for both failure paths: template-install failure (no sync, gate stays
open) and seed failure (mind kept + selected, renderer synced, seedError
surfaced).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add ai-docs/plugin-spi.md covering both seams (renderer virtual module plus
CHAMBER_PLUGIN_RENDERER, main PluginHost plus chamberPlugin / CHAMBER_PLUGIN),
the capabilities and the createMind result contract, the trusted-plugin model
(opt-in plus a default-narrow handoff, not a sandbox), the
.chamber/onboarding.md write-but-not-read seed behavior, and minimal
renderer/main authoring examples.

Make the @chamber/plugin-api consumption decision explicit in its package.json
description: a workspace-internal, types-only contract resolved in source
(matching @chamber/shared and @chamber/client), not published to npm. Add a
CHANGELOG entry under Unreleased.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-ups from the Plugin SPI audit (all non-blocking):

- L1/L4: replace the duplicated, index-based plugin logger closures in the
  composition root with a single explicit level-mapping helper, so adding a
  PluginLogLevel can never silently throw on a missing logger method.
- L3: extract the createMind orchestration out of GenesisGate into a
  useCreateMind hook (matching the renderer hooks/ convention), restoring SRP
  in the gate component.
- L5: add trailing newlines to packages/plugin-api/package.json and tsconfig.json.
- Test hygiene: scrub enterprise-specific fixtures (EnterpriseOnboarding, pulse,
  genesis-minds-enterprise) from the public GenesisGate plugin test in favor of
  neutral example identifiers, and neutralize the example comment in the
  main-process plugin wiring.

Skipped: L2 (GenesisFlow receives an unused createMind prop) is a benign,
type-safe ISP wrinkle whose only fix adds JSX branching that hurts readability.
L6 was already resolved by the seed-failure contract change.

Validated: typecheck, eslint, deps:check, actionlint, markdownlint all clean;
68 SPI/GenesisGate tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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